@linxin666/dsh-client-ui-task-board 0.1.19 → 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 (57) 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 +589 -602
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2187 -6
  7. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  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/board/status-key.d.ts +4 -0
  11. package/lib/types/client/host-api.d.ts +18 -0
  12. package/lib/types/client/locales.d.ts +10 -0
  13. package/lib/types/core/controller.d.ts +40 -15
  14. package/lib/types/core/schedule.d.ts +4 -2
  15. package/lib/types/core/tasks.d.ts +7 -5
  16. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  17. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  18. package/lib/types/dsh-home.d.ts +16 -0
  19. package/lib/types/host-ledger.d.ts +42 -0
  20. package/lib/types/host-routes.d.ts +24 -0
  21. package/lib/types/host-runner.d.ts +30 -0
  22. package/lib/types/host-service.d.ts +41 -0
  23. package/lib/types/index.d.ts +20 -10
  24. package/lib/types/loopback.d.ts +24 -0
  25. package/lib/types/mount-once.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +72 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +10 -0
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +22 -13
  33. package/src/client/board/TaskCard.tsx +24 -8
  34. package/src/client/board/TaskDetail.tsx +84 -59
  35. package/src/client/board/status-key.ts +11 -0
  36. package/src/client/host-api.ts +95 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/core/controller.ts +205 -24
  40. package/src/core/schedule.ts +20 -3
  41. package/src/core/store.ts +5 -14
  42. package/src/core/tasks.ts +7 -7
  43. package/src/core/use-cases/task-archive.ts +14 -5
  44. package/src/core/use-cases/task-schedule.ts +6 -5
  45. package/src/core/use-cases/task-update.ts +1 -6
  46. package/src/dsh-home.ts +36 -0
  47. package/src/host-ledger.ts +478 -0
  48. package/src/host-routes.ts +188 -0
  49. package/src/host-runner.ts +134 -0
  50. package/src/host-service.ts +209 -0
  51. package/src/index.ts +58 -12
  52. package/src/loopback.ts +63 -0
  53. package/src/mount-once.ts +48 -0
  54. package/src/power-inhibitor.ts +321 -0
  55. package/src/protocol.ts +213 -0
  56. package/lib/types/core/scheduler.d.ts +0 -67
  57. package/src/core/scheduler.ts +0 -151
