@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.3
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 +4 -3
- package/README.zh.md +4 -3
- package/lib/client.js +227 -388
- package/lib/client.js.map +1 -1
- package/lib/index.js +279 -42
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +41 -2
- package/lib/types/host-ledger.d.ts +9 -0
- package/lib/types/host-service.d.ts +51 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/package.json +4 -1
- package/src/client/board.module.css +16 -131
- package/src/client/index.ts +45 -17
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +59 -2
- package/src/host-ledger.ts +20 -0
- package/src/host-routes.ts +37 -1
- package/src/host-service.ts +138 -22
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
3
|
+
/**
|
|
4
|
+
* Family settings form bound to the profile entry id a Host actually serves.
|
|
5
|
+
*
|
|
6
|
+
* Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
|
|
7
|
+
* a family card knows only its own settings namespace. The family binder
|
|
8
|
+
* (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
|
|
9
|
+
* the other and is the preferred transport; this module is the fallback for a
|
|
10
|
+
* page that serves no binder — the group plugin is not installed, or its client
|
|
11
|
+
* half has not applied yet (the aggregate mounts its children in order, and a
|
|
12
|
+
* child still waiting on its own injects publishes nothing).
|
|
13
|
+
*
|
|
14
|
+
* The shared describe mirror is the only local evidence of which row id this
|
|
15
|
+
* profile serves, but it answers asynchronously: at plugin activation it usually
|
|
16
|
+
* holds nothing yet. A one-shot guess therefore cannot be right for every
|
|
17
|
+
* deployment, and the form is bound once per session:
|
|
18
|
+
*
|
|
19
|
+
* - binding the bare namespace made every save answer `No configurable plugin
|
|
20
|
+
* entry "<namespace>"` on an aggregate install (the namespace is not an entry
|
|
21
|
+
* id there), which the card reported as "the deployment did not accept these
|
|
22
|
+
* values";
|
|
23
|
+
* - binding the aggregate row id would break a standalone install, whose row id
|
|
24
|
+
* is the namespace or the package's own `ui-*` row.
|
|
25
|
+
*
|
|
26
|
+
* So the fallback starts on the most likely row and REBINDS the moment the
|
|
27
|
+
* mirror names one of this package's rows. Until the mirror answers, the form
|
|
28
|
+
* reports the binding it holds (usually `loading`), which the card renders as an
|
|
29
|
+
* unavailable form rather than a doomed one.
|
|
30
|
+
*/
|
|
31
|
+
import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
32
|
+
|
|
33
|
+
/** Options of {@link createServedEntryForm}. */
|
|
34
|
+
export interface ServedEntryFormOptions<T> {
|
|
35
|
+
/** The shared configuration forms service (`ctx.configForms`). */
|
|
36
|
+
forms: ConfigForms
|
|
37
|
+
/**
|
|
38
|
+
* Profile entry ids this package's rows carry, most likely first: the
|
|
39
|
+
* aggregate's generated row, the package's own standalone row, then the bare
|
|
40
|
+
* namespace. The first is the fallback while the mirror has no answer; the
|
|
41
|
+
* last is the answer when the mirror serves none of them.
|
|
42
|
+
*/
|
|
43
|
+
entryIds: readonly string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The snapshot a form reports before the Host has answered with this entry. */
|
|
47
|
+
function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
|
|
48
|
+
return {
|
|
49
|
+
status: 'loading',
|
|
50
|
+
value: undefined,
|
|
51
|
+
base: undefined,
|
|
52
|
+
user: undefined,
|
|
53
|
+
revision: undefined,
|
|
54
|
+
writable: false,
|
|
55
|
+
mode: 'host',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a settings form bound to the profile entry id this Host serves, and
|
|
61
|
+
* rebound whenever the shared describe mirror names a different one of this
|
|
62
|
+
* package's rows.
|
|
63
|
+
* @param options - the shared forms service and this package's candidate row ids.
|
|
64
|
+
* @returns a form delegating to the currently resolved entry's own form.
|
|
65
|
+
*/
|
|
66
|
+
export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
|
|
67
|
+
const { forms, entryIds } = options
|
|
68
|
+
const fallbackId = entryIds[0]
|
|
69
|
+
const namespaceId = entryIds[entryIds.length - 1]
|
|
70
|
+
const listeners = new Set<() => void>()
|
|
71
|
+
let boundId: string | undefined
|
|
72
|
+
let bound: ConfigForm<T> | undefined
|
|
73
|
+
let offBound: (() => void) | undefined
|
|
74
|
+
let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
|
|
75
|
+
|
|
76
|
+
/** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
|
|
77
|
+
const publish = (): void => {
|
|
78
|
+
if (bound !== undefined) snapshot = bound.getSnapshot()
|
|
79
|
+
for (const listener of [...listeners]) listener()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The entry id the mirror currently justifies. An unanswered or empty mirror
|
|
84
|
+
* is not evidence of absence, so it keeps the first candidate; a mirror that
|
|
85
|
+
* answers without any of this package's rows leaves the namespace itself.
|
|
86
|
+
* @returns the entry id to bind.
|
|
87
|
+
*/
|
|
88
|
+
const resolve = (): string => {
|
|
89
|
+
let served: readonly string[] | undefined
|
|
90
|
+
try {
|
|
91
|
+
served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
|
|
92
|
+
} catch {
|
|
93
|
+
// A mirror that refuses the read is unanswered too, not absent.
|
|
94
|
+
served = undefined
|
|
95
|
+
}
|
|
96
|
+
if (served === undefined || served.length === 0) return fallbackId
|
|
97
|
+
return entryIds.find(id => served.includes(id)) ?? namespaceId
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
|
|
101
|
+
const bind = (): void => {
|
|
102
|
+
const target = resolve()
|
|
103
|
+
if (target === boundId) return
|
|
104
|
+
offBound?.()
|
|
105
|
+
offBound = undefined
|
|
106
|
+
let form: ConfigForm<T>
|
|
107
|
+
try {
|
|
108
|
+
form = forms.get<T>(target)
|
|
109
|
+
} catch {
|
|
110
|
+
// A service that refuses the id stays unbound; the next mirror answer
|
|
111
|
+
// retries rather than pinning a wrong entry for the session.
|
|
112
|
+
boundId = undefined
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
boundId = target
|
|
116
|
+
bound = form
|
|
117
|
+
offBound = form.subscribe(() => { publish() })
|
|
118
|
+
publish()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The mirror subscription lives for the page: the card owns its form for the
|
|
122
|
+
// session, and the ConfigForm contract carries no disposer to hang it on. The
|
|
123
|
+
// callback is cheap (a resolve plus a no-op while the id is unchanged). A
|
|
124
|
+
// mirror without a subscription seat keeps the initial binding.
|
|
125
|
+
try {
|
|
126
|
+
forms.describe().subscribe(() => { bind() })
|
|
127
|
+
} catch {
|
|
128
|
+
// A describe face that refuses the subscription: the initial binding stands.
|
|
129
|
+
}
|
|
130
|
+
bind()
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
getSnapshot: () => snapshot,
|
|
134
|
+
subscribe: (listener) => {
|
|
135
|
+
listeners.add(listener)
|
|
136
|
+
return () => { listeners.delete(listener) }
|
|
137
|
+
},
|
|
138
|
+
set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
|
|
139
|
+
unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
|
|
140
|
+
mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/core/controller.ts
CHANGED
|
@@ -62,6 +62,21 @@ function currentOf(sessions: SessionsControllerFace | undefined): string | undef
|
|
|
62
62
|
return sessions?.current()
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Stable id shared by the board's sidebar panel row and its main-slot page. */
|
|
66
|
+
export const TASK_BOARD_PANEL_ID = 'task-board'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The layout's panel-navigation face. The board no longer owns the center
|
|
70
|
+
* column at the DOM level: selecting the panel is the layout's business, and
|
|
71
|
+
* this is the one call that asks for it (null returns to the conversation).
|
|
72
|
+
* Optional, so a composition without the layout service keeps the board's
|
|
73
|
+
* pure state transitions testable.
|
|
74
|
+
*/
|
|
75
|
+
export interface PanelNavigationFace {
|
|
76
|
+
/** Select the board panel, or null to hand the column back to the conversation. */
|
|
77
|
+
select(panelId: string | null): void
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
/** Controller dependencies (all swappable in tests). */
|
|
66
81
|
export interface ControllerDeps {
|
|
67
82
|
store: TaskStore
|
|
@@ -72,6 +87,8 @@ export interface ControllerDeps {
|
|
|
72
87
|
uuid?: () => string
|
|
73
88
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
74
89
|
transport?: TaskBoardTransport
|
|
90
|
+
/** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
|
|
91
|
+
panel?: PanelNavigationFace
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
/**
|
|
@@ -253,15 +270,31 @@ export class BoardController {
|
|
|
253
270
|
|
|
254
271
|
// --- view state -------------------------------------------------------------
|
|
255
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Show the board. The layout owns which panel the center column renders, so
|
|
275
|
+
* the state flip and the panel selection travel together here; a composition
|
|
276
|
+
* with no layout face (tests, a shell-less host) still flips the state.
|
|
277
|
+
*
|
|
278
|
+
* The selection is requested AFTER the snapshot flips so a subscriber
|
|
279
|
+
* rendering against `boardOpen` never observes "open" while the shell still
|
|
280
|
+
* shows the conversation.
|
|
281
|
+
*/
|
|
256
282
|
openBoard(): void {
|
|
257
283
|
if (this.boardOpen) return
|
|
258
284
|
this.boardOpen = true
|
|
259
285
|
this.notify()
|
|
286
|
+
this.selectPanel(TASK_BOARD_PANEL_ID)
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Return the center column to the conversation. The layout's own selection is
|
|
291
|
+
* the source of truth for what the column renders, so this asks for the
|
|
292
|
+
* conversation explicitly rather than only clearing local state.
|
|
293
|
+
*/
|
|
262
294
|
closeBoard(): void {
|
|
263
295
|
this.boardOpen = false
|
|
264
296
|
this.notify()
|
|
297
|
+
this.selectPanel(null)
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
toggleBoard(): void {
|
|
@@ -269,6 +302,30 @@ export class BoardController {
|
|
|
269
302
|
else this.openBoard()
|
|
270
303
|
}
|
|
271
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Reflect a panel selection that came from OUTSIDE this controller (the user
|
|
307
|
+
* clicked another sidebar row, or the layout dropped the panel id). Keeps
|
|
308
|
+
* `boardOpen` aligned with what the column actually shows without asking the
|
|
309
|
+
* layout to select anything back.
|
|
310
|
+
* @param panelId - the layout's current panel id, or null for the conversation.
|
|
311
|
+
*/
|
|
312
|
+
syncPanelSelection(panelId: string | null): void {
|
|
313
|
+
const open = panelId === TASK_BOARD_PANEL_ID
|
|
314
|
+
if (open === this.boardOpen) return
|
|
315
|
+
this.boardOpen = open
|
|
316
|
+
this.notify()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
|
|
320
|
+
private selectPanel(panelId: string | null): void {
|
|
321
|
+
try {
|
|
322
|
+
this.deps.panel?.select(panelId)
|
|
323
|
+
} catch {
|
|
324
|
+
// The layout service throws by contract before its root entry mounts;
|
|
325
|
+
// the state flip above already stands, and the next selection retries.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
272
329
|
/**
|
|
273
330
|
* Switch between the kanban columns and the archive view. Leaving the
|
|
274
331
|
* archive view with an archived task still selected closes the selection —
|
|
@@ -547,8 +604,8 @@ export class BoardController {
|
|
|
547
604
|
* (background navigation, the Host runner creating and selecting a fresh
|
|
548
605
|
* execution session, settlement, other plugins), so closing on `current`
|
|
549
606
|
* changes would evict the board without the user asking. The board closes
|
|
550
|
-
* only on explicit user navigation:
|
|
551
|
-
*
|
|
607
|
+
* only on explicit user navigation: selecting another panel (the layout owns
|
|
608
|
+
* selection, and `syncPanelSelection` follows it) or the board's own actions
|
|
552
609
|
* (openSession / close). Keeping the hook preserves the subscription
|
|
553
610
|
* contract for future listeners.
|
|
554
611
|
*/
|
package/src/host-ledger.ts
CHANGED
|
@@ -516,6 +516,26 @@ export class HostTaskLedger {
|
|
|
516
516
|
return count
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* The earliest armed `nextRunAt` strictly after the supplied Host time, or
|
|
521
|
+
* undefined when no schedule is armed in the future. This is the instant the
|
|
522
|
+
* Host arms its native timer at, so the board wakes exactly when work is due
|
|
523
|
+
* instead of polling a fixed heartbeat.
|
|
524
|
+
* @param now - current Host time in ms epoch.
|
|
525
|
+
* @returns the nearest future trigger, or undefined when none is armed.
|
|
526
|
+
*/
|
|
527
|
+
nextArmedRunAt(now: number): number | undefined {
|
|
528
|
+
let nearest: number | undefined
|
|
529
|
+
for (const task of this.document.tasks) {
|
|
530
|
+
if (task.archivedAt !== undefined) continue
|
|
531
|
+
const schedule = task.schedule
|
|
532
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined) continue
|
|
533
|
+
if (schedule.nextRunAt <= now) continue
|
|
534
|
+
if (nearest === undefined || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt
|
|
535
|
+
}
|
|
536
|
+
return nearest
|
|
537
|
+
}
|
|
538
|
+
|
|
519
539
|
/** Return value-only references for schedules due at the supplied Host time. */
|
|
520
540
|
dueSchedules(now: number): DueScheduleReference[] {
|
|
521
541
|
const due: DueScheduleReference[] = []
|
package/src/host-routes.ts
CHANGED
|
@@ -73,15 +73,51 @@ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
|
|
|
73
73
|
return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Harness browser-auth cookie prefix (dsh-client-connection): the
|
|
78
|
+
* authority-bound signed cookie the Host mints in exchange for its launch
|
|
79
|
+
* token. dsh-web's remote channel redeems that token before re-issuing paired
|
|
80
|
+
* traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
|
|
81
|
+
* official DSH Desktop shell keeps its own copy from the same exchange and
|
|
82
|
+
* attaches it to every request it forwards for the dsh-app://app/ page. The
|
|
83
|
+
* two constants describe one fact and move together.
|
|
84
|
+
*/
|
|
85
|
+
const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a Cookie header carries the Host's browser-auth credential. Only an
|
|
89
|
+
* application on this machine can hold it: the desktop shell never lets it
|
|
90
|
+
* reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
|
|
91
|
+
* from attaching it.
|
|
92
|
+
* @param header - the raw Cookie header value.
|
|
93
|
+
*/
|
|
94
|
+
function carriesBrowserAuthCookie(header: string | undefined): boolean {
|
|
95
|
+
if (header === undefined) return false
|
|
96
|
+
return header.split(';').some(segment => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX))
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
/**
|
|
77
100
|
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
78
101
|
* header and is refused, but a curl with a forged Origin passes this too.
|
|
79
102
|
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
80
103
|
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
104
|
+
*
|
|
105
|
+
* A first-party client that presents NEITHER header must still pass. The DSH
|
|
106
|
+
* Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
|
|
107
|
+
* page's Host requests itself, deleting origin and sec-fetch-site on the
|
|
108
|
+
* way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
|
|
109
|
+
* arrives marker-less and every route behind this guard answered 403 - which
|
|
110
|
+
* the panel renders as its board.hostError.unauthorized message. The shell
|
|
111
|
+
* does attach the Host's browser-auth cookie, redeemed from the Host's launch
|
|
112
|
+
* URL at startup and deliberately withheld from the page. That credential,
|
|
113
|
+
* not a header the shell strips, is the browser signal of an application on
|
|
114
|
+
* this machine; a marker-less, credential-less request stays refused.
|
|
81
115
|
*/
|
|
82
116
|
function browserSameOriginMarker(req: IncomingMessage): boolean {
|
|
83
117
|
const site = req.headers['sec-fetch-site']
|
|
84
|
-
|
|
118
|
+
if (site === 'same-origin') return true
|
|
119
|
+
if (typeof req.headers.origin === 'string') return true
|
|
120
|
+
return carriesBrowserAuthCookie(req.headers.cookie)
|
|
85
121
|
}
|
|
86
122
|
|
|
87
123
|
function sameAuthority(req: IncomingMessage, host: URL): boolean {
|
package/src/host-service.ts
CHANGED
|
@@ -38,20 +38,62 @@ export interface TaskBoardTeamDispatcher {
|
|
|
38
38
|
spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Session-roster poll cadence — the one recurring Host timer this service still holds. */
|
|
41
42
|
const SESSION_POLL_MS = 5_000
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* How late an armed schedule fire may be before it counts as a resume rather
|
|
45
|
+
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
46
|
+
* instant, so landing this far past its target means the Host was suspended,
|
|
47
|
+
* the process throttled, or the wall clock jumped forward — the same condition
|
|
48
|
+
* the old fixed 30 s heartbeat detected through its own gap threshold.
|
|
49
|
+
*/
|
|
50
|
+
const RECOVERY_TOLERANCE_MS = 60_000
|
|
51
|
+
/** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
|
|
52
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Actions that can move an armed trigger. Only these re-arm the native timer;
|
|
56
|
+
* an unrelated card edit leaves the pending fire untouched.
|
|
57
|
+
*/
|
|
58
|
+
const SCHEDULE_WRITE_ACTIONS: ReadonlySet<TaskBoardAction['kind']> = new Set(['set-schedule', 'delete', 'archive'])
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The native timer face the Host arms through. The cordis `timer` service
|
|
62
|
+
* (dsh-base's own `cordis-plugin-timer` row) provides it: its handles are
|
|
63
|
+
* registered on the owning fiber, so unloading the board clears every armed
|
|
64
|
+
* timer without this service tracking handles by hand. A composition that
|
|
65
|
+
* serves no timer service falls back to the process globals.
|
|
66
|
+
*/
|
|
67
|
+
export interface HostTimerFace {
|
|
68
|
+
timeout(callback: () => void, delay: number): () => void
|
|
69
|
+
interval(callback: () => void, delay: number): () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Process-global fallback used when the deployment serves no cordis timer service. */
|
|
73
|
+
const PROCESS_TIMERS: HostTimerFace = {
|
|
74
|
+
timeout(callback: () => void, delay: number): () => void {
|
|
75
|
+
const handle = setTimeout(callback, delay)
|
|
76
|
+
return () => { clearTimeout(handle) }
|
|
77
|
+
},
|
|
78
|
+
interval(callback: () => void, delay: number): () => void {
|
|
79
|
+
const handle = setInterval(callback, delay)
|
|
80
|
+
return () => { clearInterval(handle) }
|
|
81
|
+
},
|
|
82
|
+
}
|
|
44
83
|
|
|
45
84
|
export class TaskBoardHostService {
|
|
46
85
|
readonly ledger: HostTaskLedger
|
|
47
86
|
readonly runner: HostExecutionRunner
|
|
48
87
|
readonly power: PowerInhibitor
|
|
49
88
|
private readonly listeners = new Set<() => void>()
|
|
50
|
-
|
|
51
|
-
private
|
|
89
|
+
/** The one recurring timer: the session-roster poll. */
|
|
90
|
+
private pollTimer: (() => void) | undefined
|
|
91
|
+
/** The armed schedule timer, if a trigger is pending. */
|
|
92
|
+
private scheduleTimer: (() => void) | undefined
|
|
93
|
+
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
94
|
+
private scheduleTarget: number | undefined
|
|
52
95
|
private disposed = false
|
|
53
96
|
private pollInFlight = false
|
|
54
|
-
private tickInFlight = false
|
|
55
97
|
private active = true
|
|
56
98
|
/**
|
|
57
99
|
* Ids the last roster poll saw as present and idle; undefined while the
|
|
@@ -62,6 +104,7 @@ export class TaskBoardHostService {
|
|
|
62
104
|
private idleSessionIds: ReadonlySet<string> | undefined
|
|
63
105
|
private preventIdleSleep = false
|
|
64
106
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
107
|
+
private readonly timers: HostTimerFace
|
|
65
108
|
private lastPowerJson = ''
|
|
66
109
|
private readonly now: () => number
|
|
67
110
|
|
|
@@ -74,6 +117,7 @@ export class TaskBoardHostService {
|
|
|
74
117
|
sessionDefaultPermission?: TaskPermission
|
|
75
118
|
maxSubtaskDepth?: number
|
|
76
119
|
team?: TaskBoardTeamDispatcher
|
|
120
|
+
timers?: HostTimerFace
|
|
77
121
|
} = {}) {
|
|
78
122
|
this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
|
|
79
123
|
sessionDefaultPermission: options.sessionDefaultPermission,
|
|
@@ -81,6 +125,7 @@ export class TaskBoardHostService {
|
|
|
81
125
|
})
|
|
82
126
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
|
|
83
127
|
this.team = options.team
|
|
128
|
+
this.timers = options.timers ?? PROCESS_TIMERS
|
|
84
129
|
this.power = options.power ?? new PowerInhibitor()
|
|
85
130
|
this.now = options.now ?? Date.now
|
|
86
131
|
installStreamErrorGuards()
|
|
@@ -100,12 +145,15 @@ export class TaskBoardHostService {
|
|
|
100
145
|
}
|
|
101
146
|
|
|
102
147
|
start(): void {
|
|
103
|
-
if (this.disposed || this.
|
|
148
|
+
if (this.disposed || this.pollTimer !== undefined) return
|
|
104
149
|
this.syncPowerReasons()
|
|
105
|
-
this.timers.
|
|
106
|
-
this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
|
|
150
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, SESSION_POLL_MS)
|
|
107
151
|
this.schedulePoll()
|
|
108
|
-
|
|
152
|
+
// Boot is a recovery point: an occurrence armed while the Host was down is
|
|
153
|
+
// not replayed, and each schedule rolls to its next future target. A
|
|
154
|
+
// schedule the Board should have served while running is then armed
|
|
155
|
+
// normally by the timer below.
|
|
156
|
+
this.recoverSchedule()
|
|
109
157
|
}
|
|
110
158
|
|
|
111
159
|
setConfiguration(active: boolean, preventIdleSleep: boolean): void {
|
|
@@ -123,7 +171,11 @@ export class TaskBoardHostService {
|
|
|
123
171
|
this.power.setEnabled(active && preventIdleSleep)
|
|
124
172
|
if (resumed) {
|
|
125
173
|
this.schedulePoll()
|
|
126
|
-
this.
|
|
174
|
+
this.recoverSchedule()
|
|
175
|
+
} else if (!active) {
|
|
176
|
+
// A disabled board holds no timer: its schedules must not fire while the
|
|
177
|
+
// master switch is off.
|
|
178
|
+
this.clearScheduleTimer()
|
|
127
179
|
}
|
|
128
180
|
this.emit()
|
|
129
181
|
}
|
|
@@ -164,6 +216,10 @@ export class TaskBoardHostService {
|
|
|
164
216
|
}
|
|
165
217
|
const result = this.ledger.applyRequest(requestId, action, initiator)
|
|
166
218
|
if (result.runs !== undefined) this.dispatchRuns(result.runs)
|
|
219
|
+
// A committed schedule write (create / update / toggle / delete) moves the
|
|
220
|
+
// nearest trigger; re-arm on every action so a newly enabled schedule fires
|
|
221
|
+
// at its own instant without waiting for the previous target to elapse.
|
|
222
|
+
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
|
|
167
223
|
return {
|
|
168
224
|
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
169
225
|
revision: result.state.revision,
|
|
@@ -175,7 +231,9 @@ export class TaskBoardHostService {
|
|
|
175
231
|
|
|
176
232
|
dispose(): void {
|
|
177
233
|
this.disposed = true
|
|
178
|
-
|
|
234
|
+
this.clearScheduleTimer()
|
|
235
|
+
this.pollTimer?.()
|
|
236
|
+
this.pollTimer = undefined
|
|
179
237
|
this.power.dispose()
|
|
180
238
|
this.ledger.dispose()
|
|
181
239
|
this.listeners.clear()
|
|
@@ -293,20 +351,72 @@ export class TaskBoardHostService {
|
|
|
293
351
|
}
|
|
294
352
|
}
|
|
295
353
|
|
|
296
|
-
|
|
354
|
+
/** Drop the armed schedule timer and forget its target. */
|
|
355
|
+
private clearScheduleTimer(): void {
|
|
356
|
+
this.scheduleTimer?.()
|
|
357
|
+
this.scheduleTimer = undefined
|
|
358
|
+
this.scheduleTarget = undefined
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Boot / resume recovery: skip every occurrence that came due while the
|
|
363
|
+
* board was not running and roll each schedule to its next future target,
|
|
364
|
+
* then arm the timer for the nearest one. Rendering the occurrence is
|
|
365
|
+
* deliberately not attempted: the ACL of a card that fired hours ago is
|
|
366
|
+
* stale, and the board's own recovery contract is "missed triggers are
|
|
367
|
+
* skipped, never replayed".
|
|
368
|
+
*/
|
|
369
|
+
private recoverSchedule(): void {
|
|
370
|
+
if (this.disposed) return
|
|
371
|
+
this.clearScheduleTimer()
|
|
372
|
+
const now = this.now()
|
|
373
|
+
this.ledger.setScheduler({ lastTickAt: now })
|
|
374
|
+
this.ledger.skipMissed(now)
|
|
375
|
+
this.armSchedule()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Arm the native timer at the nearest armed future trigger. One timer serves
|
|
380
|
+
* every schedule: the ledger's next target is the only instant the Host has
|
|
381
|
+
* to wake for. A target beyond the platform's timer ceiling re-arms in
|
|
382
|
+
* segments, and a target already past (the wall clock jumped, or the process
|
|
383
|
+
* was suspended) is handled immediately as a recovery.
|
|
384
|
+
*/
|
|
385
|
+
private armSchedule(): void {
|
|
386
|
+
if (this.disposed || !this.active) return
|
|
387
|
+
this.clearScheduleTimer()
|
|
388
|
+
const target = this.ledger.nextArmedRunAt(this.now())
|
|
389
|
+
if (target === undefined) return
|
|
390
|
+
this.scheduleTarget = target
|
|
391
|
+
const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS))
|
|
392
|
+
this.scheduleTimer = this.timers.timeout(() => {
|
|
393
|
+
this.scheduleTimer = undefined
|
|
394
|
+
this.onScheduleFire(target)
|
|
395
|
+
}, delay)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One armed target became due. A fire landing well past its target is a
|
|
400
|
+
* resume (suspend, throttle, forward clock jump) rather than a normal
|
|
401
|
+
* occurrence, so it takes the recovery path instead of launching a run for a
|
|
402
|
+
* long-stale instant.
|
|
403
|
+
*/
|
|
404
|
+
private onScheduleFire(target: number): void {
|
|
297
405
|
if (this.disposed || !this.active) return
|
|
298
406
|
const now = this.now()
|
|
299
|
-
|
|
300
|
-
this.lastScheduleTick = now
|
|
407
|
+
this.scheduleTarget = undefined
|
|
301
408
|
this.ledger.setScheduler({ lastTickAt: now })
|
|
302
|
-
if (
|
|
303
|
-
this.
|
|
409
|
+
if (now - target > RECOVERY_TOLERANCE_MS) {
|
|
410
|
+
this.recoverSchedule()
|
|
304
411
|
return
|
|
305
412
|
}
|
|
306
413
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
307
414
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
308
415
|
this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now))
|
|
309
416
|
}
|
|
417
|
+
// The launched run (or the rolled-forward target) moved every due schedule,
|
|
418
|
+
// so the next nearest target has to be recomputed from the ledger.
|
|
419
|
+
this.armSchedule()
|
|
310
420
|
}
|
|
311
421
|
|
|
312
422
|
private armedSchedules(): number {
|
|
@@ -344,12 +454,18 @@ export class TaskBoardHostService {
|
|
|
344
454
|
}).finally(() => { this.pollInFlight = false })
|
|
345
455
|
}
|
|
346
456
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
457
|
+
/**
|
|
458
|
+
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
459
|
+
* schedule (the host routes, the agent tools) invoke this after the write
|
|
460
|
+
* commits, so a new or edited trigger arms without waiting for the next fire.
|
|
461
|
+
*/
|
|
462
|
+
refreshSchedule(): void {
|
|
463
|
+
if (this.disposed) return
|
|
464
|
+
if (!this.active) {
|
|
465
|
+
this.clearScheduleTimer()
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
this.armSchedule()
|
|
353
469
|
}
|
|
354
470
|
|
|
355
471
|
private syncPowerReasons(): void {
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
18
18
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
19
19
|
import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
20
|
-
import { TaskBoardHostService, type TaskBoardTeamDispatcher } from './host-service.ts'
|
|
20
|
+
import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts'
|
|
21
21
|
import { parseTaskDraft, TaskParseError } from './host-ai.ts'
|
|
22
22
|
import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
|
|
23
23
|
import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
|
|
@@ -179,6 +179,27 @@ export function resolveToolRegistry(ctx: Context): AgentToolRegistry | undefined
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
|
|
184
|
+
* row). The board arms its schedule through it so unloading the row clears
|
|
185
|
+
* every pending timer with the owning fiber, instead of the row tracking raw
|
|
186
|
+
* process handles by hand. A composition that serves no timer service keeps
|
|
187
|
+
* working on the process globals.
|
|
188
|
+
* @param ctx - the plugin context.
|
|
189
|
+
* @returns the timer face, or undefined when this deployment serves none.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveHostTimers(ctx: Context): HostTimerFace | undefined {
|
|
192
|
+
try {
|
|
193
|
+
const timer = ctx.get('timer') as Partial<HostTimerFace> | undefined
|
|
194
|
+
if (timer === undefined) return undefined
|
|
195
|
+
return typeof timer.timeout === 'function' && typeof timer.interval === 'function'
|
|
196
|
+
? { timeout: timer.timeout.bind(timer), interval: timer.interval.bind(timer) }
|
|
197
|
+
: undefined
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
182
203
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
183
204
|
const DEFAULT_ANNOUNCE = false
|
|
184
205
|
|
|
@@ -301,6 +322,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
301
322
|
workspaceRegistry: ctx.workspaceRegistry,
|
|
302
323
|
sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
|
|
303
324
|
maxSubtaskDepth: maxSubtaskDepth(),
|
|
325
|
+
timers: resolveHostTimers(ctx),
|
|
304
326
|
team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
|
|
305
327
|
commandDispatcher: {
|
|
306
328
|
async execute(sessionId, line, signal) {
|