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

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 (52) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +82 -104
  3. package/README.zh.md +82 -104
  4. package/lib/client.js +574 -581
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2144 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -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/core/controller.d.ts +40 -15
  13. package/lib/types/core/schedule.d.ts +4 -2
  14. package/lib/types/core/tasks.d.ts +6 -6
  15. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  16. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  17. package/lib/types/dsh-home.d.ts +16 -0
  18. package/lib/types/host-ledger.d.ts +42 -0
  19. package/lib/types/host-routes.d.ts +24 -0
  20. package/lib/types/host-runner.d.ts +30 -0
  21. package/lib/types/host-service.d.ts +41 -0
  22. package/lib/types/index.d.ts +17 -9
  23. package/lib/types/loopback.d.ts +24 -0
  24. package/lib/types/power-inhibitor.d.ts +73 -0
  25. package/lib/types/protocol.d.ts +72 -0
  26. package/package.json +12 -9
  27. package/src/client/PluginSettingsCard.tsx +10 -0
  28. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  29. package/src/client/board/NewTaskModal.tsx +9 -6
  30. package/src/client/board/TaskBoard.tsx +21 -4
  31. package/src/client/board/TaskCard.tsx +24 -8
  32. package/src/client/board/TaskDetail.tsx +83 -50
  33. package/src/client/host-api.ts +95 -0
  34. package/src/client/index.ts +9 -78
  35. package/src/client/locales.ts +24 -4
  36. package/src/core/controller.ts +205 -24
  37. package/src/core/schedule.ts +20 -3
  38. package/src/core/store.ts +4 -8
  39. package/src/core/tasks.ts +6 -6
  40. package/src/core/use-cases/task-archive.ts +14 -5
  41. package/src/core/use-cases/task-schedule.ts +6 -5
  42. package/src/dsh-home.ts +36 -0
  43. package/src/host-ledger.ts +478 -0
  44. package/src/host-routes.ts +188 -0
  45. package/src/host-runner.ts +134 -0
  46. package/src/host-service.ts +209 -0
  47. package/src/index.ts +54 -11
  48. package/src/loopback.ts +63 -0
  49. package/src/power-inhibitor.ts +321 -0
  50. package/src/protocol.ts +213 -0
  51. package/lib/types/core/scheduler.d.ts +0 -70
  52. package/src/core/scheduler.ts +0 -161