@@ -0,0 +1,478 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { dshHome } from './dsh-home.ts'
5
+ import { isValidCron, nextRunAtMs } from './core/schedule.ts'
6
+ import { parseLedger } from './core/store.ts'
7
+ import { canMoveManually, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
8
+ import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
9
+ import { applyCreateTask } from './core/use-cases/task-create.ts'
10
+ import { applyDeleteTask } from './core/use-cases/task-delete.ts'
11
+ import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
12
+ import { applyUpdateTask } from './core/use-cases/task-update.ts'
13
+ import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
14
+
15
+ interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
16
+ importedSources?: string[]
17
+ }
18
+
19
+ interface PersistedRequest {
20
+ requestId: string
21
+ fingerprint: string
22
+ }
23
+
24
+ interface LedgerDocument {
25
+ schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
26
+ revision: number
27
+ tasks: TaskRecord[]
28
+ scheduler: PersistedScheduler
29
+ recentRequests: PersistedRequest[]
30
+ }
31
+
32
+ export interface LedgerState {
33
+ revision: number
34
+ tasks: TaskRecord[]
35
+ scheduler: TaskBoardSchedulerSnapshot
36
+ }
37
+
38
+ export interface OpenedRun {
39
+ task: TaskRecord
40
+ execution: ExecutionRecord
41
+ }
42
+
43
+ const MAX_REQUEST_CACHE = 256
44
+
45
+ interface CachedRequest {
46
+ fingerprint: string
47
+ }
48
+
49
+ function timeZone(): string {
50
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'
51
+ }
52
+
53
+ function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
54
+ return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
55
+ }
56
+
57
+ function hasOpenExecution(task: TaskRecord): boolean {
58
+ return task.executions.some(execution => execution.endedAt === undefined)
59
+ }
60
+
61
+ function processIsAlive(pid: number): boolean {
62
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
63
+ try {
64
+ process.kill(pid, 0)
65
+ return true
66
+ } catch (error) {
67
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH'
68
+ }
69
+ }
70
+
71
+ function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
72
+ if (a.endedAt === undefined && b.endedAt !== undefined) return b
73
+ if (b.endedAt === undefined && a.endedAt !== undefined) return a
74
+ return (b.endedAt ?? b.startedAt) >= (a.endedAt ?? a.startedAt) ? b : a
75
+ }
76
+
77
+ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
78
+ // Existing Host state wins ties so an equally old browser backup cannot
79
+ // roll authoritative fields back during multi-browser v1 migration.
80
+ const newer = b.updatedAt > a.updatedAt ? b : a
81
+ const byId = new Map<string, ExecutionRecord>()
82
+ for (const entry of [...a.executions, ...b.executions]) {
83
+ const previous = byId.get(entry.id)
84
+ byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
85
+ }
86
+ return { ...newer, executions: [...byId.values()].sort((x, y) => x.startedAt - y.startedAt) }
87
+ }
88
+
89
+ function parseHostTasks(values: readonly unknown[], now: number): TaskRecord[] {
90
+ const rawById = new Map<string, Record<string, unknown>>()
91
+ for (const value of values) {
92
+ if (typeof value !== 'object' || value === null) continue
93
+ const raw = value as Record<string, unknown>
94
+ if (typeof raw.id === 'string') rawById.set(raw.id, raw)
95
+ }
96
+ return parseLedger(JSON.stringify(values)).map(task => {
97
+ const rawSchedule = rawById.get(task.id)?.schedule
98
+ if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
99
+ const schedule = rawSchedule as Record<string, unknown>
100
+ if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
101
+ return {
102
+ ...task,
103
+ schedule: {
104
+ enabled: false,
105
+ cron: schedule.cron,
106
+ nextRunAt: undefined,
107
+ lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
108
+ ? schedule.lastTriggeredAt
109
+ : undefined,
110
+ },
111
+ }
112
+ })
113
+ }
114
+
115
+ export class HostTaskLedger {
116
+ private document: LedgerDocument
117
+ private readonly listeners = new Set<() => void>()
118
+ private readonly requestCache = new Map<string, CachedRequest>()
119
+ private readonly lockToken = crypto.randomUUID()
120
+ private lockFd: number | undefined
121
+ readonly file: string
122
+ readonly lockFile: string
123
+
124
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now) {
125
+ mkdirSync(dir, { recursive: true })
126
+ this.file = join(dir, 'ledger-v2.json')
127
+ this.lockFile = join(dir, 'ledger-v2.lock')
128
+ this.lockFd = this.acquireLock()
129
+ try {
130
+ this.document = this.load(dir)
131
+ for (const request of this.document.recentRequests) {
132
+ this.requestCache.set(request.requestId, { fingerprint: request.fingerprint })
133
+ }
134
+ this.repairSchedules(true)
135
+ this.reconcileInterruptedStarts()
136
+ // Persist a freshly generated ledger identity and any recovery error
137
+ // immediately, even when there are no tasks to trigger a later action.
138
+ this.commit(false)
139
+ } catch (error) {
140
+ this.dispose()
141
+ throw error
142
+ }
143
+ }
144
+
145
+ state(): LedgerState {
146
+ const { importedSources: _imports, ...scheduler } = this.document.scheduler
147
+ return { revision: this.document.revision, tasks: cloneTasks(this.document.tasks), scheduler: { ...scheduler } }
148
+ }
149
+
150
+ subscribe(listener: () => void): () => void {
151
+ this.listeners.add(listener)
152
+ return () => { this.listeners.delete(listener) }
153
+ }
154
+
155
+ dispose(): void {
156
+ const fd = this.lockFd
157
+ if (fd === undefined) return
158
+ this.lockFd = undefined
159
+ closeSync(fd)
160
+ try {
161
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { token?: unknown }
162
+ if (owner.token === this.lockToken) unlinkSync(this.lockFile)
163
+ } catch {
164
+ // A missing or externally replaced lock must not be removed blindly.
165
+ }
166
+ }
167
+
168
+ applyRequest(requestId: string, action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
169
+ const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
170
+ const cached = this.requestCache.get(requestId)
171
+ if (cached !== undefined) {
172
+ if (cached.fingerprint !== fingerprint) throw new Error('request id was reused with a different action')
173
+ return { state: this.state() }
174
+ }
175
+
176
+ // Add the fingerprint before apply(): successful actions persist it in the
177
+ // same atomic ledger write as their state transition.
178
+ this.requestCache.set(requestId, { fingerprint })
179
+ while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value as string)
180
+ this.syncRecentRequests()
181
+ try {
182
+ return this.apply(action)
183
+ } catch (error) {
184
+ this.requestCache.delete(requestId)
185
+ this.syncRecentRequests()
186
+ throw error
187
+ }
188
+ }
189
+
190
+ openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
191
+ const task = this.document.tasks.find(item => item.id === taskId)
192
+ if (task === undefined || task.archivedAt !== undefined) return undefined
193
+ if (task.status === 'running' || hasOpenExecution(task)) {
194
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
195
+ this.commit()
196
+ return undefined
197
+ }
198
+ const opened = startExecution(task, triggeredAt, crypto.randomUUID())
199
+ this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item)
200
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)]
201
+ this.commit()
202
+ return opened
203
+ }
204
+
205
+ skipMissed(now: number): void {
206
+ let changed = false
207
+ this.document.tasks = this.document.tasks.map(task => {
208
+ const schedule = task.schedule
209
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
210
+ changed = true
211
+ return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
212
+ })
213
+ if (changed) this.commit()
214
+ }
215
+
216
+ setScheduler(patch: Partial<TaskBoardSchedulerSnapshot>): void {
217
+ this.document.scheduler = { ...this.document.scheduler, ...patch }
218
+ this.commit(false)
219
+ }
220
+
221
+ attachSession(taskId: string, executionId: string, sessionId: string): void {
222
+ const now = this.now()
223
+ this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
224
+ ...task,
225
+ updatedAt: now,
226
+ executions: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
227
+ })
228
+ this.commit()
229
+ }
230
+
231
+ settle(taskId: string, executionId: string, outcome: 'succeeded' | 'failed' | 'cancelled', error?: string): void {
232
+ this.document.tasks = this.document.tasks.map(task => task.id === taskId
233
+ ? settleExecution(task, executionId, outcome, this.now(), error)
234
+ : task)
235
+ this.commit()
236
+ }
237
+
238
+ private apply(action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
239
+ const now = this.now()
240
+ let run: OpenedRun | undefined
241
+ switch (action.kind) {
242
+ case 'import': {
243
+ const sources = new Set(this.document.scheduler.importedSources ?? [])
244
+ if (sources.has(action.sourceId)) return { state: this.state() }
245
+ const invalidScheduleIds = action.tasks
246
+ .filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron))
247
+ .map(task => task.id)
248
+ const incoming = parseHostTasks(action.tasks, now)
249
+ const merged = new Map(this.document.tasks.map(task => [task.id, task]))
250
+ for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
251
+ this.document.tasks = [...merged.values()]
252
+ this.document.scheduler.importedSources = [...sources, action.sourceId]
253
+ this.document.scheduler.error = invalidScheduleIds.length === 0
254
+ ? undefined
255
+ : `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
256
+ this.repairSchedules(true, false)
257
+ this.reconcileInterruptedStarts(false)
258
+ break
259
+ }
260
+ case 'create': {
261
+ if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
262
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
263
+ throw new Error('invalid schedule')
264
+ }
265
+ const result = applyCreateTask(this.document.tasks, action.input, now, action.id)
266
+ if (result.task === undefined) throw new Error('invalid task')
267
+ this.document.tasks = [...result.tasks]
268
+ break
269
+ }
270
+ case 'update': {
271
+ const task = this.document.tasks.find(task => task.id === action.taskId)
272
+ if (task === undefined) throw new Error('task not found')
273
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
274
+ this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)]
275
+ break
276
+ }
277
+ case 'delete':
278
+ {
279
+ const task = this.document.tasks.find(task => task.id === action.taskId)
280
+ if (task === undefined) throw new Error('task not found')
281
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
282
+ }
283
+ this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
284
+ break
285
+ case 'move': {
286
+ const task = this.document.tasks.find(item => item.id === action.taskId)
287
+ if (task === undefined) throw new Error('task not found')
288
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
289
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved')
290
+ if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
291
+ this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
292
+ break
293
+ }
294
+ case 'archive': {
295
+ const result = applyArchiveTask(this.document.tasks, action.taskId, now)
296
+ if (!result.archived) throw new Error('task cannot be archived')
297
+ this.document.tasks = [...result.tasks]
298
+ break
299
+ }
300
+ case 'restore': {
301
+ const result = applyRestoreTask(this.document.tasks, action.taskId, now)
302
+ if (!result.archived) throw new Error('task is not archived')
303
+ this.document.tasks = [...result.tasks]
304
+ break
305
+ }
306
+ case 'set-schedule': {
307
+ const task = this.document.tasks.find(task => task.id === action.taskId)
308
+ if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
309
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
310
+ if (!result.applied) throw new Error('invalid schedule')
311
+ this.document.tasks = [...result.tasks]
312
+ break
313
+ }
314
+ case 'rerun':
315
+ case 'run': {
316
+ const task = this.document.tasks.find(item => item.id === action.taskId)
317
+ if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
318
+ if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
319
+ const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
320
+ run = startExecution(base, now, crypto.randomUUID())
321
+ this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
322
+ break
323
+ }
324
+ }
325
+ this.commit()
326
+ return { state: this.state(), ...(run === undefined ? {} : { run }) }
327
+ }
328
+
329
+ private repairSchedules(skipPast: boolean, persist = true): void {
330
+ const now = this.now()
331
+ let changed = false
332
+ this.document.tasks = this.document.tasks.map(task => {
333
+ const schedule = task.schedule
334
+ if (schedule === undefined || !schedule.enabled) return task
335
+ if (!skipPast && schedule.nextRunAt !== undefined) return task
336
+ const next = nextRunAtMs(schedule.cron, now)
337
+ if (next === undefined) {
338
+ changed = true
339
+ this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
340
+ return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now }
341
+ }
342
+ if (schedule.nextRunAt === next) return task
343
+ changed = true
344
+ return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now }
345
+ })
346
+ if (changed && persist) this.commit()
347
+ }
348
+
349
+ private reconcileInterruptedStarts(persist = true): void {
350
+ const now = this.now()
351
+ let changed = false
352
+ this.document.tasks = this.document.tasks.map(task => {
353
+ if (task.status !== 'running') return task
354
+ const execution = task.executions.at(-1)
355
+ if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
356
+ changed = true
357
+ return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
358
+ })
359
+ if (changed && persist) this.commit()
360
+ }
361
+
362
+ private load(dir: string): LedgerDocument {
363
+ const existed = existsSync(this.file)
364
+ try {
365
+ const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
366
+ if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
367
+ const tasks = parseHostTasks(parsed.tasks, this.now())
368
+ const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
369
+ if (typeof value !== 'object' || value === null) return []
370
+ const row = value as { id?: unknown; schedule?: unknown }
371
+ if (typeof row.schedule !== 'object' || row.schedule === null) return []
372
+ const cron = (row.schedule as { cron?: unknown }).cron
373
+ return typeof cron !== 'string' || !isValidCron(cron)
374
+ ? [typeof row.id === 'string' ? row.id : 'unknown']
375
+ : []
376
+ })
377
+ return {
378
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
379
+ revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
380
+ tasks,
381
+ scheduler: {
382
+ timeZone: timeZone(),
383
+ ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
384
+ ...(typeof parsed.scheduler?.lastTickAt === 'number' ? { lastTickAt: parsed.scheduler.lastTickAt } : {}),
385
+ ...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
386
+ ...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
387
+ ...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
388
+ },
389
+ recentRequests: Array.isArray(parsed.recentRequests)
390
+ ? parsed.recentRequests.flatMap((entry) => {
391
+ if (typeof entry !== 'object' || entry === null) return []
392
+ const request = entry as { requestId?: unknown; fingerprint?: unknown }
393
+ return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
394
+ ? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
395
+ : []
396
+ }).slice(-MAX_REQUEST_CACHE)
397
+ : [],
398
+ }
399
+ } catch (error) {
400
+ if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`)
401
+ mkdirSync(dir, { recursive: true })
402
+ return {
403
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
404
+ revision: 0,
405
+ tasks: [],
406
+ scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) },
407
+ recentRequests: [],
408
+ }
409
+ }
410
+ }
411
+
412
+ private syncRecentRequests(): void {
413
+ this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({
414
+ requestId,
415
+ fingerprint: request.fingerprint,
416
+ }))
417
+ }
418
+
419
+ private commit(bumpRevision = true): void {
420
+ if (bumpRevision) this.document.revision += 1
421
+ mkdirSync(dirname(this.file), { recursive: true })
422
+ const tmp = `${this.file}.tmp-${process.pid}`
423
+ let fd: number | undefined
424
+ try {
425
+ fd = openSync(tmp, 'w', 0o600)
426
+ writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: 'utf8' })
427
+ fsyncSync(fd)
428
+ closeSync(fd)
429
+ fd = undefined
430
+ try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ }
431
+ renameSync(tmp, this.file)
432
+ try {
433
+ const dirFd = openSync(dirname(this.file), 'r')
434
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
435
+ } catch {
436
+ // Windows does not permit fsync on a directory handle; rename remains atomic.
437
+ }
438
+ } catch (error) {
439
+ if (fd !== undefined) closeSync(fd)
440
+ try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ }
441
+ throw error
442
+ }
443
+ this.notify()
444
+ }
445
+
446
+ private notify(): void {
447
+ for (const listener of [...this.listeners]) listener()
448
+ }
449
+
450
+ private acquireLock(): number {
451
+ for (let attempt = 0; attempt < 2; attempt += 1) {
452
+ try {
453
+ const fd = openSync(this.lockFile, 'wx', 0o600)
454
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken }), { encoding: 'utf8' })
455
+ fsyncSync(fd)
456
+ try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
457
+ return fd
458
+ } catch (error) {
459
+ const code = (error as NodeJS.ErrnoException).code
460
+ if (code !== 'EEXIST') throw error
461
+ let pid: number | undefined
462
+ try {
463
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown }
464
+ if (typeof owner.pid === 'number') pid = owner.pid
465
+ } catch {
466
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
467
+ }
468
+ if (pid !== undefined && processIsAlive(pid)) {
469
+ throw new Error(`task-board ledger is already owned by process ${pid}`)
470
+ }
471
+ try { unlinkSync(this.lockFile) } catch (unlinkError) {
472
+ if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
473
+ }
474
+ }
475
+ }
476
+ throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
477
+ }
478
+ }
@@ -0,0 +1,188 @@
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
+ function browserSameOriginMarker(req: IncomingMessage): boolean {
64
+ const site = req.headers['sec-fetch-site']
65
+ return site === 'same-origin' || typeof req.headers.origin === 'string'
66
+ }
67
+
68
+ function sameAuthority(req: IncomingMessage, host: URL): boolean {
69
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
70
+ const origin = req.headers.origin
71
+ if (origin === undefined) return req.headers['sec-fetch-site'] === 'same-origin'
72
+ try {
73
+ return new URL(origin).host === host.host
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ function matchesToken(candidate: string | string[] | undefined, expected: string | undefined): boolean {
80
+ if (typeof candidate !== 'string' || expected === undefined || candidate === '' || expected === '') return false
81
+ const actual = Buffer.from(candidate)
82
+ const wanted = Buffer.from(expected)
83
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted)
84
+ }
85
+
86
+ /**
87
+ * Task-board route fence. Direct desktop access uses the repository-wide
88
+ * loopback socket + Host guard and additionally requires a browser same-origin
89
+ * marker, so a bare local curl cannot exercise the agent control plane.
90
+ * Authenticated proxies must be explicitly allowlisted and replace the
91
+ * internal token header after their own authentication step.
92
+ */
93
+ export function isTrustedTaskBoardRequest(req: IncomingMessage, access: ResolvedRouteAccess): boolean {
94
+ if (!browserSameOriginMarker(req)) return false
95
+ if (isLoopbackRequest(req)) return true
96
+ if (!isLoopbackAddress(req.socket.remoteAddress)) return false
97
+ const host = req.headers.host
98
+ if (typeof host !== 'string') return false
99
+ const parsed = parseAuthority(host)
100
+ if (parsed === undefined || parsed.canonical !== host.toLowerCase()) return false
101
+ if (!access.trustedProxyHosts.has(parsed.canonical) || !sameAuthority(req, parsed.url)) return false
102
+ return matchesToken(req.headers[TASK_BOARD_PROXY_TOKEN_HEADER], access.proxyToken)
103
+ }
104
+
105
+ async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unknown }> {
106
+ const chunks: Buffer[] = []
107
+ let size = 0
108
+ for await (const chunk of req) {
109
+ const buffer = chunk as Buffer
110
+ size += buffer.length
111
+ if (size > IMPORT_LIMIT) throw new Error('body-too-large')
112
+ chunks.push(buffer)
113
+ }
114
+ const raw = Buffer.concat(chunks).toString('utf8')
115
+ return { raw, value: JSON.parse(raw) }
116
+ }
117
+
118
+ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskBoardRouteAccess = {}): WebRoute[] {
119
+ const resolvedAccess = resolveAccess(access)
120
+ const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
121
+ if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
122
+ json(res, 403, { ok: false, error: 'forbidden' })
123
+ return false
124
+ }
125
+ const state: WebRoute = {
126
+ kind: 'exact',
127
+ path: `${TASK_BOARD_API_PREFIX}/state`,
128
+ handler: (req, res): void => {
129
+ if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
130
+ if (!guard(req, res)) return
131
+ json(res, 200, service.snapshot())
132
+ },
133
+ }
134
+ const action: WebRoute = {
135
+ kind: 'exact',
136
+ path: `${TASK_BOARD_API_PREFIX}/action`,
137
+ handler: async (req, res): Promise<void> => {
138
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
139
+ if (!guard(req, res)) return
140
+ if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
141
+ return json(res, 415, { ok: false, error: 'json-required' })
142
+ }
143
+ try {
144
+ const body = await readBody(req)
145
+ const parsed = parseActionEnvelope(body.value)
146
+ if (parsed === undefined) return json(res, 400, { ok: false, error: 'invalid-action' })
147
+ if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
148
+ return json(res, 413, { ok: false, error: 'body-too-large' })
149
+ }
150
+ json(res, 200, service.apply(parsed.requestId, parsed.action))
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error)
153
+ json(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message })
154
+ }
155
+ },
156
+ }
157
+ const events: WebRoute = {
158
+ kind: 'exact',
159
+ path: `${TASK_BOARD_API_PREFIX}/events`,
160
+ handler: (req, res): void => {
161
+ if (req.method !== 'GET') {
162
+ res.writeHead(405)
163
+ res.end()
164
+ return
165
+ }
166
+ if (!guard(req, res)) return
167
+ res.writeHead(200, {
168
+ 'content-type': 'text/event-stream; charset=utf-8',
169
+ 'cache-control': 'no-cache',
170
+ connection: 'keep-alive',
171
+ })
172
+ const push = (): void => {
173
+ const snapshot = service.snapshot()
174
+ res.write(`data: ${JSON.stringify({ revision: snapshot.revision, scheduler: snapshot.scheduler, power: snapshot.power })}\n\n`)
175
+ }
176
+ const unsubscribe = service.subscribe(push)
177
+ const heartbeat = setInterval(() => { res.write(': ping\n\n') }, HEARTBEAT_MS)
178
+ const close = (): void => {
179
+ clearInterval(heartbeat)
180
+ unsubscribe()
181
+ }
182
+ req.once('close', close)
183
+ res.once('close', close)
184
+ push()
185
+ },
186
+ }
187
+ return [state, action, events]
188
+ }