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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
@@ -0,0 +1,196 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
4
+ import type { TaskBoardHostService } from './host-service.ts'
5
+ import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
6
+ import { parseActionEnvelope, TASK_BOARD_API_PREFIX } from './protocol.ts'
7
+
8
+ const ACTION_LIMIT = 64 * 1024
9
+ const IMPORT_LIMIT = 2 * 1024 * 1024
10
+ const HEARTBEAT_MS = 15_000
11
+
12
+ /** Header replaced by an authenticated same-host reverse proxy. */
13
+ export const TASK_BOARD_PROXY_TOKEN_HEADER = 'x-dsh-task-board-proxy-token'
14
+
15
+ /** Optional authenticated reverse-proxy access layered over the loopback default. */
16
+ export interface TaskBoardRouteAccess {
17
+ trustedProxyHosts?: readonly string[]
18
+ proxyToken?: string
19
+ }
20
+
21
+ interface ResolvedRouteAccess {
22
+ trustedProxyHosts: ReadonlySet<string>
23
+ proxyToken?: string
24
+ }
25
+
26
+ function json(res: ServerResponse, status: number, body: unknown): void {
27
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
28
+ res.end(JSON.stringify(body))
29
+ }
30
+
31
+ function parseAuthority(authority: string): { canonical: string; url: URL } | undefined {
32
+ if (authority.trim() !== authority) return undefined
33
+ const match = authority.startsWith('[')
34
+ ? /^\[[^\]]+\](?::([0-9]+))?$/.exec(authority)
35
+ : /^[^:@/?#\s]+(?::([0-9]+))?$/.exec(authority)
36
+ if (match === null) return undefined
37
+ try {
38
+ const url = new URL(`http://${authority}`)
39
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined
40
+ const rawPort = match[1]
41
+ if (rawPort !== undefined && (String(Number(rawPort)) !== rawPort || Number(rawPort) > 65_535)) return undefined
42
+ return { canonical: url.hostname.toLowerCase() + (rawPort === undefined ? '' : `:${rawPort}`), url }
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function resolveAccess(access: TaskBoardRouteAccess): ResolvedRouteAccess {
49
+ const trustedProxyHosts = new Set<string>()
50
+ for (const authority of access.trustedProxyHosts ?? []) {
51
+ const parsed = parseAuthority(authority)
52
+ if (parsed === undefined || parsed.canonical !== authority.toLowerCase()) {
53
+ throw new Error(`task-board: trustedProxyHosts entry ${JSON.stringify(authority)} is not a canonical host[:port] authority`)
54
+ }
55
+ trustedProxyHosts.add(parsed.canonical)
56
+ }
57
+ if (trustedProxyHosts.size > 0 && (access.proxyToken === undefined || access.proxyToken === '')) {
58
+ throw new Error('task-board: authenticated proxy hosts require a non-empty proxy token')
59
+ }
60
+ return { trustedProxyHosts, ...(access.proxyToken === undefined ? {} : { proxyToken: access.proxyToken }) }
61
+ }
62
+
63
+ /**
64
+ * Browser-signal tripwire, NOT an authority check: a bare curl sends neither
65
+ * header and is refused, but a curl with a forged Origin passes this too.
66
+ * The real boundary is the loopback socket + Host + origin-equality checks
67
+ * in isTrustedTaskBoardRequest below; do not rely on this marker alone.
68
+ */
69
+ function browserSameOriginMarker(req: IncomingMessage): boolean {
70
+ const site = req.headers['sec-fetch-site']
71
+ return site === 'same-origin' || typeof req.headers.origin === 'string'
72
+ }
73
+
74
+ function sameAuthority(req: IncomingMessage, host: URL): boolean {
75
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
76
+ const origin = req.headers.origin
77
+ if (origin === undefined) return req.headers['sec-fetch-site'] === 'same-origin'
78
+ try {
79
+ return new URL(origin).host === host.host
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ function matchesToken(candidate: string | string[] | undefined, expected: string | undefined): boolean {
86
+ if (typeof candidate !== 'string' || expected === undefined || candidate === '' || expected === '') return false
87
+ const actual = Buffer.from(candidate)
88
+ const wanted = Buffer.from(expected)
89
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted)
90
+ }
91
+
92
+ /**
93
+ * Task-board route fence. Direct desktop access uses the repository-wide
94
+ * loopback socket + Host guard and additionally requires a browser same-origin
95
+ * marker: a bare local curl without any browser signal cannot exercise the
96
+ * agent control plane (a forged Origin does pass the marker — it is a
97
+ * tripwire, the socket/Host/origin-equality checks carry the authority).
98
+ * Authenticated proxies must be explicitly allowlisted and replace the
99
+ * internal token header after their own authentication step.
100
+ */
101
+ export function isTrustedTaskBoardRequest(req: IncomingMessage, access: ResolvedRouteAccess): boolean {
102
+ if (!browserSameOriginMarker(req)) return false
103
+ if (isLoopbackRequest(req)) return true
104
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false
105
+ const host = req.headers.host
106
+ if (typeof host !== 'string') return false
107
+ const parsed = parseAuthority(host)
108
+ if (parsed === undefined || parsed.canonical !== host.toLowerCase()) return false
109
+ if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false
110
+ return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken)
111
+ }
112
+
113
+ async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unknown }> {
114
+ const chunks: Buffer[] = []
115
+ let size = 0
116
+ for await (const chunk of req) {
117
+ const buffer = chunk as Buffer
118
+ size += buffer.length
119
+ if (size > IMPORT_LIMIT) throw new Error('body-too-large')
120
+ chunks.push(buffer)
121
+ }
122
+ const raw = Buffer.concat(chunks).toString('utf8')
123
+ return { raw, value: JSON.parse(raw) }
124
+ }
125
+
126
+ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskBoardRouteAccess = {}): WebRoute[] {
127
+ const resolvedAccess = resolveAccess(access)
128
+ const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
129
+ if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
130
+ json(res, 403, { ok: false, error: 'forbidden' })
131
+ return false
132
+ }
133
+ const state: WebRoute = {
134
+ kind: 'exact',
135
+ path: `${TASK_BOARD_API_PREFIX}/state`,
136
+ handler: (req, res): void => {
137
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
138
+ if (!guard(req, res)) return
139
+ json(res, 200, service.snapshot())
140
+ },
141
+ }
142
+ const action: WebRoute = {
143
+ kind: 'exact',
144
+ path: `${TASK_BOARD_API_PREFIX}/action`,
145
+ handler: async (req, res): Promise<void> => {
146
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
147
+ if (!guard(req, res)) return
148
+ if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
149
+ return json(res, 415, { ok: false, error: 'json-required' })
150
+ }
151
+ try {
152
+ const body = await readBody(req)
153
+ const parsed = parseActionEnvelope(body.value)
154
+ if (parsed === undefined) return json(res, 400, { ok: false, error: 'invalid-action' })
155
+ if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
156
+ return json(res, 413, { ok: false, error: 'body-too-large' })
157
+ }
158
+ json(res, 200, service.apply(parsed.requestId, parsed.action))
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error)
161
+ json(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
162
+ }
163
+ },
164
+ }
165
+ const events: WebRoute = {
166
+ kind: 'exact',
167
+ path: `${TASK_BOARD_API_PREFIX}/events`,
168
+ handler: (req, res): void => {
169
+ if (req.method !== 'GET') {
170
+ res.writeHead(405)
171
+ res.end()
172
+ return
173
+ }
174
+ if (!guard(req, res)) return
175
+ res.writeHead(200, {
176
+ 'content-type': 'text/event-stream; charset=utf-8',
177
+ 'cache-control': 'no-cache',
178
+ connection: 'keep-alive',
179
+ })
180
+ const push = (): void => {
181
+ const payload = service.eventPayload()
182
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
183
+ }
184
+ const unsubscribe = service.subscribe(push)
185
+ const heartbeat = setInterval(() => { res.write(': ping\n\n') }, HEARTBEAT_MS)
186
+ const close = (): void => {
187
+ clearInterval(heartbeat)
188
+ unsubscribe()
189
+ }
190
+ req.once('close', close)
191
+ res.once('close', close)
192
+ push()
193
+ },
194
+ }
195
+ return [state, action, events]
196
+ }
@@ -0,0 +1,152 @@
1
+ import type { ApiProxy, RpcId } from '@deepseek-ai/dsh-host-apiproxy'
2
+ import type { TaskRecord } from './core/tasks.ts'
3
+
4
+ function request<T>(payload: T) {
5
+ return { rpcId: `task-board-${crypto.randomUUID()}` as RpcId, payload }
6
+ }
7
+
8
+ function failure(error: { code: string; message: string }): Error {
9
+ return new Error(`${error.code}: ${error.message}`)
10
+ }
11
+
12
+ /** One session-list row, extracted from the sessions.list RPC result. */
13
+ export type SessionSummary = Extract<
14
+ Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'],
15
+ { ok: true }
16
+ >['value']['items'][number]
17
+
18
+ export type ExecutionInspection =
19
+ | { outcome: 'pending' }
20
+ | { outcome: 'succeeded' }
21
+ | { outcome: 'failed'; error: string }
22
+ | { outcome: 'cancelled'; error: string }
23
+
24
+ /** A post-create launch failure that still identifies the session to the ledger. */
25
+ export class SessionLaunchError extends Error {
26
+ constructor(readonly sessionId: string, cause: unknown) {
27
+ super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause })
28
+ this.name = 'SessionLaunchError'
29
+ }
30
+ }
31
+
32
+ function isErrorTurnEnd(data: unknown): boolean {
33
+ if (typeof data !== 'object' || data === null) return false
34
+ const reason = (data as { reason?: unknown }).reason
35
+ return typeof reason === 'object' && reason !== null && (reason as { kind?: unknown }).kind === 'error'
36
+ }
37
+
38
+ export class HostExecutionRunner {
39
+ constructor(private readonly api: ApiProxy) {}
40
+
41
+ async launch(task: TaskRecord): Promise<string> {
42
+ if (task.workspaceId !== undefined) {
43
+ const workspaces = await this.api.workspace.list(request({}))
44
+ if (!workspaces.result.ok) throw failure(workspaces.result.error)
45
+ if (!workspaces.result.value.items.some(item => item.workspaceId === task.workspaceId)) {
46
+ throw new Error(`workspace not found: ${task.workspaceId}`)
47
+ }
48
+ }
49
+ if (task.mode !== undefined) {
50
+ const presets = await this.api.agentPresets.list(request({}))
51
+ if (!presets.result.ok) throw failure(presets.result.error)
52
+ const preset = presets.result.value.presets.find(item => item.id === task.mode)
53
+ if (preset === undefined) throw new Error(`agent preset not found: ${task.mode}`)
54
+ if (preset.broken !== undefined) throw new Error(`agent preset is unavailable: ${preset.broken}`)
55
+ }
56
+ const created = await this.api.sessions.create(request({
57
+ ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId as never }),
58
+ ...(task.mode === undefined ? {} : { agentPreset: task.mode }),
59
+ }))
60
+ if (!created.result.ok) throw failure(created.result.error)
61
+ const sessionId = created.result.value.sessionId
62
+ try {
63
+ const renamed = await this.api.sessions.rename(request({ sessionId, title: task.title }))
64
+ if (!renamed.result.ok) throw failure(renamed.result.error)
65
+ if (task.permission !== undefined) {
66
+ const command = await this.api.sessions.prompt(request({
67
+ sessionId,
68
+ mode: 'queue' as const,
69
+ content: [{ type: 'text' as const, text: `/permission ${task.permission}` }],
70
+ }))
71
+ if (!command.result.ok) throw failure(command.result.error)
72
+ if (command.result.value.command?.kind !== 'success') throw new Error('permission command was not acknowledged')
73
+ }
74
+ const prompt = await this.api.sessions.prompt(request({
75
+ sessionId,
76
+ mode: 'queue' as const,
77
+ content: [{ type: 'text' as const, text: task.prompt !== '' ? task.prompt : task.title }],
78
+ }))
79
+ if (!prompt.result.ok) throw failure(prompt.result.error)
80
+ } catch (error) {
81
+ throw new SessionLaunchError(sessionId, error)
82
+ }
83
+ return sessionId
84
+ }
85
+
86
+ async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
87
+ try {
88
+ const response = await this.api.sessions.list(request({}))
89
+ return response.result.ok
90
+ ? { known: true, count: response.result.value.items.filter(item => item.running).length, items: response.result.value.items }
91
+ : { known: false }
92
+ } catch {
93
+ return { known: false }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Resolve one execution's outcome. The caller may pass the session list it
99
+ * already fetched this poll tick; otherwise inspect lists sessions itself.
100
+ * Sharing the list keeps a poll with E open executions at one list RPC
101
+ * instead of 1 + E.
102
+ */
103
+ async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection> {
104
+ let items: readonly SessionSummary[]
105
+ if (sessions !== undefined) {
106
+ items = sessions
107
+ } else {
108
+ const response = await this.api.sessions.list(request({}))
109
+ if (!response.result.ok) return { outcome: 'pending' }
110
+ items = response.result.value.items
111
+ }
112
+ const summary = items.find(item => item.sessionId === sessionId)
113
+ if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
114
+ if (summary.running) return { outcome: 'pending' }
115
+ const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
116
+ let beforeSeq: number | undefined
117
+ let reachedExecutionBoundary = false
118
+ for (let page = 0; page < 100; page += 1) {
119
+ const history = await this.api.sessions.history(request({
120
+ sessionId: summary.sessionId,
121
+ maxMessages: 100,
122
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
123
+ }))
124
+ if (!history.result.ok) return { outcome: 'pending' }
125
+ events.push(...history.result.value.events)
126
+ const oldestTime = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
127
+ const time = entry.event.time
128
+ return typeof time !== 'number' ? oldest : oldest === undefined ? time : Math.min(oldest, time)
129
+ }, undefined)
130
+ if (!history.result.value.hasMore || (oldestTime !== undefined && oldestTime <= startedAt)) {
131
+ reachedExecutionBoundary = true
132
+ break
133
+ }
134
+ const oldestSeq = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
135
+ const seq = entry.event.seq
136
+ return typeof seq !== 'number' ? oldest : oldest === undefined ? seq : Math.min(oldest, seq)
137
+ }, undefined)
138
+ if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
139
+ beforeSeq = oldestSeq
140
+ }
141
+ if (!reachedExecutionBoundary) return { outcome: 'pending' }
142
+ const turnEnd = events
143
+ .filter(entry => entry.event.type === 'turn/end' && (
144
+ startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
145
+ ))
146
+ .sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
147
+ if (turnEnd === undefined) return { outcome: 'pending' }
148
+ return isErrorTurnEnd(turnEnd.event.data)
149
+ ? { outcome: 'failed', error: 'agent turn ended with an error' }
150
+ : { outcome: 'succeeded' }
151
+ }
152
+ }
@@ -0,0 +1,226 @@
1
+ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
2
+ import { nextRunAtMs } from './core/schedule.ts'
3
+ import type { TaskRecord } from './core/tasks.ts'
4
+ import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
5
+ import { HostExecutionRunner, SessionLaunchError, type SessionSummary } from './host-runner.ts'
6
+ import { PowerInhibitor } from './power-inhibitor.ts'
7
+ import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
8
+
9
+ const SESSION_POLL_MS = 5_000
10
+ const SCHEDULE_TICK_MS = 30_000
11
+ const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
12
+
13
+ export class TaskBoardHostService {
14
+ readonly ledger: HostTaskLedger
15
+ readonly runner: HostExecutionRunner
16
+ readonly power: PowerInhibitor
17
+ private readonly listeners = new Set<() => void>()
18
+ private timers: Array<ReturnType<typeof setInterval>> = []
19
+ private lastScheduleTick: number | undefined
20
+ private disposed = false
21
+ private pollInFlight = false
22
+ private tickInFlight = false
23
+ private active = true
24
+ private preventIdleSleep = false
25
+ private lastPowerJson = ''
26
+ private readonly now: () => number
27
+
28
+ constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
29
+ this.ledger = options.ledger ?? new HostTaskLedger()
30
+ this.runner = new HostExecutionRunner(api)
31
+ this.power = options.power ?? new PowerInhibitor()
32
+ this.now = options.now ?? Date.now
33
+ this.ledger.subscribe(() => {
34
+ this.syncPowerReasons()
35
+ this.emit()
36
+ })
37
+ this.power.subscribe(() => {
38
+ // updateReasons emits on every poll tick even when nothing changed;
39
+ // gate on the actual snapshot so the 5 s heartbeat does not push an
40
+ // empty SSE frame per tab forever.
41
+ const json = JSON.stringify(this.power.snapshot())
42
+ if (json === this.lastPowerJson) return
43
+ this.lastPowerJson = json
44
+ this.emit()
45
+ })
46
+ }
47
+
48
+ start(): void {
49
+ if (this.disposed || this.timers.length > 0) return
50
+ this.syncPowerReasons()
51
+ this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
52
+ this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
53
+ this.schedulePoll()
54
+ this.scheduleTick(true)
55
+ }
56
+
57
+ setConfiguration(active: boolean, preventIdleSleep: boolean): void {
58
+ const resumed = !this.active && active
59
+ this.active = active
60
+ this.preventIdleSleep = preventIdleSleep
61
+ if (resumed) {
62
+ const current = this.power.snapshot()
63
+ this.power.updateReasons({
64
+ runningSessions: current.runningSessions,
65
+ armedSchedules: this.armedSchedules(),
66
+ sessionStateKnown: false,
67
+ })
68
+ }
69
+ this.power.setEnabled(active && preventIdleSleep)
70
+ if (resumed) {
71
+ this.schedulePoll()
72
+ this.scheduleTick(true)
73
+ }
74
+ this.emit()
75
+ }
76
+
77
+ snapshot(): TaskBoardSnapshot {
78
+ const state = this.ledger.state()
79
+ return {
80
+ schemaVersion: 2,
81
+ revision: state.revision,
82
+ tasks: state.tasks,
83
+ scheduler: state.scheduler,
84
+ power: this.power.snapshot(),
85
+ }
86
+ }
87
+
88
+ /** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
89
+ eventPayload(): TaskBoardEventPayload {
90
+ const { revision, scheduler } = this.ledger.summary()
91
+ return { revision, scheduler, power: this.power.snapshot() }
92
+ }
93
+
94
+ subscribe(listener: () => void): () => void {
95
+ this.listeners.add(listener)
96
+ return () => { this.listeners.delete(listener) }
97
+ }
98
+
99
+ apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot {
100
+ if (!this.active) throw new Error('task board is disabled')
101
+ const result = this.ledger.applyRequest(requestId, action)
102
+ if (result.run !== undefined) this.scheduleLaunch(result.run)
103
+ return {
104
+ schemaVersion: 2,
105
+ revision: result.state.revision,
106
+ tasks: result.state.tasks,
107
+ scheduler: result.state.scheduler,
108
+ power: this.power.snapshot(),
109
+ }
110
+ }
111
+
112
+ dispose(): void {
113
+ this.disposed = true
114
+ for (const timer of this.timers.splice(0)) clearInterval(timer)
115
+ this.power.dispose()
116
+ this.ledger.dispose()
117
+ this.listeners.clear()
118
+ }
119
+
120
+ private async launch(opened: OpenedRun): Promise<void> {
121
+ try {
122
+ const sessionId = await this.runner.launch(opened.task)
123
+ this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
124
+ } catch (error) {
125
+ if (error instanceof SessionLaunchError) {
126
+ this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
127
+ }
128
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
129
+ }
130
+ }
131
+
132
+ private async pollSessions(): Promise<void> {
133
+ if (this.disposed) return
134
+ if (!this.active && !this.hasOpenExecutions()) return
135
+ const running = await this.runner.listRunning()
136
+ const previous = this.power.snapshot()
137
+ this.power.updateReasons({
138
+ runningSessions: running.known ? running.count : previous.runningSessions,
139
+ armedSchedules: this.armedSchedules(),
140
+ sessionStateKnown: running.known,
141
+ })
142
+ // No unconditional emit here: real changes already emit through the
143
+ // ledger subscription (settles) and the gated power listener above.
144
+ if (running.known) await this.reconcileExecutions(running.items)
145
+ }
146
+
147
+ /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
148
+ private async reconcileExecutions(sessions: readonly SessionSummary[]): Promise<void> {
149
+ for (const task of this.ledger.state().tasks) {
150
+ for (const execution of task.executions) {
151
+ if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
152
+ try {
153
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
154
+ if (result.outcome === 'pending') continue
155
+ this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
156
+ } catch {
157
+ // A transient inspection failure never settles a running execution.
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ private async tickSchedule(first: boolean): Promise<void> {
164
+ if (this.disposed || !this.active) return
165
+ const now = this.now()
166
+ const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
167
+ this.lastScheduleTick = now
168
+ this.ledger.setScheduler({ lastTickAt: now })
169
+ if (recovered) {
170
+ this.ledger.skipMissed(now)
171
+ return
172
+ }
173
+ for (const task of this.ledger.state().tasks) {
174
+ if (task.archivedAt !== undefined) continue
175
+ const schedule = task.schedule
176
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
177
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
178
+ const opened = this.ledger.openScheduled(task.id, next, now)
179
+ if (opened !== undefined) this.scheduleLaunch(opened)
180
+ }
181
+ }
182
+
183
+ private armedSchedules(): number {
184
+ return this.ledger.state().tasks.filter((task: TaskRecord) => task.archivedAt === undefined && task.schedule?.enabled === true).length
185
+ }
186
+
187
+ private hasOpenExecutions(): boolean {
188
+ return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
189
+ }
190
+
191
+ private scheduleLaunch(opened: OpenedRun): void {
192
+ void this.launch(opened).catch(error => {
193
+ console.error('[dsh-task-board] execution launch settlement failed', error)
194
+ })
195
+ }
196
+
197
+ private schedulePoll(): void {
198
+ if (this.pollInFlight || this.disposed) return
199
+ this.pollInFlight = true
200
+ void this.pollSessions().catch(error => {
201
+ console.error('[dsh-task-board] session polling failed', error)
202
+ }).finally(() => { this.pollInFlight = false })
203
+ }
204
+
205
+ private scheduleTick(first: boolean): void {
206
+ if (this.tickInFlight || this.disposed) return
207
+ this.tickInFlight = true
208
+ void this.tickSchedule(first).catch(error => {
209
+ console.error('[dsh-task-board] scheduler tick failed', error)
210
+ }).finally(() => { this.tickInFlight = false })
211
+ }
212
+
213
+ private syncPowerReasons(): void {
214
+ const current = this.power.snapshot()
215
+ this.power.updateReasons({
216
+ runningSessions: current.runningSessions,
217
+ armedSchedules: this.armedSchedules(),
218
+ sessionStateKnown: current.sessionStateKnown,
219
+ })
220
+ this.power.setEnabled(this.active && this.preventIdleSleep)
221
+ }
222
+
223
+ private emit(): void {
224
+ for (const listener of [...this.listeners]) listener()
225
+ }
226
+ }