@@ -0,0 +1,321 @@
1
+ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, type SpawnSyncReturns } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { win32 } from 'node:path'
4
+ import type { PowerPhase, TaskBoardPowerSnapshot } from './protocol.ts'
5
+
6
+ const RETRY_DELAYS = [1_000, 2_000, 5_000, 10_000, 30_000] as const
7
+ const DARWIN_STABLE_MS = 30_000
8
+
9
+ const WINDOWS_HELPER = String.raw`
10
+ $source = @'
11
+ using System;
12
+ using System.Runtime.InteropServices;
13
+ public static class DshExecutionState {
14
+ [DllImport("kernel32.dll", SetLastError = true)]
15
+ public static extern uint SetThreadExecutionState(uint flags);
16
+ }
17
+ '@
18
+ Add-Type -TypeDefinition $source
19
+ $continuous = [Convert]::ToUInt32('80000000', 16)
20
+ $systemRequired = [uint32]0x00000001
21
+ try {
22
+ $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)
23
+ if ($result -eq 0) { throw 'SetThreadExecutionState failed' }
24
+ [Console]::Out.WriteLine('READY')
25
+ [Console]::Out.Flush()
26
+ while ([Console]::In.ReadLine() -ne $null) { }
27
+ } finally {
28
+ [void][DshExecutionState]::SetThreadExecutionState($continuous)
29
+ }
30
+ `
31
+
32
+ const LINUX_HELPER = String.raw`
33
+ process.stdout.write('READY\n')
34
+ process.stdin.resume()
35
+ `
36
+
37
+ const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit'] as const
38
+
39
+ export interface PowerReasons {
40
+ runningSessions: number
41
+ armedSchedules: number
42
+ sessionStateKnown: boolean
43
+ }
44
+
45
+ export interface SpawnOptions {
46
+ shell: false
47
+ windowsHide: boolean
48
+ stdio: ['pipe', 'pipe', 'pipe'] | ['ignore', 'ignore', 'ignore']
49
+ }
50
+
51
+ export type SpawnLike = (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess
52
+ export type SpawnSyncLike = (
53
+ file: string,
54
+ args: readonly string[],
55
+ options: { stdio: 'ignore'; timeout: number; windowsHide: boolean },
56
+ ) => Pick<SpawnSyncReturns<Buffer>, 'status' | 'error'>
57
+
58
+ export interface PowerInhibitorOptions {
59
+ platform?: NodeJS.Platform
60
+ pid?: number
61
+ env?: NodeJS.ProcessEnv
62
+ spawn?: SpawnLike
63
+ spawnSync?: SpawnSyncLike
64
+ exists?: typeof existsSync
65
+ execPath?: string
66
+ setTimeout?: typeof globalThis.setTimeout
67
+ clearTimeout?: typeof globalThis.clearTimeout
68
+ }
69
+
70
+ export class PowerInhibitor {
71
+ private readonly listeners = new Set<() => void>()
72
+ private enabled = false
73
+ private reasons: PowerReasons = { runningSessions: 0, armedSchedules: 0, sessionStateKnown: false }
74
+ private phase: PowerPhase = 'disabled'
75
+ private child: ChildProcess | undefined
76
+ private retry: ReturnType<typeof setTimeout> | undefined
77
+ private retryReset: ReturnType<typeof setTimeout> | undefined
78
+ private retryIndex = 0
79
+ private lastError: string | undefined
80
+ private stopping = false
81
+ private readonly platform: NodeJS.Platform
82
+ private readonly pid: number
83
+ private readonly env: NodeJS.ProcessEnv
84
+ private readonly spawn: SpawnLike
85
+ private readonly spawnSync: SpawnSyncLike
86
+ private readonly exists: typeof existsSync
87
+ private readonly execPath: string
88
+ private readonly timer: typeof globalThis.setTimeout
89
+ private readonly clearTimer: typeof globalThis.clearTimeout
90
+ private linuxProbe: { executable?: string; available: boolean } | undefined
91
+
92
+ constructor(options: PowerInhibitorOptions = {}) {
93
+ this.platform = options.platform ?? process.platform
94
+ this.pid = options.pid ?? process.pid
95
+ this.env = options.env ?? process.env
96
+ this.spawn = options.spawn ?? ((file, args, spawnOptions) => nodeSpawn(file, [...args], spawnOptions))
97
+ this.spawnSync = options.spawnSync ?? ((file, args, spawnOptions) => nodeSpawnSync(file, [...args], spawnOptions))
98
+ this.exists = options.exists ?? existsSync
99
+ this.execPath = options.execPath ?? process.execPath
100
+ this.timer = options.setTimeout ?? globalThis.setTimeout
101
+ this.clearTimer = options.clearTimeout ?? globalThis.clearTimeout
102
+ }
103
+
104
+ setEnabled(enabled: boolean): void {
105
+ this.enabled = enabled
106
+ this.sync()
107
+ this.emit()
108
+ }
109
+
110
+ updateReasons(reasons: PowerReasons): void {
111
+ this.reasons = reasons
112
+ this.sync()
113
+ this.emit()
114
+ }
115
+
116
+ subscribe(listener: () => void): () => void {
117
+ this.listeners.add(listener)
118
+ return () => { this.listeners.delete(listener) }
119
+ }
120
+
121
+ snapshot(): TaskBoardPowerSnapshot {
122
+ return {
123
+ platform: this.platform,
124
+ phase: this.phase,
125
+ enabled: this.enabled,
126
+ ...this.reasons,
127
+ ...(this.lastError === undefined ? {} : { lastError: this.lastError }),
128
+ }
129
+ }
130
+
131
+ dispose(): void {
132
+ this.enabled = false
133
+ this.release()
134
+ this.phase = 'disabled'
135
+ this.emit()
136
+ this.listeners.clear()
137
+ }
138
+
139
+ private desired(): boolean {
140
+ return this.enabled && (!this.reasons.sessionStateKnown || this.reasons.runningSessions > 0 || this.reasons.armedSchedules > 0)
141
+ }
142
+
143
+ private sync(): void {
144
+ if (!this.enabled) {
145
+ this.release()
146
+ this.phase = 'disabled'
147
+ return
148
+ }
149
+ if (this.platform !== 'darwin' && this.platform !== 'win32' && this.platform !== 'linux') {
150
+ this.release()
151
+ this.phase = 'unsupported'
152
+ return
153
+ }
154
+ if (this.platform === 'linux' && this.linuxSystemdInhibit() === undefined) {
155
+ this.release()
156
+ this.phase = 'unsupported'
157
+ return
158
+ }
159
+ if (!this.desired()) {
160
+ this.release()
161
+ this.phase = 'idle'
162
+ return
163
+ }
164
+ if (this.child === undefined && this.retry === undefined) this.acquire()
165
+ }
166
+
167
+ private acquire(): void {
168
+ this.phase = 'acquiring'
169
+ this.emit()
170
+ this.stopping = false
171
+ try {
172
+ const child = this.spawnCommand()
173
+ this.child = child
174
+ let ready = false
175
+ let stderr = ''
176
+ if (this.platform === 'darwin') {
177
+ child.once('spawn', () => {
178
+ ready = true
179
+ this.markReady(child)
180
+ })
181
+ }
182
+ child.stdout?.on('data', (chunk: Buffer) => {
183
+ if (!ready && chunk.toString('utf8').includes('READY')) {
184
+ ready = true
185
+ this.markReady(child)
186
+ }
187
+ })
188
+ child.stderr?.on('data', (chunk: Buffer) => {
189
+ stderr = `${stderr}${chunk.toString('utf8')}`.slice(-2_000)
190
+ })
191
+ child.on('error', error => { this.fail(error, child) })
192
+ child.on('exit', (code, signal) => {
193
+ if (this.child !== child) return
194
+ this.child = undefined
195
+ if (this.stopping || !this.desired()) return
196
+ const detail = stderr.trim()
197
+ this.fail(new Error(`power helper exited (${String(code ?? signal ?? 'unknown')})${detail === '' ? '' : `: ${detail}`}`))
198
+ })
199
+ } catch (error) {
200
+ this.fail(error)
201
+ }
202
+ }
203
+
204
+ private markReady(child: ChildProcess): void {
205
+ if (this.child !== child || !this.desired()) return
206
+ this.phase = 'active'
207
+ if (this.platform === 'darwin') {
208
+ this.retryReset = this.timer(() => {
209
+ this.retryReset = undefined
210
+ if (this.child === child && this.desired()) this.retryIndex = 0
211
+ }, DARWIN_STABLE_MS)
212
+ } else {
213
+ this.retryIndex = 0
214
+ }
215
+ this.lastError = undefined
216
+ this.emit()
217
+ }
218
+
219
+ private fail(error: unknown, source?: ChildProcess): void {
220
+ if (source !== undefined && this.child !== source) return
221
+ this.clearRetryReset()
222
+ this.lastError = error instanceof Error ? error.message : String(error)
223
+ this.phase = 'error'
224
+ this.emit()
225
+ const child = this.child
226
+ this.child = undefined
227
+ child?.stdin?.end()
228
+ child?.kill()
229
+ if (!this.desired() || this.retry !== undefined) return
230
+ const delay = RETRY_DELAYS[Math.min(this.retryIndex, RETRY_DELAYS.length - 1)]
231
+ this.retryIndex += 1
232
+ this.retry = this.timer(() => {
233
+ this.retry = undefined
234
+ if (this.desired()) this.acquire()
235
+ }, delay)
236
+ }
237
+
238
+ private release(): void {
239
+ this.clearRetryReset()
240
+ if (this.retry !== undefined) {
241
+ this.clearTimer(this.retry)
242
+ this.retry = undefined
243
+ }
244
+ this.lastError = undefined
245
+ const child = this.child
246
+ this.child = undefined
247
+ if (child === undefined) return
248
+ this.stopping = true
249
+ if (this.platform === 'win32' || this.platform === 'linux') {
250
+ child.stdin?.end()
251
+ const force = this.timer(() => { if (child.exitCode === null) child.kill() }, 1_000)
252
+ child.once('exit', () => { this.clearTimer(force) })
253
+ } else {
254
+ child.kill('SIGTERM')
255
+ }
256
+ }
257
+
258
+ private clearRetryReset(): void {
259
+ if (this.retryReset === undefined) return
260
+ this.clearTimer(this.retryReset)
261
+ this.retryReset = undefined
262
+ }
263
+
264
+ private windowsPowerShell(): string {
265
+ const root = this.env.SystemRoot
266
+ if (root === undefined || root.trim() === '') throw new Error('SystemRoot is unavailable')
267
+ if (!win32.isAbsolute(root)) throw new Error('SystemRoot is not an absolute path')
268
+ return win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
269
+ }
270
+
271
+ private linuxSystemdInhibit(): string | undefined {
272
+ if (this.linuxProbe !== undefined) return this.linuxProbe.available ? this.linuxProbe.executable : undefined
273
+ const executable = LINUX_INHIBIT_PATHS.find(path => this.exists(path))
274
+ if (executable === undefined) {
275
+ this.linuxProbe = { available: false }
276
+ return undefined
277
+ }
278
+ const probe = this.spawnSync(executable, ['--list', '--no-pager'], {
279
+ stdio: 'ignore',
280
+ timeout: 2_000,
281
+ windowsHide: true,
282
+ })
283
+ this.linuxProbe = { executable, available: probe.status === 0 && probe.error === undefined }
284
+ return this.linuxProbe.available ? executable : undefined
285
+ }
286
+
287
+ private spawnCommand(): ChildProcess {
288
+ if (this.platform === 'darwin') {
289
+ return this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
290
+ shell: false,
291
+ windowsHide: false,
292
+ stdio: ['ignore', 'ignore', 'ignore'],
293
+ })
294
+ }
295
+ if (this.platform === 'linux') {
296
+ const executable = this.linuxSystemdInhibit()
297
+ if (executable === undefined) throw new Error('systemd-inhibit is unavailable')
298
+ return this.spawn(executable, [
299
+ '--what=idle',
300
+ '--who=DeepSeek Harness task board',
301
+ '--why=DSH sessions are running or schedules are armed',
302
+ '--mode=block',
303
+ '--',
304
+ this.execPath,
305
+ '-e',
306
+ LINUX_HELPER,
307
+ ], { shell: false, windowsHide: false, stdio: ['pipe', 'pipe', 'pipe'] })
308
+ }
309
+ return this.spawn(this.windowsPowerShell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
310
+ shell: false,
311
+ windowsHide: true,
312
+ stdio: ['pipe', 'pipe', 'pipe'],
313
+ })
314
+ }
315
+
316
+ private emit(): void {
317
+ for (const listener of [...this.listeners]) listener()
318
+ }
319
+ }
320
+
321
+ export { LINUX_HELPER, LINUX_INHIBIT_PATHS, WINDOWS_HELPER }
@@ -0,0 +1,213 @@
1
+ import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
2
+ import { isTaskPermission, isTaskStatus, type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts'
3
+ import { parseLedger } from './core/store.ts'
4
+
5
+ export const TASK_BOARD_SCHEMA_VERSION = 2 as const
6
+ export const TASK_BOARD_API_PREFIX = '/api/task-board'
7
+
8
+ export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
9
+
10
+ export interface TaskBoardPowerSnapshot {
11
+ platform: string
12
+ phase: PowerPhase
13
+ enabled: boolean
14
+ runningSessions: number
15
+ armedSchedules: number
16
+ sessionStateKnown: boolean
17
+ lastError?: string
18
+ }
19
+
20
+ export interface TaskBoardSchedulerSnapshot {
21
+ timeZone: string
22
+ /** Opaque identity of the current Host ledger generation. */
23
+ ledgerId?: string
24
+ lastTickAt?: number
25
+ error?: string
26
+ }
27
+
28
+ export interface TaskBoardSnapshot {
29
+ schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
30
+ revision: number
31
+ tasks: TaskRecord[]
32
+ scheduler: TaskBoardSchedulerSnapshot
33
+ power: TaskBoardPowerSnapshot
34
+ }
35
+
36
+ export type TaskBoardAction =
37
+ | { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
38
+ | { kind: 'create'; id: string; input: NewTaskInput }
39
+ | { kind: 'update'; taskId: string; patch: TaskUpdatePatch }
40
+ | { kind: 'delete'; taskId: string }
41
+ | { kind: 'move'; taskId: string; status: TaskStatus }
42
+ | { kind: 'archive'; taskId: string }
43
+ | { kind: 'restore'; taskId: string }
44
+ | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string } }
45
+ | { kind: 'run'; taskId: string }
46
+ | { kind: 'rerun'; taskId: string }
47
+
48
+ export interface TaskBoardActionEnvelope {
49
+ requestId: string
50
+ action: TaskBoardAction
51
+ }
52
+
53
+ function record(value: unknown): Record<string, unknown> | undefined {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
55
+ ? value as Record<string, unknown>
56
+ : undefined
57
+ }
58
+
59
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
60
+ return Object.keys(value).every(key => allowed.includes(key))
61
+ }
62
+
63
+ function optionalString(value: unknown): boolean {
64
+ return value === undefined || typeof value === 'string'
65
+ }
66
+
67
+ const FORBIDDEN_IMPORT_FIELDS = new Set(['args', 'command', 'executable', 'powershell', 'shell'])
68
+
69
+ function hasForbiddenImportField(value: unknown): boolean {
70
+ if (Array.isArray(value)) return value.some(hasForbiddenImportField)
71
+ const row = record(value)
72
+ if (row === undefined) return false
73
+ return Object.entries(row).some(([key, nested]) => FORBIDDEN_IMPORT_FIELDS.has(key.toLowerCase()) || hasForbiddenImportField(nested))
74
+ }
75
+
76
+ function optionalFiniteNumber(value: unknown): boolean {
77
+ return value === undefined || (typeof value === 'number' && Number.isFinite(value))
78
+ }
79
+
80
+ function validImportedKnownFields(value: Record<string, unknown>): boolean {
81
+ if (value.schedule !== undefined) {
82
+ const schedule = record(value.schedule)
83
+ if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
84
+ if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
85
+ }
86
+ if (value.executions !== undefined) {
87
+ if (!Array.isArray(value.executions)) return false
88
+ for (const item of value.executions) {
89
+ const execution = record(item)
90
+ if (execution === undefined || typeof execution.id !== 'string' || !optionalString(execution.sessionId)) return false
91
+ if (typeof execution.startedAt !== 'number' || !Number.isFinite(execution.startedAt)) return false
92
+ if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false
93
+ if (execution.result !== undefined && !['succeeded', 'failed', 'cancelled'].includes(String(execution.result))) return false
94
+ }
95
+ }
96
+ return true
97
+ }
98
+
99
+ function importedTask(value: unknown): TaskRecord | undefined {
100
+ const input = record(value)
101
+ if (input === undefined || hasForbiddenImportField(input) || !validImportedKnownFields(input)) return undefined
102
+ const task = parseLedger(JSON.stringify([value]))[0]
103
+ if (task === undefined) return undefined
104
+ return {
105
+ id: task.id,
106
+ title: task.title,
107
+ description: task.description,
108
+ prompt: task.prompt,
109
+ status: task.status,
110
+ createdAt: task.createdAt,
111
+ updatedAt: task.updatedAt,
112
+ executions: task.executions.map(execution => ({
113
+ id: execution.id,
114
+ sessionId: execution.sessionId,
115
+ startedAt: execution.startedAt,
116
+ endedAt: execution.endedAt,
117
+ result: execution.result,
118
+ error: execution.error,
119
+ })),
120
+ ...(task.schedule === undefined ? {} : {
121
+ schedule: {
122
+ enabled: task.schedule.enabled,
123
+ cron: task.schedule.cron,
124
+ nextRunAt: task.schedule.nextRunAt,
125
+ lastTriggeredAt: task.schedule.lastTriggeredAt,
126
+ },
127
+ }),
128
+ ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
129
+ ...(task.mode === undefined ? {} : { mode: task.mode }),
130
+ ...(task.permission === undefined ? {} : { permission: task.permission }),
131
+ ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
132
+ }
133
+ }
134
+
135
+ function createInput(value: unknown): value is NewTaskInput {
136
+ const input = record(value)
137
+ if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule'])) return false
138
+ if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
139
+ if (!optionalString(input.workspaceId) || !optionalString(input.mode)) return false
140
+ if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
141
+ if (input.schedule !== undefined) {
142
+ const schedule = record(input.schedule)
143
+ if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
144
+ if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
145
+ }
146
+ return true
147
+ }
148
+
149
+ function updatePatch(value: unknown): boolean {
150
+ const patch = record(value)
151
+ if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission'])) return false
152
+ for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode'] as const) {
153
+ if (!optionalString(patch[key])) return false
154
+ }
155
+ return patch.permission === undefined || isTaskPermission(patch.permission)
156
+ }
157
+
158
+ function schedulePatch(value: unknown): boolean {
159
+ const patch = record(value)
160
+ return patch !== undefined
161
+ && exactKeys(patch, ['enabled', 'cron'])
162
+ && (patch.enabled === undefined || typeof patch.enabled === 'boolean')
163
+ && (patch.cron === undefined || typeof patch.cron === 'string')
164
+ }
165
+
166
+ export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
167
+ const envelope = record(value)
168
+ if (envelope === undefined || !exactKeys(envelope, ['requestId', 'action'])) return undefined
169
+ if (typeof envelope.requestId !== 'string' || envelope.requestId.trim() === '' || envelope.requestId.length > 256) return undefined
170
+ const action = record(envelope.action)
171
+ if (action === undefined || typeof action.kind !== 'string') return undefined
172
+ const taskId = typeof action.taskId === 'string' && action.taskId !== '' ? action.taskId : undefined
173
+ switch (action.kind) {
174
+ case 'import':
175
+ if (!exactKeys(action, ['kind', 'sourceId', 'tasks'])) return undefined
176
+ if (typeof action.sourceId !== 'string' || action.sourceId === '' || !Array.isArray(action.tasks)) return undefined
177
+ {
178
+ const tasks = action.tasks.map(importedTask)
179
+ return tasks.every((task): task is TaskRecord => task !== undefined)
180
+ ? { requestId: envelope.requestId, action: { kind: 'import', sourceId: action.sourceId, tasks } }
181
+ : undefined
182
+ }
183
+ case 'create':
184
+ if (!exactKeys(action, ['kind', 'id', 'input'])) return undefined
185
+ return typeof action.id === 'string' && action.id !== '' && createInput(action.input)
186
+ ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'create' }> }
187
+ : undefined
188
+ case 'update':
189
+ if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
190
+ return taskId !== undefined && updatePatch(action.patch)
191
+ ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'update' }> }
192
+ : undefined
193
+ case 'set-schedule':
194
+ if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
195
+ return taskId !== undefined && schedulePatch(action.patch)
196
+ ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'set-schedule' }> }
197
+ : undefined
198
+ case 'move':
199
+ if (!exactKeys(action, ['kind', 'taskId', 'status'])) return undefined
200
+ return taskId !== undefined && isTaskStatus(action.status)
201
+ ? { requestId: envelope.requestId, action: action as unknown as Extract<TaskBoardAction, { kind: 'move' }> }
202
+ : undefined
203
+ case 'delete':
204
+ case 'archive':
205
+ case 'restore':
206
+ case 'run':
207
+ case 'rerun':
208
+ if (!exactKeys(action, ['kind', 'taskId'])) return undefined
209
+ return taskId === undefined ? undefined : { requestId: envelope.requestId, action: action as TaskBoardAction }
210
+ default:
211
+ return undefined
212
+ }
213
+ }
@@ -1,70 +0,0 @@
1
- import type { TaskRecord } from './tasks.ts';
2
- /** Everything the scheduler needs from its host (the board controller). */
3
- export interface SchedulerDeps {
4
- /** Read the current task ledger (the controller snapshot). */
5
- tasks(): readonly TaskRecord[];
6
- /**
7
- * Re-sync the task ledger from its persisted source before the tick decides
8
- * what is due. The wiring passes the controller's store reload so a task
9
- * deleted elsewhere can never be fired from a stale in-memory copy.
10
- * Optional: absent in pure in-memory harnesses.
11
- */
12
- refresh?: () => void;
13
- /** Clock; defaults to Date.now in the controller wiring. */
14
- now(): number;
15
- /** Trigger one task's real execution (the controller's runTask); resolves
16
- * true when the run was accepted, false when rejected (e.g. the task is
17
- * already running). */
18
- runTask(id: string): Promise<boolean>;
19
- /** Persist a rolled-forward schedule (next due + this trigger instant). */
20
- applySchedule(id: string, nextRunAt: number | undefined, lastTriggeredAt: number | undefined): void;
21
- /** Tick cadence; defaults to 60_000 ms. */
22
- tickMs?: number;
23
- /**
24
- * Gate: while false the tick no-ops (e.g. the session list baseline has not
25
- * arrived on page load, so executions would fail). Defaults to always ready.
26
- */
27
- ready?: () => boolean;
28
- /** Environment listeners for tab-visibility recovery (browser only). */
29
- environment?: {
30
- addEventListener(type: 'visibilitychange', listener: () => void): void;
31
- removeEventListener(type: 'visibilitychange', listener: () => void): void;
32
- };
33
- }
34
- /**
35
- * The schedule heartbeat (see module doc). `tick` is public so tests and
36
- * callers can drive a check without waiting for the interval.
37
- */
38
- export declare class SchedulerService {
39
- private readonly deps;
40
- private timer;
41
- private environmentListener;
42
- private disposed;
43
- private started;
44
- /** Cron expressions that matched no instant in the scan window, per task —
45
- * remembered so a never-matching rule is not re-scanned on every tick. */
46
- private readonly unmatchableRepair;
47
- /** @param deps - tasks/clock/trigger/apply faces (see {@link SchedulerDeps}). */
48
- constructor(deps: SchedulerDeps);
49
- /**
50
- * Start ticking: one immediate check (catch-up after reload) + the interval.
51
- * Single-instance: a second start while already armed is a no-op, so a
52
- * re-entrant mount can never stack a duplicate timer.
53
- */
54
- start(): void;
55
- /**
56
- * Stop ticking and drop listeners (idempotent). Preferred shutdown verb for
57
- * callers that treat the scheduler as a controlled ticker; `dispose` is an
58
- * alias.
59
- */
60
- stop(): void;
61
- /** Stop ticking and drop listeners (idempotent). */
62
- dispose(): void;
63
- /**
64
- * Check every enabled schedule and trigger the due ones. Idempotent per
65
- * task per tick: the schedule is rolled forward only after runTask accepts
66
- * the run, so a rejected run keeps its due slot and is retried on the next
67
- * tick instead of being silently dropped.
68
- */
69
- tick(): Promise<void>;
70
- }