@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,539 @@
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
+ /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
124
+ readonly schedulerFile: string
125
+
126
+ constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now) {
127
+ mkdirSync(dir, { recursive: true })
128
+ this.file = join(dir, 'ledger-v2.json')
129
+ this.lockFile = join(dir, 'ledger-v2.lock')
130
+ this.schedulerFile = join(dir, 'scheduler-v2.json')
131
+ this.lockFd = this.acquireLock()
132
+ try {
133
+ this.document = this.load(dir)
134
+ for (const request of this.document.recentRequests) {
135
+ this.requestCache.set(request.requestId, { fingerprint: request.fingerprint })
136
+ }
137
+ this.repairSchedules(true)
138
+ this.reconcileInterruptedStarts()
139
+ // Persist a freshly generated ledger identity and any recovery error
140
+ // immediately, even when there are no tasks to trigger a later action.
141
+ this.commit(false)
142
+ } catch (error) {
143
+ this.dispose()
144
+ throw error
145
+ }
146
+ }
147
+
148
+ /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
149
+ summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } {
150
+ const { importedSources: _imports, ...scheduler } = this.document.scheduler
151
+ return { revision: this.document.revision, scheduler: { ...scheduler } }
152
+ }
153
+
154
+ state(): LedgerState {
155
+ const { revision, scheduler } = this.summary()
156
+ return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
157
+ }
158
+
159
+ subscribe(listener: () => void): () => void {
160
+ this.listeners.add(listener)
161
+ return () => { this.listeners.delete(listener) }
162
+ }
163
+
164
+ dispose(): void {
165
+ const fd = this.lockFd
166
+ if (fd === undefined) return
167
+ this.lockFd = undefined
168
+ closeSync(fd)
169
+ try {
170
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { token?: unknown }
171
+ if (owner.token === this.lockToken) unlinkSync(this.lockFile)
172
+ } catch {
173
+ // A missing or externally replaced lock must not be removed blindly.
174
+ }
175
+ }
176
+
177
+ applyRequest(requestId: string, action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
178
+ const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
179
+ const cached = this.requestCache.get(requestId)
180
+ if (cached !== undefined) {
181
+ if (cached.fingerprint !== fingerprint) throw new Error('request id was reused with a different action')
182
+ return { state: this.state() }
183
+ }
184
+
185
+ // Add the fingerprint before apply(): successful actions persist it in the
186
+ // same atomic ledger write as their state transition.
187
+ this.requestCache.set(requestId, { fingerprint })
188
+ while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value as string)
189
+ this.syncRecentRequests()
190
+ try {
191
+ return this.apply(action)
192
+ } catch (error) {
193
+ this.requestCache.delete(requestId)
194
+ this.syncRecentRequests()
195
+ throw error
196
+ }
197
+ }
198
+
199
+ openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
200
+ const task = this.document.tasks.find(item => item.id === taskId)
201
+ if (task === undefined || task.archivedAt !== undefined) return undefined
202
+ if (task.status === 'running' || hasOpenExecution(task)) {
203
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
204
+ this.commit()
205
+ return undefined
206
+ }
207
+ const opened = startExecution(task, triggeredAt, crypto.randomUUID())
208
+ this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item)
209
+ this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)]
210
+ this.commit()
211
+ return opened
212
+ }
213
+
214
+ skipMissed(now: number): void {
215
+ let changed = false
216
+ this.document.tasks = this.document.tasks.map(task => {
217
+ const schedule = task.schedule
218
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
219
+ changed = true
220
+ return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
221
+ })
222
+ if (changed) this.commit()
223
+ }
224
+
225
+ setScheduler(patch: Partial<TaskBoardSchedulerSnapshot>): void {
226
+ this.document.scheduler = { ...this.document.scheduler, ...patch }
227
+ // The 30 s heartbeat only moves lastTickAt; rewriting the whole ledger
228
+ // for it made idle idle cost O(ledger bytes) every tick. Persist it to a
229
+ // tiny sidecar instead; any other patch still goes through the full
230
+ // atomic commit.
231
+ if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) {
232
+ this.writeSchedulerSidecar()
233
+ return
234
+ }
235
+ this.commit(false)
236
+ }
237
+
238
+ attachSession(taskId: string, executionId: string, sessionId: string): void {
239
+ const now = this.now()
240
+ this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : {
241
+ ...task,
242
+ updatedAt: now,
243
+ executions: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
244
+ })
245
+ this.commit()
246
+ }
247
+
248
+ settle(taskId: string, executionId: string, outcome: 'succeeded' | 'failed' | 'cancelled', error?: string): void {
249
+ this.document.tasks = this.document.tasks.map(task => task.id === taskId
250
+ ? settleExecution(task, executionId, outcome, this.now(), error)
251
+ : task)
252
+ this.commit()
253
+ }
254
+
255
+ private apply(action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
256
+ const now = this.now()
257
+ let run: OpenedRun | undefined
258
+ switch (action.kind) {
259
+ case 'import': {
260
+ const sources = new Set(this.document.scheduler.importedSources ?? [])
261
+ if (sources.has(action.sourceId)) return { state: this.state() }
262
+ const invalidScheduleIds = action.tasks
263
+ .filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron))
264
+ .map(task => task.id)
265
+ const incoming = parseHostTasks(action.tasks, now)
266
+ const merged = new Map(this.document.tasks.map(task => [task.id, task]))
267
+ for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task)
268
+ this.document.tasks = [...merged.values()]
269
+ this.document.scheduler.importedSources = [...sources, action.sourceId]
270
+ this.document.scheduler.error = invalidScheduleIds.length === 0
271
+ ? undefined
272
+ : `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
273
+ this.repairSchedules(true, false)
274
+ this.reconcileInterruptedStarts(false)
275
+ break
276
+ }
277
+ case 'create': {
278
+ if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
279
+ if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
280
+ throw new Error('invalid schedule')
281
+ }
282
+ const result = applyCreateTask(this.document.tasks, action.input, now, action.id)
283
+ if (result.task === undefined) throw new Error('invalid task')
284
+ this.document.tasks = [...result.tasks]
285
+ break
286
+ }
287
+ case 'update': {
288
+ const task = this.document.tasks.find(task => task.id === action.taskId)
289
+ if (task === undefined) throw new Error('task not found')
290
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
291
+ this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, action.patch, now)]
292
+ break
293
+ }
294
+ case 'delete':
295
+ {
296
+ const task = this.document.tasks.find(task => task.id === action.taskId)
297
+ if (task === undefined) throw new Error('task not found')
298
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted')
299
+ }
300
+ this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks]
301
+ break
302
+ case 'move': {
303
+ const task = this.document.tasks.find(item => item.id === action.taskId)
304
+ if (task === undefined) throw new Error('task not found')
305
+ if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
306
+ if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved')
307
+ if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
308
+ this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
309
+ break
310
+ }
311
+ case 'archive': {
312
+ const result = applyArchiveTask(this.document.tasks, action.taskId, now)
313
+ if (!result.archived) throw new Error('task cannot be archived')
314
+ this.document.tasks = [...result.tasks]
315
+ break
316
+ }
317
+ case 'restore': {
318
+ const result = applyRestoreTask(this.document.tasks, action.taskId, now)
319
+ if (!result.archived) throw new Error('task is not archived')
320
+ this.document.tasks = [...result.tasks]
321
+ break
322
+ }
323
+ case 'set-schedule': {
324
+ const task = this.document.tasks.find(task => task.id === action.taskId)
325
+ if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
326
+ const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
327
+ if (!result.applied) throw new Error('invalid schedule')
328
+ this.document.tasks = [...result.tasks]
329
+ break
330
+ }
331
+ case 'rerun':
332
+ case 'run': {
333
+ const task = this.document.tasks.find(item => item.id === action.taskId)
334
+ if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
335
+ if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
336
+ const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
337
+ run = startExecution(base, now, crypto.randomUUID())
338
+ this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
339
+ break
340
+ }
341
+ }
342
+ this.commit()
343
+ return { state: this.state(), ...(run === undefined ? {} : { run }) }
344
+ }
345
+
346
+ private repairSchedules(skipPast: boolean, persist = true): void {
347
+ const now = this.now()
348
+ let changed = false
349
+ this.document.tasks = this.document.tasks.map(task => {
350
+ const schedule = task.schedule
351
+ if (schedule === undefined || !schedule.enabled) return task
352
+ if (!skipPast && schedule.nextRunAt !== undefined) return task
353
+ const next = nextRunAtMs(schedule.cron, now)
354
+ if (next === undefined) {
355
+ changed = true
356
+ this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
357
+ return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now }
358
+ }
359
+ if (schedule.nextRunAt === next) return task
360
+ changed = true
361
+ return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now }
362
+ })
363
+ if (changed && persist) this.commit()
364
+ }
365
+
366
+ private reconcileInterruptedStarts(persist = true): void {
367
+ const now = this.now()
368
+ let changed = false
369
+ this.document.tasks = this.document.tasks.map(task => {
370
+ if (task.status !== 'running') return task
371
+ const execution = task.executions.at(-1)
372
+ if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
373
+ changed = true
374
+ return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded')
375
+ })
376
+ if (changed && persist) this.commit()
377
+ }
378
+
379
+ private load(dir: string): LedgerDocument {
380
+ const existed = existsSync(this.file)
381
+ try {
382
+ const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<LedgerDocument>
383
+ if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
384
+ const tasks = parseHostTasks(parsed.tasks, this.now())
385
+ const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
386
+ if (typeof value !== 'object' || value === null) return []
387
+ const row = value as { id?: unknown; schedule?: unknown }
388
+ if (typeof row.schedule !== 'object' || row.schedule === null) return []
389
+ const cron = (row.schedule as { cron?: unknown }).cron
390
+ return typeof cron !== 'string' || !isValidCron(cron)
391
+ ? [typeof row.id === 'string' ? row.id : 'unknown']
392
+ : []
393
+ })
394
+ const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
395
+ const sidecarLastTickAt = this.readSchedulerSidecar()
396
+ // A sidecar write can be newer than the last full commit (crash between
397
+ // the two); lastTickAt only ever moves forward, so take the greater.
398
+ const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
399
+ ? documentLastTickAt
400
+ : sidecarLastTickAt
401
+ return {
402
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
403
+ revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
404
+ tasks,
405
+ scheduler: {
406
+ timeZone: timeZone(),
407
+ ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
408
+ ...(lastTickAt === undefined ? {} : { lastTickAt }),
409
+ ...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
410
+ ...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
411
+ ...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
412
+ },
413
+ recentRequests: Array.isArray(parsed.recentRequests)
414
+ ? parsed.recentRequests.flatMap((entry) => {
415
+ if (typeof entry !== 'object' || entry === null) return []
416
+ const request = entry as { requestId?: unknown; fingerprint?: unknown }
417
+ return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
418
+ ? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
419
+ : []
420
+ }).slice(-MAX_REQUEST_CACHE)
421
+ : [],
422
+ }
423
+ } catch (error) {
424
+ if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`)
425
+ mkdirSync(dir, { recursive: true })
426
+ return {
427
+ schemaVersion: TASK_BOARD_SCHEMA_VERSION,
428
+ revision: 0,
429
+ tasks: [],
430
+ scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) },
431
+ recentRequests: [],
432
+ }
433
+ }
434
+ }
435
+
436
+ private syncRecentRequests(): void {
437
+ this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({
438
+ requestId,
439
+ fingerprint: request.fingerprint,
440
+ }))
441
+ }
442
+
443
+ private readSchedulerSidecar(): number | undefined {
444
+ try {
445
+ const parsed = JSON.parse(readFileSync(this.schedulerFile, 'utf8')) as { lastTickAt?: unknown }
446
+ return typeof parsed.lastTickAt === 'number' && Number.isFinite(parsed.lastTickAt) ? parsed.lastTickAt : undefined
447
+ } catch {
448
+ return undefined
449
+ }
450
+ }
451
+
452
+ /** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
453
+ private writeSchedulerSidecar(): void {
454
+ const payload = JSON.stringify({ lastTickAt: this.document.scheduler.lastTickAt })
455
+ mkdirSync(dirname(this.schedulerFile), { recursive: true })
456
+ const tmp = `${this.schedulerFile}.tmp-${process.pid}`
457
+ let fd: number | undefined
458
+ try {
459
+ fd = openSync(tmp, 'w', 0o600)
460
+ writeFileSync(fd, payload, { encoding: 'utf8' })
461
+ fsyncSync(fd)
462
+ closeSync(fd)
463
+ fd = undefined
464
+ try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ }
465
+ renameSync(tmp, this.schedulerFile)
466
+ try {
467
+ const dirFd = openSync(dirname(this.schedulerFile), 'r')
468
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
469
+ } catch {
470
+ // Windows does not permit fsync on a directory handle; rename remains atomic.
471
+ }
472
+ } catch (error) {
473
+ if (fd !== undefined) closeSync(fd)
474
+ try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ }
475
+ throw error
476
+ }
477
+ this.notify()
478
+ }
479
+
480
+ private commit(bumpRevision = true): void {
481
+ if (bumpRevision) this.document.revision += 1
482
+ mkdirSync(dirname(this.file), { recursive: true })
483
+ const tmp = `${this.file}.tmp-${process.pid}`
484
+ let fd: number | undefined
485
+ try {
486
+ fd = openSync(tmp, 'w', 0o600)
487
+ writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: 'utf8' })
488
+ fsyncSync(fd)
489
+ closeSync(fd)
490
+ fd = undefined
491
+ try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ }
492
+ renameSync(tmp, this.file)
493
+ try {
494
+ const dirFd = openSync(dirname(this.file), 'r')
495
+ try { fsyncSync(dirFd) } finally { closeSync(dirFd) }
496
+ } catch {
497
+ // Windows does not permit fsync on a directory handle; rename remains atomic.
498
+ }
499
+ } catch (error) {
500
+ if (fd !== undefined) closeSync(fd)
501
+ try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ }
502
+ throw error
503
+ }
504
+ this.notify()
505
+ }
506
+
507
+ private notify(): void {
508
+ for (const listener of [...this.listeners]) listener()
509
+ }
510
+
511
+ private acquireLock(): number {
512
+ for (let attempt = 0; attempt < 2; attempt += 1) {
513
+ try {
514
+ const fd = openSync(this.lockFile, 'wx', 0o600)
515
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken }), { encoding: 'utf8' })
516
+ fsyncSync(fd)
517
+ try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
518
+ return fd
519
+ } catch (error) {
520
+ const code = (error as NodeJS.ErrnoException).code
521
+ if (code !== 'EEXIST') throw error
522
+ let pid: number | undefined
523
+ try {
524
+ const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown }
525
+ if (typeof owner.pid === 'number') pid = owner.pid
526
+ } catch {
527
+ throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`)
528
+ }
529
+ if (pid !== undefined && processIsAlive(pid)) {
530
+ throw new Error(`task-board ledger is already owned by process ${pid}`)
531
+ }
532
+ try { unlinkSync(this.lockFile) } catch (unlinkError) {
533
+ if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
534
+ }
535
+ }
536
+ }
537
+ throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
538
+ }
539
+ }