@linxin666/dsh-client-ui-task-board 0.2.0 → 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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +349 -73
- package/lib/client.js.map +1 -1
- package/lib/index.js +158 -28
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +3 -3
- 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 +9 -2
- package/lib/types/core/schedule.d.ts +7 -0
- package/lib/types/host-ledger.d.ts +10 -0
- package/lib/types/host-routes.d.ts +3 -1
- package/lib/types/host-runner.d.ts +12 -1
- package/lib/types/host-service.d.ts +5 -1
- package/lib/types/protocol.d.ts +6 -0
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/TaskBoardSettingsCard.tsx +10 -10
- package/src/client/board/TaskBoard.tsx +3 -3
- package/src/client/board/TaskCard.tsx +1 -0
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +12 -3
- 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 +20 -3
- package/src/core/schedule.ts +57 -10
- package/src/host-ledger.ts +64 -3
- package/src/host-routes.ts +11 -3
- package/src/host-runner.ts +24 -6
- package/src/host-service.ts +24 -7
- package/src/protocol.ts +7 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Shared sidebar entry injection core.
|
|
4
|
+
*
|
|
5
|
+
* dsh's sidebar shell exposes no slot an external plugin can register into,
|
|
6
|
+
* so the entry row is injected between the shell's New Session button and the
|
|
7
|
+
* workspace browser. The injection self-heals: a MutationObserver watches the
|
|
8
|
+
* sidebar root and re-inserts the row whenever a React re-render displaces it
|
|
9
|
+
* (re-insertion happens in the same frame, before paint, so no flicker).
|
|
10
|
+
*
|
|
11
|
+
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
12
|
+
* reconciliation; the view it toggles is a separate root owned by the caller.
|
|
13
|
+
*
|
|
14
|
+
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
15
|
+
* edit the shared source and re-run the sync instead of editing a copy.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Per-package configuration for one sidebar entry row. */
|
|
19
|
+
export interface SidebarEntryOptions {
|
|
20
|
+
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
21
|
+
rowAttribute: string
|
|
22
|
+
/** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
|
|
23
|
+
rowSelector: string
|
|
24
|
+
/**
|
|
25
|
+
* L2 semantic-attribute plugin id (issue #506, enum table:
|
|
26
|
+
* skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
|
|
27
|
+
* outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
|
|
28
|
+
* leaves the row without semantic attributes.
|
|
29
|
+
*/
|
|
30
|
+
plugin?: string
|
|
31
|
+
/** Inline icon markup (matches the shell's 16px nav-icon look). */
|
|
32
|
+
icon: string
|
|
33
|
+
/** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
|
|
34
|
+
css: Record<string, string>
|
|
35
|
+
/** Localized row label (aria-label + visible text). */
|
|
36
|
+
label(): string
|
|
37
|
+
/** Optional localized tooltip (title attribute). */
|
|
38
|
+
tooltip?(): string
|
|
39
|
+
/** Click action (open/toggle the owning panel). */
|
|
40
|
+
onToggle(): void
|
|
41
|
+
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
|
|
42
|
+
position: 'before' | 'after'
|
|
43
|
+
/**
|
|
44
|
+
* Selectors of the sibling plugin entry rows this package orders against
|
|
45
|
+
* (its own row included — the placement guard excludes a row that is
|
|
46
|
+
* already inside the root). Each package passes the same list it used
|
|
47
|
+
* before the consolidation so the rendered order stays stable.
|
|
48
|
+
*/
|
|
49
|
+
familySelectors: readonly string[]
|
|
50
|
+
/** Optional active-state bridge; highlights the row while the panel is open. */
|
|
51
|
+
active?: {
|
|
52
|
+
subscribe(listener: () => void): () => void
|
|
53
|
+
isOpen(): boolean
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
58
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
59
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
60
|
+
if (column === null) return undefined
|
|
61
|
+
// Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
|
|
62
|
+
// Prefer the element that owns the logo row — the real sidebar UI root —
|
|
63
|
+
// and fall back to the column's first child for legacy shells.
|
|
64
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
65
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
|
|
69
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
70
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
71
|
+
if (nested !== null) return nested
|
|
72
|
+
for (const child of root.children) {
|
|
73
|
+
if (child.tagName === 'BUTTON') return child as HTMLButtonElement
|
|
74
|
+
}
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
79
|
+
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
|
|
80
|
+
const entry = document.createElement('button')
|
|
81
|
+
entry.type = 'button'
|
|
82
|
+
entry.setAttribute(options.rowAttribute, '')
|
|
83
|
+
if (options.plugin !== undefined) {
|
|
84
|
+
entry.setAttribute('data-dsh-plugin', options.plugin)
|
|
85
|
+
entry.setAttribute('data-dsh-part', 'sidebar-entry')
|
|
86
|
+
}
|
|
87
|
+
entry.className = options.css['entry'] ?? ''
|
|
88
|
+
entry.setAttribute('aria-label', options.label())
|
|
89
|
+
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
|
|
90
|
+
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
|
|
91
|
+
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
|
|
92
|
+
entry.addEventListener('click', options.onToggle)
|
|
93
|
+
return entry
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Re-insert the entry after the New Session row (before the browser region). */
|
|
97
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
|
|
98
|
+
const button = newSessionButton(root)
|
|
99
|
+
if (button === undefined) return false
|
|
100
|
+
if (entry.parentElement !== root) {
|
|
101
|
+
// Position relative to the family block (entries injected by sibling
|
|
102
|
+
// plugins), never relative to transient logoRow geometry: every family
|
|
103
|
+
// plugin that self-heals during a re-render then lands in the same
|
|
104
|
+
// relative order, so the entries cannot swap positions regardless of
|
|
105
|
+
// observer callback order or of shell wrapper changes. There is no
|
|
106
|
+
// append-to-end fallback: appending at the end would randomly reorder
|
|
107
|
+
// the block after a shell re-render.
|
|
108
|
+
const row = button.closest('[class*="logoRow"]')
|
|
109
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
110
|
+
const family = Array.from(root.children).filter(
|
|
111
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
|
|
112
|
+
)
|
|
113
|
+
const anchor = options.position === 'before'
|
|
114
|
+
? (family.length > 0 ? family[0] : base.nextElementSibling)
|
|
115
|
+
: (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
|
|
116
|
+
root.insertBefore(entry, anchor)
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mount the sidebar entry, waiting for the shell to render and self-healing
|
|
123
|
+
* on later React re-renders.
|
|
124
|
+
* @param options - the row's attribute/icon/copy/action/ordering configuration.
|
|
125
|
+
* @returns disposer removing the entry and its observers.
|
|
126
|
+
*/
|
|
127
|
+
export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
|
|
128
|
+
// DOM-level idempotency: whatever path mounted an entry row before this
|
|
129
|
+
// call (a duplicated apply, an HMR re-injection, a stale module still
|
|
130
|
+
// alive), never mount a second one. The existing row keeps working; a full
|
|
131
|
+
// page reload is the ultimate reset.
|
|
132
|
+
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
|
|
133
|
+
return () => {}
|
|
134
|
+
}
|
|
135
|
+
const entry = createEntry(options)
|
|
136
|
+
let root: HTMLElement | undefined
|
|
137
|
+
let placed = false
|
|
138
|
+
|
|
139
|
+
const tryPlace = (): void => {
|
|
140
|
+
if (root !== undefined && !root.isConnected) {
|
|
141
|
+
// The shell rebuilt the sidebar pane (whole-tree teardown); the root
|
|
142
|
+
// observer is gone with the old tree, so detach it and re-query from
|
|
143
|
+
// scratch. The new pane is later noticed by the body-level watcher.
|
|
144
|
+
rootObserver.disconnect()
|
|
145
|
+
root = undefined
|
|
146
|
+
placed = false
|
|
147
|
+
}
|
|
148
|
+
if (placed) {
|
|
149
|
+
// Cheap short-circuit: entry still lives in a mountable subtree.
|
|
150
|
+
if (document.body.contains(entry)) return
|
|
151
|
+
// Entry was torn down together with the old tree; reset and re-place.
|
|
152
|
+
rootObserver.disconnect()
|
|
153
|
+
root = undefined
|
|
154
|
+
placed = false
|
|
155
|
+
}
|
|
156
|
+
root ??= sidebarRoot()
|
|
157
|
+
if (root === undefined) return
|
|
158
|
+
placed = placeEntry(root, entry, options)
|
|
159
|
+
if (placed) {
|
|
160
|
+
rootObserver.observe(root, { childList: true, subtree: true })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Body-level watcher retained as the "whole rebuild" fallback: when the shell
|
|
165
|
+
// tears down the whole sidebar pane, the root observer is gone with it and
|
|
166
|
+
// only this body observation can notice the new pane mounting. It is no
|
|
167
|
+
// longer disconnected after placement; the placed-and-still-mounted case
|
|
168
|
+
// short-circuits through the cheap document.body.contains(entry) check, so
|
|
169
|
+
// unrelated app mutations (e.g. chat streaming) cost one contains check
|
|
170
|
+
// instead of churning the full re-query.
|
|
171
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
172
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
173
|
+
|
|
174
|
+
// Self-heal: if a React re-render displaces the row, re-insert it in the
|
|
175
|
+
// same frame (microtask before paint -> no visible flicker).
|
|
176
|
+
const rootObserver = new MutationObserver(() => {
|
|
177
|
+
if (root === undefined || !root.isConnected) {
|
|
178
|
+
placed = false
|
|
179
|
+
tryPlace()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!root.contains(entry)) {
|
|
183
|
+
placed = placeEntry(root, entry, options)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Reflect the panel's open state on the row (active highlight). Note: assigning
|
|
188
|
+
// undefined to dataset.active materializes data-active="undefined" and keeps the
|
|
189
|
+
// row permanently highlighted — delete the attribute instead.
|
|
190
|
+
const unsubscribeActive = options.active === undefined ? undefined : (() => {
|
|
191
|
+
const syncActive = (): void => {
|
|
192
|
+
if (options.active!.isOpen()) entry.dataset.active = 'true'
|
|
193
|
+
else delete entry.dataset.active
|
|
194
|
+
}
|
|
195
|
+
const unsubscribe = options.active.subscribe(syncActive)
|
|
196
|
+
syncActive()
|
|
197
|
+
return unsubscribe
|
|
198
|
+
})()
|
|
199
|
+
|
|
200
|
+
tryPlace()
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
waitObserver.disconnect()
|
|
204
|
+
rootObserver.disconnect()
|
|
205
|
+
unsubscribeActive?.()
|
|
206
|
+
entry.remove()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -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
|
@@ -23,13 +23,13 @@ import { applyCreateTask } from './use-cases/task-create.ts'
|
|
|
23
23
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
24
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
25
25
|
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
26
|
-
import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
|
|
26
|
+
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from '../protocol.ts'
|
|
27
27
|
|
|
28
28
|
export interface TaskBoardTransport {
|
|
29
29
|
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
30
30
|
state(): Promise<TaskBoardSnapshot>
|
|
31
31
|
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
32
|
-
subscribe(listener: () => void): () => void
|
|
32
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** The sessions face the controller needs for navigation awareness. */
|
|
@@ -601,7 +601,7 @@ export class BoardController {
|
|
|
601
601
|
this.acceptRemote(await transport.bootstrap(this.tasks))
|
|
602
602
|
if (!this.remoteSubscribed) {
|
|
603
603
|
this.remoteSubscribed = true
|
|
604
|
-
this.disposers.push(transport.subscribe(() => {
|
|
604
|
+
this.disposers.push(transport.subscribe((event) => { this.onRemoteEvent(event) }))
|
|
605
605
|
}
|
|
606
606
|
return true
|
|
607
607
|
} catch (error) {
|
|
@@ -611,6 +611,23 @@ export class BoardController {
|
|
|
611
611
|
}
|
|
612
612
|
}
|
|
613
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
|
+
|
|
614
631
|
private async refreshRemote(preserveError?: string): Promise<boolean> {
|
|
615
632
|
const transport = this.deps.transport
|
|
616
633
|
if (transport === undefined) return true
|
package/src/core/schedule.ts
CHANGED
|
@@ -77,22 +77,73 @@ export function isValidCron(expr: string): boolean {
|
|
|
77
77
|
* can never match (for example `0 0 30 2 *`). The five-year horizon includes
|
|
78
78
|
* a full leap cycle, so a valid February 29 schedule remains reachable from
|
|
79
79
|
* every non-leap year.
|
|
80
|
+
*
|
|
81
|
+
* Walks candidate year/month/day/hour/minute values straight from the parsed
|
|
82
|
+
* field sets instead of scanning every minute: a sparse expression such as
|
|
83
|
+
* `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the
|
|
84
|
+
* next leap day. Wall-clock field construction + the final `matches` re-check
|
|
85
|
+
* preserve the old minute scan's DST semantics exactly (nonexistent spring
|
|
86
|
+
* minutes normalize forward and the repeated fall-back hour is never visited).
|
|
80
87
|
*/
|
|
81
88
|
export function nextRunAtMs(expr: string, fromMs: number): number | undefined {
|
|
82
89
|
const schedule = parseCron(expr)
|
|
83
90
|
if (schedule === null) return undefined
|
|
84
91
|
if (!hasPossibleCalendarDay(schedule)) return undefined
|
|
85
92
|
const from = new Date(fromMs)
|
|
86
|
-
// Scan from the next minute; Date rolls overflow (Feb 30 → Mar 2) for free.
|
|
87
|
-
const scan = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes() + 1, 0, 0)
|
|
88
93
|
const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
|
|
95
|
+
const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b)
|
|
96
|
+
const sortedHours = [...schedule.hours].sort((a, b) => a - b)
|
|
97
|
+
const sortedMonths = [...schedule.months].sort((a, b) => a - b)
|
|
98
|
+
|
|
99
|
+
let year = from.getFullYear()
|
|
100
|
+
let month = from.getMonth() + 1
|
|
101
|
+
let day = from.getDate()
|
|
102
|
+
let hour = from.getHours()
|
|
103
|
+
// Strictly after fromMs: the old scan started from the next minute.
|
|
104
|
+
let minute = from.getMinutes() + 1
|
|
105
|
+
|
|
106
|
+
while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) {
|
|
107
|
+
for (const candidateMonth of sortedMonths) {
|
|
108
|
+
if (candidateMonth < month) continue
|
|
109
|
+
const daysInMonth = new Date(year, candidateMonth, 0).getDate()
|
|
110
|
+
const dayStart = candidateMonth === month ? day : 1
|
|
111
|
+
for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) {
|
|
112
|
+
const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0)
|
|
113
|
+
if (!dayCandidate(schedule, dayProbe)) continue
|
|
114
|
+
const hourStart = candidateMonth === month && candidateDay === day ? hour : 0
|
|
115
|
+
for (const candidateHour of sortedHours) {
|
|
116
|
+
if (candidateHour < hourStart) continue
|
|
117
|
+
const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0
|
|
118
|
+
for (const candidateMinute of sortedMinutes) {
|
|
119
|
+
if (candidateMinute < minuteStart) continue
|
|
120
|
+
const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0)
|
|
121
|
+
const time = candidate.getTime()
|
|
122
|
+
if (time <= fromMs) continue
|
|
123
|
+
if (time > limitMs) return undefined
|
|
124
|
+
if (matches(schedule, candidate)) return time
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
year += 1
|
|
130
|
+
month = 1
|
|
131
|
+
day = 1
|
|
132
|
+
hour = 0
|
|
133
|
+
minute = 0
|
|
92
134
|
}
|
|
93
135
|
return undefined
|
|
94
136
|
}
|
|
95
137
|
|
|
138
|
+
/** Day/weekday OR gate shared by {@link matches} and the candidate scan. */
|
|
139
|
+
function dayCandidate(schedule: CronSchedule, date: Date): boolean {
|
|
140
|
+
const dayMatches = schedule.days.has(date.getDate())
|
|
141
|
+
const weekdayMatches = schedule.weekdays.has(date.getDay())
|
|
142
|
+
if (schedule.dayWildcard) return weekdayMatches
|
|
143
|
+
if (schedule.weekdayWildcard) return dayMatches
|
|
144
|
+
return dayMatches || weekdayMatches
|
|
145
|
+
}
|
|
146
|
+
|
|
96
147
|
/** Reject impossible month/day pairs without spending the multi-year scan. */
|
|
97
148
|
function hasPossibleCalendarDay(schedule: CronSchedule): boolean {
|
|
98
149
|
if (schedule.dayWildcard || !schedule.weekdayWildcard) return true
|
|
@@ -145,11 +196,7 @@ function matches(schedule: CronSchedule, date: Date): boolean {
|
|
|
145
196
|
if (!schedule.minutes.has(date.getMinutes())) return false
|
|
146
197
|
if (!schedule.hours.has(date.getHours())) return false
|
|
147
198
|
if (!schedule.months.has(date.getMonth() + 1)) return false
|
|
148
|
-
|
|
149
|
-
const weekdayMatches = schedule.weekdays.has(date.getDay())
|
|
150
|
-
if (schedule.dayWildcard) return weekdayMatches
|
|
151
|
-
if (schedule.weekdayWildcard) return dayMatches
|
|
152
|
-
return dayMatches || weekdayMatches
|
|
199
|
+
return dayCandidate(schedule, date)
|
|
153
200
|
}
|
|
154
201
|
|
|
155
202
|
function isDigits(value: string): boolean {
|
package/src/host-ledger.ts
CHANGED
|
@@ -120,11 +120,14 @@ export class HostTaskLedger {
|
|
|
120
120
|
private lockFd: number | undefined
|
|
121
121
|
readonly file: string
|
|
122
122
|
readonly lockFile: string
|
|
123
|
+
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
124
|
+
readonly schedulerFile: string
|
|
123
125
|
|
|
124
126
|
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now) {
|
|
125
127
|
mkdirSync(dir, { recursive: true })
|
|
126
128
|
this.file = join(dir, 'ledger-v2.json')
|
|
127
129
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
130
|
+
this.schedulerFile = join(dir, 'scheduler-v2.json')
|
|
128
131
|
this.lockFd = this.acquireLock()
|
|
129
132
|
try {
|
|
130
133
|
this.document = this.load(dir)
|
|
@@ -142,9 +145,15 @@ export class HostTaskLedger {
|
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
149
|
+
summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } {
|
|
146
150
|
const { importedSources: _imports, ...scheduler } = this.document.scheduler
|
|
147
|
-
return { revision: this.document.revision,
|
|
151
|
+
return { revision: this.document.revision, scheduler: { ...scheduler } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state(): LedgerState {
|
|
155
|
+
const { revision, scheduler } = this.summary()
|
|
156
|
+
return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
subscribe(listener: () => void): () => void {
|
|
@@ -215,6 +224,14 @@ export class HostTaskLedger {
|
|
|
215
224
|
|
|
216
225
|
setScheduler(patch: Partial<TaskBoardSchedulerSnapshot>): void {
|
|
217
226
|
this.document.scheduler = { ...this.document.scheduler, ...patch }
|
|
227
|
+
// The 30 s heartbeat only moves lastTickAt; rewriting the whole ledger
|
|
228
|
+
// for it made idle idle cost O(ledger bytes) every tick. Persist it to a
|
|
229
|
+
// tiny sidecar instead; any other patch still goes through the full
|
|
230
|
+
// atomic commit.
|
|
231
|
+
if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) {
|
|
232
|
+
this.writeSchedulerSidecar()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
218
235
|
this.commit(false)
|
|
219
236
|
}
|
|
220
237
|
|
|
@@ -374,6 +391,13 @@ export class HostTaskLedger {
|
|
|
374
391
|
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
375
392
|
: []
|
|
376
393
|
})
|
|
394
|
+
const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
|
|
395
|
+
const sidecarLastTickAt = this.readSchedulerSidecar()
|
|
396
|
+
// A sidecar write can be newer than the last full commit (crash between
|
|
397
|
+
// the two); lastTickAt only ever moves forward, so take the greater.
|
|
398
|
+
const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
|
|
399
|
+
? documentLastTickAt
|
|
400
|
+
: sidecarLastTickAt
|
|
377
401
|
return {
|
|
378
402
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
379
403
|
revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
|
|
@@ -381,7 +405,7 @@ export class HostTaskLedger {
|
|
|
381
405
|
scheduler: {
|
|
382
406
|
timeZone: timeZone(),
|
|
383
407
|
ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
384
|
-
...(
|
|
408
|
+
...(lastTickAt === undefined ? {} : { lastTickAt }),
|
|
385
409
|
...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
|
|
386
410
|
...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
|
|
387
411
|
...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
|
|
@@ -416,6 +440,43 @@ export class HostTaskLedger {
|
|
|
416
440
|
}))
|
|
417
441
|
}
|
|
418
442
|
|
|
443
|
+
private readSchedulerSidecar(): number | undefined {
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(readFileSync(this.schedulerFile, 'utf8')) as { lastTickAt?: unknown }
|
|
446
|
+
return typeof parsed.lastTickAt === 'number' && Number.isFinite(parsed.lastTickAt) ? parsed.lastTickAt : undefined
|
|
447
|
+
} catch {
|
|
448
|
+
return undefined
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
|
|
453
|
+
private writeSchedulerSidecar(): void {
|
|
454
|
+
const payload = JSON.stringify({ lastTickAt: this.document.scheduler.lastTickAt })
|
|
455
|
+
mkdirSync(dirname(this.schedulerFile), { recursive: true })
|
|
456
|
+
const tmp = `${this.schedulerFile}.tmp-${process.pid}`
|
|
457
|
+
let fd: number | undefined
|
|
458
|
+
try {
|
|
459
|
+
fd = openSync(tmp, 'w', 0o600)
|
|
460
|
+
writeFileSync(fd, payload, { encoding: 'utf8' })
|
|
461
|
+
fsyncSync(fd)
|
|
462
|
+
closeSync(fd)
|
|
463
|
+
fd = undefined
|
|
464
|
+
try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ }
|
|
465
|
+
renameSync(tmp, this.schedulerFile)
|
|
466
|
+
try {
|
|
467
|
+
const dirFd = openSync(dirname(this.schedulerFile), 'r')
|
|
468
|
+
try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
|
|
469
|
+
} catch {
|
|
470
|
+
// Windows does not permit fsync on a directory handle; rename remains atomic.
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (fd !== undefined) closeSync(fd)
|
|
474
|
+
try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ }
|
|
475
|
+
throw error
|
|
476
|
+
}
|
|
477
|
+
this.notify()
|
|
478
|
+
}
|
|
479
|
+
|
|
419
480
|
private commit(bumpRevision = true): void {
|
|
420
481
|
if (bumpRevision) this.document.revision += 1
|
|
421
482
|
mkdirSync(dirname(this.file), { recursive: true })
|