@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.
- package/README.i18n.yaml +2 -2
- package/README.md +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +589 -602
- package/lib/client.js.map +1 -1
- package/lib/index.js +2187 -6
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/board/status-key.d.ts +4 -0
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -2
- package/lib/types/core/tasks.d.ts +7 -5
- 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 +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -0
- package/lib/types/index.d.ts +20 -10
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +22 -13
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +84 -59
- package/src/client/board/status-key.ts +11 -0
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- package/src/core/store.ts +5 -14
- package/src/core/tasks.ts +7 -7
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/core/use-cases/task-update.ts +1 -6
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +58 -12
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -67
- 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
|
+
}
|