@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.9
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 +9 -4
- package/README.zh.md +9 -4
- package/lib/client.js +947 -354
- package/lib/client.js.map +1 -1
- package/lib/index.js +712 -180
- package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
- package/lib/types/client/board/TaskBoard.d.ts +3 -0
- package/lib/types/client/board/TaskForm.d.ts +28 -0
- package/lib/types/client/host-api.d.ts +2 -2
- package/lib/types/client/index.d.ts +10 -2
- package/lib/types/client/locales.d.ts +19 -0
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/core/controller.d.ts +11 -2
- package/lib/types/core/freeze-snapshot.d.ts +71 -0
- package/lib/types/core/handover.d.ts +58 -0
- package/lib/types/core/tasks.d.ts +70 -1
- package/lib/types/core/use-cases/task-update.d.ts +16 -7
- package/lib/types/host-ledger.d.ts +17 -2
- package/lib/types/host-runner.d.ts +44 -17
- package/lib/types/host-service.d.ts +8 -5
- package/lib/types/index.d.ts +9 -1
- package/lib/types/protocol.d.ts +15 -2
- package/package.json +26 -17
- package/src/client/TaskBoardSettingsCard.tsx +2 -1
- package/src/client/board/EditTaskModal.tsx +19 -55
- package/src/client/board/NewTaskModal.tsx +59 -42
- package/src/client/board/TaskBoard.tsx +5 -3
- package/src/client/board/TaskCard.tsx +3 -0
- package/src/client/board/TaskDetail.tsx +54 -0
- package/src/client/board/TaskForm.tsx +111 -0
- package/src/client/board-mount.tsx +7 -1
- package/src/client/host-api.ts +5 -5
- package/src/client/index.ts +67 -7
- package/src/client/locales.ts +40 -2
- package/src/client/settings-form.ts +78 -92
- package/src/core/controller.ts +36 -16
- package/src/core/freeze-snapshot.ts +213 -0
- package/src/core/handover.ts +117 -0
- package/src/core/store.ts +48 -1
- package/src/core/tasks.ts +87 -0
- package/src/core/use-cases/task-update.ts +30 -10
- package/src/dsh-home.ts +1 -1
- package/src/host-ledger.ts +137 -59
- package/src/host-routes.ts +1 -1
- package/src/host-runner.ts +223 -83
- package/src/host-service.ts +14 -10
- package/src/index.ts +24 -10
- package/src/protocol.ts +98 -14
|
@@ -4,18 +4,25 @@
|
|
|
4
4
|
* fresh updatedAt. Pure ledger transition (no persistence or notify — the
|
|
5
5
|
* controller orchestrates those).
|
|
6
6
|
*
|
|
7
|
-
* An explicit `undefined` in the patch clears
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* can never persist a value the execution service rejects.
|
|
7
|
+
* An explicit `undefined` in the patch clears the field (the task falls
|
|
8
|
+
* back to the runtime default); an unknown permission string is ignored so
|
|
9
|
+
* stale UI can never persist a value the execution service rejects.
|
|
11
10
|
*/
|
|
12
|
-
import { isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>>
|
|
11
|
+
import { freezeOf, isTaskPermission, normalizeTargetId, type TaskRecord, type TaskPermission } from '../tasks.ts'
|
|
12
|
+
import type { FreezeSnapshot } from '../freeze-snapshot.ts'
|
|
13
|
+
import type { TaskHandoverInput } from '../handover.ts'
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
|
-
*
|
|
16
|
+
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
17
|
+
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
18
|
+
*/
|
|
19
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission'>> & {
|
|
20
|
+
freeze?: FreezeSnapshot & { redacted?: boolean } | null
|
|
21
|
+
/** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
|
|
22
|
+
handover?: TaskHandoverInput | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The fields that edit the task's content (what the user reads and what the
|
|
19
26
|
* next execution sends). Unlike the execution targets they stay editable only
|
|
20
27
|
* while the task has never started executing — after the first run the
|
|
21
28
|
* recorded prompt is the record of what actually ran, so it becomes read-only.
|
|
@@ -61,10 +68,11 @@ export function applyUpdateTask(
|
|
|
61
68
|
): readonly TaskRecord[] {
|
|
62
69
|
return tasks.map(task => {
|
|
63
70
|
if (task.id !== id) return task
|
|
71
|
+
const { freeze: freezePatch, handover: handoverPatch, ...rest } = patch
|
|
64
72
|
const workspaceId = 'workspaceId' in patch ? normalizeTargetId(patch.workspaceId) : undefined
|
|
65
73
|
const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
|
|
66
74
|
const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
|
|
67
|
-
const next: TaskRecord = { ...task, ...
|
|
75
|
+
const next: TaskRecord = { ...task, ...rest, updatedAt: now }
|
|
68
76
|
// Content fields normalize like creation does (trimmed); an explicit
|
|
69
77
|
// undefined keeps the current value — content cannot be cleared.
|
|
70
78
|
for (const field of TASK_CONTENT_FIELDS) {
|
|
@@ -72,6 +80,18 @@ export function applyUpdateTask(
|
|
|
72
80
|
const value = patch[field]
|
|
73
81
|
next[field] = value === undefined ? task[field] : value.trim()
|
|
74
82
|
}
|
|
83
|
+
// null (or a vanished key value) clears the snapshot; a present object
|
|
84
|
+
// replaces it with a fresh frozenAt stamp.
|
|
85
|
+
next.freeze = freezePatch == null ? undefined : freezeOf(freezePatch, now)
|
|
86
|
+
// Handover follows the same null-clears convention, restamping bundledAt.
|
|
87
|
+
next.handover = handoverPatch == null ? undefined : { ...handoverPatch, bundledAt: now }
|
|
88
|
+
// The permission confirmation binds the exact permission value: a change
|
|
89
|
+
// of the pinned permission or of the handover bundle re-arms the gate
|
|
90
|
+
// (blocking confirm-then-swap escalation). handover was destructured out
|
|
91
|
+
// of rest above, so the stamp survives only when explicitly kept.
|
|
92
|
+
if (('permission' in patch && patch.permission !== undefined && patch.permission !== task.permission) || 'handover' in patch) {
|
|
93
|
+
next.permissionConfirmedAt = undefined
|
|
94
|
+
}
|
|
75
95
|
if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
|
|
76
96
|
if (mode !== undefined || 'mode' in patch) next.mode = mode
|
|
77
97
|
if (permission !== undefined || 'permission' in patch) next.permission = permission
|
package/src/dsh-home.ts
CHANGED
package/src/host-ledger.ts
CHANGED
|
@@ -4,14 +4,15 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
|
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
7
|
-
import { parseLedger } from './core/store.ts'
|
|
7
|
+
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
8
|
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
10
10
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
11
11
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
12
12
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
13
13
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
14
|
-
import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
14
|
+
import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
15
|
+
import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
15
16
|
|
|
16
17
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
17
18
|
importedSources?: string[]
|
|
@@ -22,6 +23,9 @@ interface PersistedRequest {
|
|
|
22
23
|
fingerprint: string
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** On-disk document of any schema generation (schemaVersion untyped until the load branches decide). */
|
|
27
|
+
type ParsedLedgerDocument = Omit<Partial<LedgerDocument>, 'schemaVersion'> & { schemaVersion?: unknown }
|
|
28
|
+
|
|
25
29
|
interface LedgerDocument {
|
|
26
30
|
schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION
|
|
27
31
|
revision: number
|
|
@@ -273,7 +277,11 @@ export class HostTaskLedger {
|
|
|
273
277
|
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
274
278
|
readonly schedulerFile: string
|
|
275
279
|
|
|
276
|
-
|
|
280
|
+
/** Session-default permission the confirmation gate compares against. */
|
|
281
|
+
readonly sessionDefaultPermission: TaskPermission
|
|
282
|
+
|
|
283
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) {
|
|
284
|
+
this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION
|
|
277
285
|
mkdirSync(dir, { recursive: true })
|
|
278
286
|
this.file = join(dir, 'ledger-v2.json')
|
|
279
287
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
@@ -368,7 +376,11 @@ export class HostTaskLedger {
|
|
|
368
376
|
}
|
|
369
377
|
}
|
|
370
378
|
|
|
371
|
-
applyRequest(
|
|
379
|
+
applyRequest(
|
|
380
|
+
requestId: string,
|
|
381
|
+
action: TaskBoardAction,
|
|
382
|
+
initiator?: string,
|
|
383
|
+
): { state: LedgerState; run?: OpenedRun } {
|
|
372
384
|
const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex')
|
|
373
385
|
const cached = this.requestCache.get(requestId)
|
|
374
386
|
if (cached !== undefined) {
|
|
@@ -382,7 +394,7 @@ export class HostTaskLedger {
|
|
|
382
394
|
while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value as string)
|
|
383
395
|
this.syncRecentRequests()
|
|
384
396
|
try {
|
|
385
|
-
return this.apply(action)
|
|
397
|
+
return this.apply(action, initiator)
|
|
386
398
|
} catch (error) {
|
|
387
399
|
this.requestCache.delete(requestId)
|
|
388
400
|
this.syncRecentRequests()
|
|
@@ -393,6 +405,14 @@ export class HostTaskLedger {
|
|
|
393
405
|
openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined {
|
|
394
406
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
395
407
|
if (task === undefined || task.archivedAt !== undefined) return undefined
|
|
408
|
+
if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
|
|
409
|
+
// An unconfirmed above-default permission must never run unattended:
|
|
410
|
+
// cron refuses the card and rolls to the next occurrence, exactly
|
|
411
|
+
// like the already-running refusal.
|
|
412
|
+
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
|
|
413
|
+
this.commit()
|
|
414
|
+
return undefined
|
|
415
|
+
}
|
|
396
416
|
if (task.status === 'running' || hasOpenExecution(task)) {
|
|
397
417
|
this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)]
|
|
398
418
|
this.commit()
|
|
@@ -446,7 +466,7 @@ export class HostTaskLedger {
|
|
|
446
466
|
this.commit()
|
|
447
467
|
}
|
|
448
468
|
|
|
449
|
-
private apply(action: TaskBoardAction): { state: LedgerState; run?: OpenedRun } {
|
|
469
|
+
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } {
|
|
450
470
|
const now = this.now()
|
|
451
471
|
let run: OpenedRun | undefined
|
|
452
472
|
switch (action.kind) {
|
|
@@ -473,7 +493,10 @@ export class HostTaskLedger {
|
|
|
473
493
|
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) {
|
|
474
494
|
throw new Error('invalid schedule')
|
|
475
495
|
}
|
|
476
|
-
const
|
|
496
|
+
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
497
|
+
? action.input
|
|
498
|
+
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
499
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id)
|
|
477
500
|
if (result.task === undefined) throw new Error('invalid task')
|
|
478
501
|
this.document.tasks = [...result.tasks]
|
|
479
502
|
break
|
|
@@ -490,7 +513,12 @@ export class HostTaskLedger {
|
|
|
490
513
|
throw new Error('task has already been executed')
|
|
491
514
|
}
|
|
492
515
|
if ('title' in action.patch && action.patch.title?.trim() === '') throw new Error('title is required')
|
|
493
|
-
|
|
516
|
+
// A replaced snapshot is re-stamped with the updating session (the
|
|
517
|
+
// initiator), so a swapped freeze cannot keep the old author stamp.
|
|
518
|
+
const patch = action.patch.freeze === null || action.patch.freeze === undefined || initiator === undefined || initiator === ''
|
|
519
|
+
? action.patch
|
|
520
|
+
: { ...action.patch, freeze: { ...action.patch.freeze, frozenBy: initiator } }
|
|
521
|
+
this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)]
|
|
494
522
|
break
|
|
495
523
|
}
|
|
496
524
|
case 'delete':
|
|
@@ -522,6 +550,15 @@ export class HostTaskLedger {
|
|
|
522
550
|
this.document.tasks = [...result.tasks]
|
|
523
551
|
break
|
|
524
552
|
}
|
|
553
|
+
case 'confirm-permission': {
|
|
554
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
555
|
+
if (task === undefined) throw new Error('task not found')
|
|
556
|
+
if (task.permissionConfirmedAt !== undefined) break
|
|
557
|
+
this.document.tasks = this.document.tasks.map(item => item.id === action.taskId
|
|
558
|
+
? { ...item, permissionConfirmedAt: now, updatedAt: now }
|
|
559
|
+
: item)
|
|
560
|
+
break
|
|
561
|
+
}
|
|
525
562
|
case 'set-schedule': {
|
|
526
563
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
527
564
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
@@ -535,8 +572,11 @@ export class HostTaskLedger {
|
|
|
535
572
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
536
573
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
537
574
|
if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
575
|
+
if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) {
|
|
576
|
+
throw new Error(`confirmation-required: the effective permission is above the session default (${this.sessionDefaultPermission}); confirm the card's permission binding first`)
|
|
577
|
+
}
|
|
538
578
|
const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task
|
|
539
|
-
run = startExecution(base, now, crypto.randomUUID())
|
|
579
|
+
run = startExecution(base, now, crypto.randomUUID(), initiator)
|
|
540
580
|
this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item)
|
|
541
581
|
break
|
|
542
582
|
}
|
|
@@ -578,61 +618,99 @@ export class HostTaskLedger {
|
|
|
578
618
|
if (changed && persist) this.commit()
|
|
579
619
|
}
|
|
580
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
|
|
623
|
+
* migration reuses the v3 normalization, but it first proves every task
|
|
624
|
+
* row is structurally valid: a v2 document that would silently drop or
|
|
625
|
+
* coerce rows fails loudly instead (no quarantined-empty restart).
|
|
626
|
+
*/
|
|
627
|
+
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
628
|
+
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
629
|
+
throw new Error('v2 document contains structurally invalid task rows')
|
|
630
|
+
}
|
|
631
|
+
return this.normalizeDocument(parsed)
|
|
632
|
+
}
|
|
633
|
+
|
|
581
634
|
private load(dir: string): LedgerDocument {
|
|
582
635
|
const existed = existsSync(this.file)
|
|
636
|
+
// schemaVersion stays unknown-typed here: on-disk documents may be v2
|
|
637
|
+
// (legacy), v3, or any future/invalid value the branches below sort out.
|
|
638
|
+
let parsed: ParsedLedgerDocument
|
|
583
639
|
try {
|
|
584
|
-
|
|
585
|
-
if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
|
|
586
|
-
const tasks = parseHostTasks(parsed.tasks).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
|
|
587
|
-
const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
|
|
588
|
-
if (typeof value !== 'object' || value === null) return []
|
|
589
|
-
const row = value as { id?: unknown; schedule?: unknown }
|
|
590
|
-
if (typeof row.schedule !== 'object' || row.schedule === null) return []
|
|
591
|
-
const cron = (row.schedule as { cron?: unknown }).cron
|
|
592
|
-
return typeof cron !== 'string' || !isValidCron(cron)
|
|
593
|
-
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
594
|
-
: []
|
|
595
|
-
})
|
|
596
|
-
const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
|
|
597
|
-
const sidecarLastTickAt = this.readSchedulerSidecar()
|
|
598
|
-
// A sidecar write can be newer than the last full commit (crash between
|
|
599
|
-
// the two); lastTickAt only ever moves forward, so take the greater.
|
|
600
|
-
const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
|
|
601
|
-
? documentLastTickAt
|
|
602
|
-
: sidecarLastTickAt
|
|
603
|
-
return {
|
|
604
|
-
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
605
|
-
revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
|
|
606
|
-
tasks,
|
|
607
|
-
scheduler: {
|
|
608
|
-
timeZone: timeZone(),
|
|
609
|
-
ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
610
|
-
...(lastTickAt === undefined ? {} : { lastTickAt }),
|
|
611
|
-
...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
|
|
612
|
-
...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
|
|
613
|
-
...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
|
|
614
|
-
},
|
|
615
|
-
recentRequests: Array.isArray(parsed.recentRequests)
|
|
616
|
-
? parsed.recentRequests.flatMap((entry) => {
|
|
617
|
-
if (typeof entry !== 'object' || entry === null) return []
|
|
618
|
-
const request = entry as { requestId?: unknown; fingerprint?: unknown }
|
|
619
|
-
return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
|
|
620
|
-
? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
|
|
621
|
-
: []
|
|
622
|
-
}).slice(-MAX_REQUEST_CACHE)
|
|
623
|
-
: [],
|
|
624
|
-
}
|
|
640
|
+
parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
|
|
625
641
|
} catch (error) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
642
|
+
return this.recoverCorrupt(dir, existed, error)
|
|
643
|
+
}
|
|
644
|
+
if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION) {
|
|
645
|
+
try {
|
|
646
|
+
return this.migrateLegacyDocument(parsed)
|
|
647
|
+
} catch (error) {
|
|
648
|
+
// Migration failure is explicit: the original v2 file stays in place
|
|
649
|
+
// for manual recovery and the ledger refuses to start (fail closed).
|
|
650
|
+
throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
634
651
|
}
|
|
635
652
|
}
|
|
653
|
+
try {
|
|
654
|
+
if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema')
|
|
655
|
+
return this.normalizeDocument(parsed)
|
|
656
|
+
} catch (error) {
|
|
657
|
+
return this.recoverCorrupt(dir, existed, error)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private normalizeDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
662
|
+
const tasks = parseHostTasks(parsed.tasks as readonly unknown[]).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) }))
|
|
663
|
+
const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => {
|
|
664
|
+
if (typeof value !== 'object' || value === null) return []
|
|
665
|
+
const row = value as { id?: unknown; schedule?: unknown }
|
|
666
|
+
if (typeof row.schedule !== 'object' || row.schedule === null) return []
|
|
667
|
+
const cron = (row.schedule as { cron?: unknown }).cron
|
|
668
|
+
return typeof cron !== 'string' || !isValidCron(cron)
|
|
669
|
+
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
670
|
+
: []
|
|
671
|
+
})
|
|
672
|
+
const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined
|
|
673
|
+
const sidecarLastTickAt = this.readSchedulerSidecar()
|
|
674
|
+
// A sidecar write can be newer than the last full commit (crash between
|
|
675
|
+
// the two); lastTickAt only ever moves forward, so take the greater.
|
|
676
|
+
const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt)
|
|
677
|
+
? documentLastTickAt
|
|
678
|
+
: sidecarLastTickAt
|
|
679
|
+
return {
|
|
680
|
+
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
681
|
+
revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0,
|
|
682
|
+
tasks,
|
|
683
|
+
scheduler: {
|
|
684
|
+
timeZone: timeZone(),
|
|
685
|
+
ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(),
|
|
686
|
+
...(lastTickAt === undefined ? {} : { lastTickAt }),
|
|
687
|
+
...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}),
|
|
688
|
+
...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}),
|
|
689
|
+
...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}),
|
|
690
|
+
},
|
|
691
|
+
recentRequests: Array.isArray(parsed.recentRequests)
|
|
692
|
+
? parsed.recentRequests.flatMap((entry) => {
|
|
693
|
+
if (typeof entry !== 'object' || entry === null) return []
|
|
694
|
+
const request = entry as { requestId?: unknown; fingerprint?: unknown }
|
|
695
|
+
return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string'
|
|
696
|
+
? [{ requestId: request.requestId, fingerprint: request.fingerprint }]
|
|
697
|
+
: []
|
|
698
|
+
}).slice(-MAX_REQUEST_CACHE)
|
|
699
|
+
: [],
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Quarantine an unreadable document and start from an empty ledger. */
|
|
704
|
+
private recoverCorrupt(dir: string, existed: boolean, error: unknown): LedgerDocument {
|
|
705
|
+
if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`)
|
|
706
|
+
mkdirSync(dir, { recursive: true })
|
|
707
|
+
return {
|
|
708
|
+
schemaVersion: TASK_BOARD_SCHEMA_VERSION,
|
|
709
|
+
revision: 0,
|
|
710
|
+
tasks: [],
|
|
711
|
+
scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) },
|
|
712
|
+
recentRequests: [],
|
|
713
|
+
}
|
|
636
714
|
}
|
|
637
715
|
|
|
638
716
|
private syncRecentRequests(): void {
|
|
@@ -772,4 +850,4 @@ export class HostTaskLedger {
|
|
|
772
850
|
}
|
|
773
851
|
throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
|
|
774
852
|
}
|
|
775
|
-
}
|
|
853
|
+
}
|
package/src/host-routes.ts
CHANGED
|
@@ -151,7 +151,7 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
|
|
|
151
151
|
if (parsed.action.kind !== 'import' && Buffer.byteLength(body.raw) > ACTION_LIMIT) {
|
|
152
152
|
return writeJson(res, 413, { ok: false, error: 'body-too-large' }, { 'cache-control': 'no-store' })
|
|
153
153
|
}
|
|
154
|
-
writeJson(res, 200, service.apply(parsed.requestId, parsed.action), { 'cache-control': 'no-store' })
|
|
154
|
+
writeJson(res, 200, service.apply(parsed.requestId, parsed.action, parsed.initiator), { 'cache-control': 'no-store' })
|
|
155
155
|
} catch (error) {
|
|
156
156
|
const message = error instanceof Error ? error.message : String(error)
|
|
157
157
|
writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
|