@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5
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 +56 -18
- package/README.zh.md +56 -18
- package/lib/client.js +6242 -958
- package/lib/client.js.map +1 -1
- package/lib/index.js +3475 -234
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +23 -2
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/board-card-seat.d.ts +34 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/locales.d.ts +141 -3
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +38 -1
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +110 -5
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification.d.ts +386 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +71 -0
- package/lib/types/host/verification-runner.d.ts +108 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +107 -11
- package/lib/types/host-runner.d.ts +99 -4
- package/lib/types/host-service.d.ts +114 -2
- package/lib/types/index.d.ts +80 -26
- package/lib/types/protocol.d.ts +53 -3
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +27 -26
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +304 -65
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +30 -15
- package/src/client/board/NewTaskModal.tsx +288 -151
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +238 -86
- package/src/client/board/TaskDetail.tsx +154 -59
- package/src/client/board/TaskForm.tsx +61 -6
- package/src/client/board/VerificationReport.tsx +203 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board/status-key.ts +27 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-card-seat.ts +102 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +602 -68
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +48 -6
- package/src/client/locales.ts +290 -12
- package/src/client/native-panel.tsx +18 -2
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +101 -5
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +26 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +138 -10
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -4
- package/src/core/verification.ts +826 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +58 -24
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +331 -0
- package/src/host/verification-runner.ts +535 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +342 -51
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +219 -20
- package/src/host-service.ts +346 -26
- package/src/index.ts +204 -16
- package/src/protocol.ts +122 -9
- package/src/tool-surface.ts +95 -0
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
package/src/host-ledger.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
|
-
import { isValidCron, nextRunAtMs } from './core/schedule.ts'
|
|
6
|
+
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, hasOpenExecution, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_SUBTASK_DEPTH,
|
|
11
11
|
cascadeTargets,
|
|
@@ -20,9 +20,11 @@ import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
|
20
20
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
21
21
|
import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts'
|
|
22
22
|
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
23
|
+
import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
|
|
23
24
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
25
|
+
import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
26
|
+
import type { ExecutionVerification } from './core/verification.ts'
|
|
27
|
+
import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
26
28
|
|
|
27
29
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
28
30
|
importedSources?: string[]
|
|
@@ -57,7 +59,7 @@ function bindingRefusalMessage(
|
|
|
57
59
|
surface: 'run' | 'schedule',
|
|
58
60
|
): string {
|
|
59
61
|
if (refusal.kind === 'subtask-pin') {
|
|
60
|
-
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session; clear
|
|
62
|
+
return `team run cannot honor the permission binding of subtask "${refusal.title}": a teammate runs inside the Lead session and inherits its permission, which that pin exceeds; clear the card's permission or run the tree without Agent Team`
|
|
61
63
|
}
|
|
62
64
|
if (surface === 'schedule') return `task "${refusal.title}" has an unconfirmed above-default permission`
|
|
63
65
|
if (refusal.kind === 'root') {
|
|
@@ -84,12 +86,21 @@ export interface OpenExecutionReference {
|
|
|
84
86
|
readonly executionId: string
|
|
85
87
|
readonly sessionId: string | undefined
|
|
86
88
|
readonly startedAt: number
|
|
89
|
+
/**
|
|
90
|
+
* True for a member of a team-mode run other than its Lead. A teammate is a
|
|
91
|
+
* durable member of the Team: it stays alive after its turn ends, so the
|
|
92
|
+
* roster may keep reporting its session as running, and the turn it completed
|
|
93
|
+
* is the only verdict it exposes.
|
|
94
|
+
*/
|
|
95
|
+
readonly teamMember: boolean
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/** Minimal value copy used by the Host scheduler. */
|
|
90
99
|
export interface DueScheduleReference {
|
|
91
100
|
readonly taskId: string
|
|
92
101
|
readonly cron: string
|
|
102
|
+
/** Zone this rule's wall clock is read in. */
|
|
103
|
+
readonly timeZone: string
|
|
93
104
|
readonly nextRunAt: number
|
|
94
105
|
}
|
|
95
106
|
|
|
@@ -106,15 +117,16 @@ interface CachedRequest {
|
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
function timeZone(): string {
|
|
109
|
-
return
|
|
120
|
+
return resolveHostTimeZone()
|
|
110
121
|
}
|
|
111
122
|
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
/** The zone a schedule is evaluated in: its own, or the Host zone when it stores none. */
|
|
124
|
+
function scheduleZone(schedule: { timeZone?: string }): string {
|
|
125
|
+
return schedule.timeZone ?? timeZone()
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
function
|
|
117
|
-
return
|
|
128
|
+
function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
129
|
+
return JSON.parse(JSON.stringify(tasks)) as TaskRecord[]
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
/**
|
|
@@ -327,7 +339,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
327
339
|
const previous = byId.get(entry.id)
|
|
328
340
|
byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
|
|
329
341
|
}
|
|
330
|
-
|
|
342
|
+
// Acceptance evidence is the one field "betterExecution" cannot choose: a
|
|
343
|
+
// browser half that also wrote this execution carries no attempts, and taking
|
|
344
|
+
// its copy would erase the Host's verdict. The copy with the most recorded
|
|
345
|
+
// attempts wins, and one execution's verdict is never copied onto another.
|
|
346
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
|
|
347
|
+
if (entry.verification !== undefined) return entry
|
|
348
|
+
const richer = [...a.executions, ...b.executions]
|
|
349
|
+
.filter(other => other.id === entry.id && other.verification !== undefined)
|
|
350
|
+
.sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
|
|
351
|
+
return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
|
|
352
|
+
})
|
|
331
353
|
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
332
354
|
}
|
|
333
355
|
|
|
@@ -381,6 +403,7 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
381
403
|
schedule: {
|
|
382
404
|
enabled: false,
|
|
383
405
|
cron: schedule.cron,
|
|
406
|
+
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
384
407
|
nextRunAt: undefined,
|
|
385
408
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
386
409
|
? schedule.lastTriggeredAt
|
|
@@ -401,8 +424,18 @@ export class HostTaskLedger {
|
|
|
401
424
|
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
402
425
|
readonly schedulerFile: string
|
|
403
426
|
|
|
404
|
-
/**
|
|
405
|
-
readonly
|
|
427
|
+
/** Resolves the baseline the confirmation gate compares a binding against. */
|
|
428
|
+
private readonly sessionDefault: () => TaskPermission
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Session-default permission the confirmation gate compares against. Read
|
|
432
|
+
* through the resolver on every use, so a baseline that follows the Host's
|
|
433
|
+
* own default preset tracks a Settings change made while the board runs.
|
|
434
|
+
*/
|
|
435
|
+
get sessionDefaultPermission(): TaskPermission {
|
|
436
|
+
return this.sessionDefault()
|
|
437
|
+
}
|
|
438
|
+
|
|
406
439
|
/**
|
|
407
440
|
* Deployment subtask depth limit (1..3): the lineage gate every write obeys.
|
|
408
441
|
* The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
|
|
@@ -410,8 +443,10 @@ export class HostTaskLedger {
|
|
|
410
443
|
*/
|
|
411
444
|
private depthLimit: number
|
|
412
445
|
|
|
413
|
-
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
|
|
414
|
-
|
|
446
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
|
|
447
|
+
// Resolved before the load/repair passes below: they can evaluate the gate.
|
|
448
|
+
const baseline = options.sessionDefaultPermission
|
|
449
|
+
this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
|
|
415
450
|
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
416
451
|
mkdirSync(dir, { recursive: true })
|
|
417
452
|
this.file = join(dir, 'ledger-v2.json')
|
|
@@ -426,6 +461,10 @@ export class HostTaskLedger {
|
|
|
426
461
|
}
|
|
427
462
|
this.repairSchedules(true)
|
|
428
463
|
this.reconcileInterruptedStarts()
|
|
464
|
+
// Boot recovery also folds every already-decided run: a team run whose
|
|
465
|
+
// Lead recorded its verdict in an earlier process, and a lineage whose
|
|
466
|
+
// last settle never reached its parent, both leave the running column here.
|
|
467
|
+
this.finalizeReadyRuns(false)
|
|
429
468
|
// Persist a freshly generated ledger identity and any recovery error
|
|
430
469
|
// immediately, even when there are no tasks to trigger a later action.
|
|
431
470
|
this.commit(false)
|
|
@@ -488,6 +527,15 @@ export class HostTaskLedger {
|
|
|
488
527
|
*/
|
|
489
528
|
runtimeView(): LedgerRuntimeView {
|
|
490
529
|
let armedSchedules = 0
|
|
530
|
+
// Run groups opened by a team-mode card: every other member of those groups
|
|
531
|
+
// runs as a teammate inside that card's Lead session.
|
|
532
|
+
const teamGroups = new Set<string>()
|
|
533
|
+
for (const task of this.document.tasks) {
|
|
534
|
+
if (task.teamRun !== true) continue
|
|
535
|
+
for (const execution of task.executions) {
|
|
536
|
+
if (execution.runGroupId !== undefined) teamGroups.add(execution.runGroupId)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
491
539
|
const openExecutions: OpenExecutionReference[] = []
|
|
492
540
|
for (const task of this.document.tasks) {
|
|
493
541
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
@@ -501,6 +549,9 @@ export class HostTaskLedger {
|
|
|
501
549
|
executionId: execution.id,
|
|
502
550
|
sessionId: execution.sessionId,
|
|
503
551
|
startedAt: execution.startedAt,
|
|
552
|
+
teamMember: task.teamRun !== true
|
|
553
|
+
&& execution.runGroupId !== undefined
|
|
554
|
+
&& teamGroups.has(execution.runGroupId),
|
|
504
555
|
})
|
|
505
556
|
}
|
|
506
557
|
}
|
|
@@ -543,7 +594,7 @@ export class HostTaskLedger {
|
|
|
543
594
|
if (task.archivedAt !== undefined) continue
|
|
544
595
|
const schedule = task.schedule
|
|
545
596
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
546
|
-
due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
|
|
597
|
+
due.push({ taskId: task.id, cron: schedule.cron, timeZone: scheduleZone(schedule), nextRunAt: schedule.nextRunAt })
|
|
547
598
|
}
|
|
548
599
|
return due
|
|
549
600
|
}
|
|
@@ -613,7 +664,7 @@ export class HostTaskLedger {
|
|
|
613
664
|
rollForward()
|
|
614
665
|
return []
|
|
615
666
|
}
|
|
616
|
-
if (
|
|
667
|
+
if (hasOpenExecution(task)) {
|
|
617
668
|
rollForward()
|
|
618
669
|
return []
|
|
619
670
|
}
|
|
@@ -626,7 +677,7 @@ export class HostTaskLedger {
|
|
|
626
677
|
const schedule = task.schedule
|
|
627
678
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
628
679
|
changed = true
|
|
629
|
-
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now }
|
|
680
|
+
return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now, scheduleZone(schedule)) }, updatedAt: now }
|
|
630
681
|
})
|
|
631
682
|
if (changed) this.commit()
|
|
632
683
|
}
|
|
@@ -681,20 +732,48 @@ export class HostTaskLedger {
|
|
|
681
732
|
})
|
|
682
733
|
changed = true
|
|
683
734
|
}
|
|
735
|
+
// A team run is governed by its Lead: the Lead's own outcome closes whatever
|
|
736
|
+
// the Team still holds open, so the tree can never wait on a teammate that
|
|
737
|
+
// will never report one.
|
|
738
|
+
if (task.teamRun === true
|
|
739
|
+
&& this.closeTeamMembers(taskId, groupId, execution.ownResult ?? outcome, execution.ownError ?? error, now)) changed = true
|
|
684
740
|
if (this.settleCascade(taskId, groupId, now)) changed = true
|
|
685
741
|
if (changed) this.commit()
|
|
686
742
|
}
|
|
687
743
|
|
|
688
744
|
attachSession(taskId: string, executionId: string, sessionId: string): void {
|
|
689
745
|
const now = this.now()
|
|
690
|
-
|
|
691
|
-
|
|
746
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
747
|
+
// A run the Lead's verdict already closed keeps its record: attaching a
|
|
748
|
+
// session to a settled execution would rewrite history the Host no longer
|
|
749
|
+
// observes (a teammate spawn that resolved after its Team was closed).
|
|
750
|
+
if (task?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) return
|
|
751
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
752
|
+
...item,
|
|
692
753
|
updatedAt: now,
|
|
693
|
-
executions:
|
|
754
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry),
|
|
694
755
|
})
|
|
695
756
|
this.commit()
|
|
696
757
|
}
|
|
697
758
|
|
|
759
|
+
allTasks(): readonly TaskRecord[] {
|
|
760
|
+
return this.document.tasks
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
getTask(id: string): TaskRecord | undefined {
|
|
764
|
+
return this.document.tasks.find(item => item.id === id)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
saveTaskRecord(task: TaskRecord): void {
|
|
768
|
+
const exists = this.document.tasks.some(item => item.id === task.id)
|
|
769
|
+
if (exists) {
|
|
770
|
+
this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
|
|
771
|
+
} else {
|
|
772
|
+
this.document.tasks = [...this.document.tasks, task]
|
|
773
|
+
}
|
|
774
|
+
this.commit()
|
|
775
|
+
}
|
|
776
|
+
|
|
698
777
|
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
|
|
699
778
|
const now = this.now()
|
|
700
779
|
let runs: OpenedRun[] | undefined
|
|
@@ -717,17 +796,22 @@ export class HostTaskLedger {
|
|
|
717
796
|
: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}`
|
|
718
797
|
this.repairSchedules(true, false)
|
|
719
798
|
this.reconcileInterruptedStarts(false)
|
|
799
|
+
// An imported document may carry a decided-but-open run; fold it in the
|
|
800
|
+
// same action (apply() commits once at the end).
|
|
801
|
+
this.finalizeReadyRuns(false)
|
|
720
802
|
break
|
|
721
803
|
}
|
|
722
804
|
case 'create': {
|
|
723
805
|
if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
|
|
724
|
-
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
806
|
+
if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron)
|
|
807
|
+
|| !isValidTimeZone(action.input.schedule.timeZone ?? timeZone())
|
|
808
|
+
|| nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
|
|
725
809
|
throw new Error('invalid schedule')
|
|
726
810
|
}
|
|
727
811
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
728
812
|
? action.input
|
|
729
813
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
730
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth)
|
|
814
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
|
|
731
815
|
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
732
816
|
this.document.tasks = [...result.tasks]
|
|
733
817
|
break
|
|
@@ -755,7 +839,7 @@ export class HostTaskLedger {
|
|
|
755
839
|
case 'delete': {
|
|
756
840
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
757
841
|
if (task === undefined) throw new Error('task not found')
|
|
758
|
-
if (
|
|
842
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
|
|
759
843
|
// Subtasks keep their link: deleting the parent would leave dangling
|
|
760
844
|
// children, so the user detaches or deletes them explicitly first.
|
|
761
845
|
if (this.document.tasks.some(item => item.parentId === action.taskId)) {
|
|
@@ -774,7 +858,10 @@ export class HostTaskLedger {
|
|
|
774
858
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
775
859
|
if (task === undefined) throw new Error('task not found')
|
|
776
860
|
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
777
|
-
|
|
861
|
+
// The lock belongs to an open execution, not to the column: a card
|
|
862
|
+
// parked in 'running' by hand has no session and stays movable, while
|
|
863
|
+
// a card the runner is executing cannot be moved out from under it.
|
|
864
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
|
|
778
865
|
if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
|
|
779
866
|
this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
|
|
780
867
|
break
|
|
@@ -786,6 +873,34 @@ export class HostTaskLedger {
|
|
|
786
873
|
this.document.tasks = [...result.tasks]
|
|
787
874
|
break
|
|
788
875
|
}
|
|
876
|
+
case 'settle': {
|
|
877
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
878
|
+
if (task === undefined) throw new Error('task not found')
|
|
879
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
880
|
+
if (!task.executions.some(entry => entry.endedAt === undefined)) throw new Error('task has no open execution')
|
|
881
|
+
// Force-close the executions this card governs. The verdict is
|
|
882
|
+
// 'cancelled', never 'succeeded': a card whose execution produced no
|
|
883
|
+
// evidence of a completed turn must not be recorded as finished work,
|
|
884
|
+
// and a cancelled settlement returns the card to the todo column so the
|
|
885
|
+
// operator can run it again. Nothing else clears a running card: move,
|
|
886
|
+
// archive and delete all refuse one, which is what made a stuck card
|
|
887
|
+
// unrecoverable from the board.
|
|
888
|
+
const reason = 'execution closed manually by '
|
|
889
|
+
+ (initiator === undefined || initiator === '' ? 'the operator' : initiator)
|
|
890
|
+
+ ': no verdict was recorded'
|
|
891
|
+
for (const member of cascadeTargets(this.document.tasks, action.taskId, this.maxSubtaskDepth)) {
|
|
892
|
+
for (const execution of member.executions) {
|
|
893
|
+
if (execution.endedAt !== undefined) continue
|
|
894
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== member.id
|
|
895
|
+
? item
|
|
896
|
+
: settleExecution(item, execution.id, 'cancelled', now, reason))
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// Fold whatever became ready in the same action: an ancestor whose last
|
|
900
|
+
// member this closed leaves the running column with it.
|
|
901
|
+
this.finalizeReadyRuns(false)
|
|
902
|
+
break
|
|
903
|
+
}
|
|
789
904
|
case 'restore': {
|
|
790
905
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
791
906
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -802,10 +917,22 @@ export class HostTaskLedger {
|
|
|
802
917
|
: item)
|
|
803
918
|
break
|
|
804
919
|
}
|
|
920
|
+
case 'rename-tag': {
|
|
921
|
+
const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
|
|
922
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
923
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
924
|
+
break
|
|
925
|
+
}
|
|
926
|
+
case 'delete-tag': {
|
|
927
|
+
const result = applyDeleteTag(this.document.tasks, action.name, now)
|
|
928
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
929
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
930
|
+
break
|
|
931
|
+
}
|
|
805
932
|
case 'set-schedule': {
|
|
806
933
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
807
934
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
808
|
-
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now)
|
|
935
|
+
const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now, timeZone())
|
|
809
936
|
if (!result.applied) throw new Error('invalid schedule')
|
|
810
937
|
this.document.tasks = [...result.tasks]
|
|
811
938
|
break
|
|
@@ -814,7 +941,7 @@ export class HostTaskLedger {
|
|
|
814
941
|
case 'run': {
|
|
815
942
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
816
943
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
817
|
-
if (task === undefined ||
|
|
944
|
+
if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
818
945
|
// The confirmation gate judges the RESOLVED binding: a subtask that
|
|
819
946
|
// inherits an elevated permission from its parent is exactly as
|
|
820
947
|
// unconfirmed as the parent would be without its own stamp.
|
|
@@ -835,10 +962,12 @@ export class HostTaskLedger {
|
|
|
835
962
|
*
|
|
836
963
|
* A plain cascade launches one session per participant, so every participant
|
|
837
964
|
* carries its own resolved binding and each one gates the run. A team run
|
|
838
|
-
* launches only the Lead session: the Lead's binding gates it,
|
|
839
|
-
*
|
|
840
|
-
* refused
|
|
841
|
-
*
|
|
965
|
+
* launches only the Lead session: the Lead's binding gates it, and a
|
|
966
|
+
* teammate inherits that session's permission, so a subtask's OWN pin is
|
|
967
|
+
* refused exactly when the Lead session does not already carry it — the
|
|
968
|
+
* teammate could never be given that authority, and dropping the pin
|
|
969
|
+
* silently would misreport the work (an inherited binding is the Lead's own
|
|
970
|
+
* and stays allowed once the Lead is confirmed).
|
|
842
971
|
* @param root - the task being run.
|
|
843
972
|
* @returns the first refusal, or undefined when the run may start.
|
|
844
973
|
*/
|
|
@@ -849,12 +978,17 @@ export class HostTaskLedger {
|
|
|
849
978
|
if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
|
|
850
979
|
return { kind: 'root', title: lead.title }
|
|
851
980
|
}
|
|
852
|
-
// The
|
|
853
|
-
//
|
|
981
|
+
// The Lead session runs at the Lead's own binding, or at the deployment
|
|
982
|
+
// default when it pins none; a teammate inherits that permission and
|
|
983
|
+
// cannot be narrowed below it, so it carries a subtask pin when it is
|
|
984
|
+
// already at least as wide. The raw record carries the subtask's OWN
|
|
985
|
+
// binding: the resolved participant above already folded an inherited one
|
|
986
|
+
// into its permission.
|
|
987
|
+
const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
|
|
854
988
|
const pinned = participants.find(participant => {
|
|
855
989
|
if (participant.id === root.id) return false
|
|
856
990
|
const raw = this.document.tasks.find(task => task.id === participant.id)
|
|
857
|
-
return raw !== undefined &&
|
|
991
|
+
return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
|
|
858
992
|
})
|
|
859
993
|
return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
|
|
860
994
|
}
|
|
@@ -873,14 +1007,13 @@ export class HostTaskLedger {
|
|
|
873
1007
|
return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
|
|
874
1008
|
.map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
|
|
875
1009
|
.filter(participant => participant.archivedAt === undefined
|
|
876
|
-
&& participant.status !== 'running'
|
|
877
1010
|
&& !hasOpenExecution(participant))
|
|
878
1011
|
}
|
|
879
1012
|
|
|
880
1013
|
/** Whether a task or any of its subtasks still has an open execution. */
|
|
881
1014
|
private subtreeHasOpenExecution(id: string): boolean {
|
|
882
1015
|
return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
|
|
883
|
-
.some(task =>
|
|
1016
|
+
.some(task => hasOpenExecution(task))
|
|
884
1017
|
}
|
|
885
1018
|
|
|
886
1019
|
/**
|
|
@@ -905,7 +1038,7 @@ export class HostTaskLedger {
|
|
|
905
1038
|
const runs: OpenedRun[] = []
|
|
906
1039
|
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
907
1040
|
if (task.archivedAt !== undefined) continue
|
|
908
|
-
if (
|
|
1041
|
+
if (hasOpenExecution(task)) continue
|
|
909
1042
|
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
910
1043
|
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
911
1044
|
started.set(task.id, opened.task)
|
|
@@ -959,6 +1092,95 @@ export class HostTaskLedger {
|
|
|
959
1092
|
return changed
|
|
960
1093
|
}
|
|
961
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Close every run that is already decided, and fold every lineage whose
|
|
1097
|
+
* members are all settled. The Host poll calls this on every tick, so a run
|
|
1098
|
+
* can no longer be left in the running column by a settle nobody observed:
|
|
1099
|
+
*
|
|
1100
|
+
* - A team run is governed by its Lead. Once the Lead's own outcome is
|
|
1101
|
+
* recorded, its still-open members are settled with that verdict. A
|
|
1102
|
+
* teammate is a durable member of the Team and may never report a turn of
|
|
1103
|
+
* its own, and without this the whole lineage waits on it forever.
|
|
1104
|
+
* - A cascade parent whose members are all settled is finalized here even
|
|
1105
|
+
* when the settle that completed the tree happened in another process, or
|
|
1106
|
+
* was interrupted between its child write and its parent write.
|
|
1107
|
+
*
|
|
1108
|
+
* Idempotent: a run that is already folded is left untouched, so a caller may
|
|
1109
|
+
* invoke it on every tick.
|
|
1110
|
+
* @param persist - false to leave the document for the caller to commit.
|
|
1111
|
+
* @returns whether the document changed.
|
|
1112
|
+
*/
|
|
1113
|
+
finalizeReadyRuns(persist = true): boolean {
|
|
1114
|
+
const now = this.now()
|
|
1115
|
+
let changed = false
|
|
1116
|
+
const groups = new Set<string>()
|
|
1117
|
+
for (const task of this.document.tasks) {
|
|
1118
|
+
for (const execution of task.executions) {
|
|
1119
|
+
if (execution.endedAt === undefined && execution.runGroupId !== undefined) groups.add(execution.runGroupId)
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
for (const groupId of groups) {
|
|
1123
|
+
const lead = this.document.tasks.find(task => task.teamRun === true
|
|
1124
|
+
&& task.executions.some(entry => entry.runGroupId === groupId))
|
|
1125
|
+
const verdict = lead === undefined ? undefined : this.leadVerdict(lead, groupId)
|
|
1126
|
+
if (lead !== undefined && verdict !== undefined
|
|
1127
|
+
&& this.closeTeamMembers(lead.id, groupId, verdict.result, verdict.error, now)) changed = true
|
|
1128
|
+
// Fold the lineage: a member may itself be a parent of a deeper member.
|
|
1129
|
+
for (const task of this.document.tasks) {
|
|
1130
|
+
if (this.settleCascade(task.id, groupId, now)) changed = true
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (changed && persist) this.commit()
|
|
1134
|
+
return changed
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* The verdict a team run's Lead has already recorded: its own turn outcome
|
|
1139
|
+
* once that turn ended, or the folded outcome once its card settled.
|
|
1140
|
+
*/
|
|
1141
|
+
private leadVerdict(lead: TaskRecord, groupId: string): { result: ExecutionOutcome; error: string | undefined } | undefined {
|
|
1142
|
+
const execution = lead.executions.find(entry => entry.runGroupId === groupId)
|
|
1143
|
+
const result = execution?.ownResult ?? execution?.result
|
|
1144
|
+
if (result === undefined) return undefined
|
|
1145
|
+
return { result, error: execution?.ownError ?? execution?.error }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Settle every still-open member of a team run with the Lead's verdict.
|
|
1150
|
+
* A teammate that already reported its own outcome keeps it: the ordinary
|
|
1151
|
+
* fold still lets a failure dominate.
|
|
1152
|
+
* @param leadId - the Lead card's task id (never settled here).
|
|
1153
|
+
* @param groupId - the team run's group.
|
|
1154
|
+
* @param verdict - the Lead's outcome, inherited by members that never reported.
|
|
1155
|
+
* @param error - the Lead's failure text, inherited with a failed verdict.
|
|
1156
|
+
* @param now - clock instant (ms epoch).
|
|
1157
|
+
* @returns whether the document changed.
|
|
1158
|
+
*/
|
|
1159
|
+
private closeTeamMembers(leadId: string, groupId: string, verdict: ExecutionOutcome, error: string | undefined, now: number): boolean {
|
|
1160
|
+
const members: string[] = []
|
|
1161
|
+
let recorded = false
|
|
1162
|
+
this.document.tasks = this.document.tasks.map(task => {
|
|
1163
|
+
if (task.id === leadId) return task
|
|
1164
|
+
const execution = openGroupExecution(task, groupId)
|
|
1165
|
+
if (execution === undefined) return task
|
|
1166
|
+
members.push(task.id)
|
|
1167
|
+
if (execution.ownResult !== undefined) return task
|
|
1168
|
+
recorded = true
|
|
1169
|
+
return {
|
|
1170
|
+
...task,
|
|
1171
|
+
updatedAt: now,
|
|
1172
|
+
executions: task.executions.map(entry => entry.id === execution.id
|
|
1173
|
+
? { ...entry, ownResult: verdict, ownError: error }
|
|
1174
|
+
: entry),
|
|
1175
|
+
}
|
|
1176
|
+
})
|
|
1177
|
+
let changed = recorded
|
|
1178
|
+
for (const memberId of members) {
|
|
1179
|
+
if (this.settleCascade(memberId, groupId, now)) changed = true
|
|
1180
|
+
}
|
|
1181
|
+
return changed
|
|
1182
|
+
}
|
|
1183
|
+
|
|
962
1184
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
963
1185
|
const now = this.now()
|
|
964
1186
|
let changed = false
|
|
@@ -966,7 +1188,7 @@ export class HostTaskLedger {
|
|
|
966
1188
|
const schedule = task.schedule
|
|
967
1189
|
if (schedule === undefined || !schedule.enabled) return task
|
|
968
1190
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
969
|
-
const next = nextRunAtMs(schedule.cron, now)
|
|
1191
|
+
const next = nextRunAtMs(schedule.cron, now, scheduleZone(schedule))
|
|
970
1192
|
if (next === undefined) {
|
|
971
1193
|
changed = true
|
|
972
1194
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
@@ -984,7 +1206,6 @@ export class HostTaskLedger {
|
|
|
984
1206
|
let changed = false
|
|
985
1207
|
const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
|
|
986
1208
|
this.document.tasks = this.document.tasks.map(task => {
|
|
987
|
-
if (task.status !== 'running') return task
|
|
988
1209
|
const execution = task.executions.at(-1)
|
|
989
1210
|
if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
|
|
990
1211
|
changed = true
|
|
@@ -1003,35 +1224,105 @@ export class HostTaskLedger {
|
|
|
1003
1224
|
}
|
|
1004
1225
|
|
|
1005
1226
|
/**
|
|
1006
|
-
* Field-preserving
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1227
|
+
* Field-preserving migration of an older document. It first proves every task
|
|
1228
|
+
* row is structurally valid, so a document that would silently drop or coerce
|
|
1229
|
+
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
1230
|
+
* the current normalization.
|
|
1231
|
+
*
|
|
1232
|
+
* v4 adds `ScheduleRule.timeZone`. A rule written before v4 was evaluated in
|
|
1233
|
+
* whatever zone the Host process happened to report, so the migration stamps
|
|
1234
|
+
* that zone onto every enabled rule: the trigger instant is preserved exactly
|
|
1235
|
+
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
1236
|
+
* following a later `TZ` change, which is what made an existing schedule
|
|
1237
|
+
* silently move when the Host's zone changed.
|
|
1238
|
+
*
|
|
1239
|
+
* v5 adds the per-execution acceptance block. The migration deliberately does
|
|
1240
|
+
* NOT stamp one: an execution opened before v5 keeps no contract, so it
|
|
1241
|
+
* settles on its own historical verdict and no in-flight run is retroactively
|
|
1242
|
+
* judged by a gate that was never armed for it.
|
|
1010
1243
|
*/
|
|
1011
1244
|
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
1012
1245
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
1013
|
-
throw new Error(
|
|
1246
|
+
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
1247
|
+
}
|
|
1248
|
+
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1249
|
+
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1250
|
+
const rows = (parsed.tasks as readonly unknown[]).map((value) => {
|
|
1251
|
+
const row = value as { schedule?: unknown }
|
|
1252
|
+
const schedule = row.schedule
|
|
1253
|
+
if (typeof schedule !== 'object' || schedule === null) return value
|
|
1254
|
+
if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
|
|
1255
|
+
return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
|
|
1256
|
+
})
|
|
1257
|
+
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Find the still-open execution that ran in one session, with its task.
|
|
1262
|
+
*
|
|
1263
|
+
* The completion gate keys on the session id its tool call arrives from: the
|
|
1264
|
+
* execution record is the only object binding a session to a card, and an
|
|
1265
|
+
* already-settled execution is deliberately not returned (a session the board
|
|
1266
|
+
* no longer owns must not be gated).
|
|
1267
|
+
* @param sessionId - the DSH session the tool call runs in.
|
|
1268
|
+
* @returns the open execution and its task, or undefined.
|
|
1269
|
+
*/
|
|
1270
|
+
findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
|
|
1271
|
+
if (sessionId === '') return undefined
|
|
1272
|
+
for (const task of this.document.tasks) {
|
|
1273
|
+
for (const execution of task.executions) {
|
|
1274
|
+
if (execution.endedAt !== undefined) continue
|
|
1275
|
+
if (execution.sessionId === sessionId) return { task, execution }
|
|
1276
|
+
}
|
|
1014
1277
|
}
|
|
1015
|
-
return
|
|
1278
|
+
return undefined
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Replace one execution's acceptance block (creating it when absent).
|
|
1283
|
+
*
|
|
1284
|
+
* A no-op when the serialized value is unchanged, so a coarse caller that
|
|
1285
|
+
* re-writes the same state never bumps the ledger revision. A settled
|
|
1286
|
+
* execution keeps its record: the report of a finished cycle must survive,
|
|
1287
|
+
* and only the board's own settlement moves the card.
|
|
1288
|
+
* @param taskId - the task owning the execution.
|
|
1289
|
+
* @param executionId - the execution to update.
|
|
1290
|
+
* @param verification - the new block.
|
|
1291
|
+
* @returns true when the ledger changed.
|
|
1292
|
+
*/
|
|
1293
|
+
setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
|
|
1294
|
+
const now = this.now()
|
|
1295
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
1296
|
+
const execution = task?.executions.find(entry => entry.id === executionId)
|
|
1297
|
+
if (task === undefined || execution === undefined) return false
|
|
1298
|
+
if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
|
|
1299
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
1300
|
+
...item,
|
|
1301
|
+
updatedAt: now,
|
|
1302
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
|
|
1303
|
+
})
|
|
1304
|
+
this.commit()
|
|
1305
|
+
return true
|
|
1016
1306
|
}
|
|
1017
1307
|
|
|
1018
1308
|
private load(dir: string): LedgerDocument {
|
|
1019
1309
|
const existed = existsSync(this.file)
|
|
1020
|
-
// schemaVersion stays unknown-typed here: on-disk documents may be v2
|
|
1021
|
-
// (legacy),
|
|
1310
|
+
// schemaVersion stays unknown-typed here: on-disk documents may be v2 or
|
|
1311
|
+
// v3 (legacy), v4, or any future/invalid value the branches below sort out.
|
|
1022
1312
|
let parsed: ParsedLedgerDocument
|
|
1023
1313
|
try {
|
|
1024
1314
|
parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument
|
|
1025
1315
|
} catch (error) {
|
|
1026
1316
|
return this.recoverCorrupt(dir, existed, error)
|
|
1027
1317
|
}
|
|
1028
|
-
if (parsed.schemaVersion ===
|
|
1318
|
+
if (typeof parsed.schemaVersion === 'number'
|
|
1319
|
+
&& TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
|
|
1029
1320
|
try {
|
|
1030
1321
|
return this.migrateLegacyDocument(parsed)
|
|
1031
1322
|
} catch (error) {
|
|
1032
|
-
// Migration failure is explicit: the original
|
|
1323
|
+
// Migration failure is explicit: the original file stays in place
|
|
1033
1324
|
// for manual recovery and the ledger refuses to start (fail closed).
|
|
1034
|
-
throw new Error(`ledger
|
|
1325
|
+
throw new Error(`ledger v${String(parsed.schemaVersion)} to v${TASK_BOARD_SCHEMA_VERSION} migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
1035
1326
|
}
|
|
1036
1327
|
}
|
|
1037
1328
|
try {
|
|
@@ -1246,4 +1537,4 @@ export class HostTaskLedger {
|
|
|
1246
1537
|
}
|
|
1247
1538
|
throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
|
|
1248
1539
|
}
|
|
1249
|
-
}
|
|
1540
|
+
}
|