@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.
- package/README.i18n.yaml +2 -2
- package/README.md +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- 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
|
+
}
|