@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7
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 +34 -18
- package/README.zh.md +34 -18
- package/lib/client.js +2804 -1024
- package/lib/client.js.map +1 -1
- package/lib/index.js +2576 -416
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +81 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +41 -17
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +163 -14
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +91 -11
- package/lib/types/host-runner.d.ts +111 -13
- package/lib/types/host-service.d.ts +199 -8
- package/lib/types/index.d.ts +141 -0
- package/lib/types/protocol.d.ts +63 -12
- package/package.json +29 -33
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +288 -17
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +237 -60
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +261 -24
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/status-key.ts +3 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +105 -108
- package/src/client/locales.ts +171 -6
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +55 -16
- package/src/core/launch-width.ts +38 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +69 -15
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +213 -39
- package/src/core/use-cases/task-create.ts +57 -25
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +9 -4
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +127 -29
- package/src/host/launch-admission.ts +140 -0
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +346 -48
- package/src/host-runner.ts +234 -35
- package/src/host-service.ts +539 -52
- package/src/index.ts +250 -7
- package/src/protocol.ts +160 -32
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
package/src/host-ledger.ts
CHANGED
|
@@ -5,11 +5,12 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { dshHome } from './dsh-home.ts'
|
|
6
6
|
import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from './core/schedule.ts'
|
|
7
7
|
import { isTaskRecord, parseLedger } from './core/store.ts'
|
|
8
|
-
import { canMoveManually, hasOpenExecution, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionOutcome, type ExecutionRecord, type TaskRecord } from './core/tasks.ts'
|
|
8
|
+
import { canMoveManually, hasOpenExecution, retainRecentExecutions, scheduleExhausted, scheduleRunBudget, settleExecution, settledStatus, startExecution, withSchedule, withStatus, type ExecutionOutcome, type ExecutionRecord, type ScheduleMode, type ScheduleStopReason, type TaskRecord } from './core/tasks.ts'
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_SUBTASK_DEPTH,
|
|
11
11
|
cascadeTargets,
|
|
12
12
|
combineCascadeOutcome,
|
|
13
|
+
joinsCascadeRun,
|
|
13
14
|
normalizeSubtaskDepth,
|
|
14
15
|
openGroupExecution,
|
|
15
16
|
pendingCascadeChildren,
|
|
@@ -17,13 +18,14 @@ import {
|
|
|
17
18
|
} from './core/subtask.ts'
|
|
18
19
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
19
20
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
21
|
+
import type { CardDefaults } from './core/card-defaults.ts'
|
|
20
22
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
21
|
-
import { applySetSchedule,
|
|
23
|
+
import { applySetSchedule, applyScheduleProgress, applyScheduleRefund, type ScheduleProgress } from './core/use-cases/task-schedule.ts'
|
|
22
24
|
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
23
25
|
import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
|
|
24
26
|
import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts'
|
|
25
|
-
import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
26
|
-
import type
|
|
27
|
+
import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
28
|
+
import { withoutAcceptanceAnomalies, type ExecutionVerification } from './core/verification.ts'
|
|
27
29
|
import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
28
30
|
|
|
29
31
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
@@ -98,7 +100,10 @@ export interface OpenExecutionReference {
|
|
|
98
100
|
/** Minimal value copy used by the Host scheduler. */
|
|
99
101
|
export interface DueScheduleReference {
|
|
100
102
|
readonly taskId: string
|
|
101
|
-
|
|
103
|
+
/** Whether this due rule is a recurring cron rule or a single planned instant. */
|
|
104
|
+
readonly mode: ScheduleMode
|
|
105
|
+
/** Cron expression of a recurring rule; absent on a one-shot. */
|
|
106
|
+
readonly cron?: string
|
|
102
107
|
/** Zone this rule's wall clock is read in. */
|
|
103
108
|
readonly timeZone: string
|
|
104
109
|
readonly nextRunAt: number
|
|
@@ -108,6 +113,24 @@ export interface DueScheduleReference {
|
|
|
108
113
|
export interface LedgerRuntimeView {
|
|
109
114
|
readonly armedSchedules: number
|
|
110
115
|
readonly openExecutions: readonly OpenExecutionReference[]
|
|
116
|
+
/**
|
|
117
|
+
* Session ids of every open execution, including the ones
|
|
118
|
+
* {@link openExecutions} skips because their own outcome is already
|
|
119
|
+
* recorded. A caller that asks whether the board still observes any session
|
|
120
|
+
* must use this list: a deferred cascade parent is settled by its members,
|
|
121
|
+
* but it is still an open execution.
|
|
122
|
+
*/
|
|
123
|
+
readonly openSessionIds: readonly string[]
|
|
124
|
+
/**
|
|
125
|
+
* Whether this document holds anything the session roster can still decide:
|
|
126
|
+
* an open execution that needs inspecting, or a card in the running column
|
|
127
|
+
* whose verdict may arrive from a settle this process never saw.
|
|
128
|
+
*
|
|
129
|
+
* A board with no running card, no open execution and no armed schedule has
|
|
130
|
+
* no use for the roster at all, which is what lets the Host poll stand down
|
|
131
|
+
* instead of re-reading every persisted session forever.
|
|
132
|
+
*/
|
|
133
|
+
readonly needsSessionState: boolean
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
const MAX_REQUEST_CACHE = 256
|
|
@@ -125,8 +148,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
|
|
|
125
148
|
return schedule.timeZone ?? timeZone()
|
|
126
149
|
}
|
|
127
150
|
|
|
128
|
-
|
|
129
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The task collection one board read hands out.
|
|
153
|
+
*
|
|
154
|
+
* A read must not hand a consumer the ledger's own array: the state route, every
|
|
155
|
+
* action response and the agent tools all receive this value in-process, so an
|
|
156
|
+
* in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
|
|
157
|
+
* the authoritative document from outside the write path. One level of copying
|
|
158
|
+
* is what that contract needs, and it is all it needs: every write in this class
|
|
159
|
+
* replaces the document's array (`this.document.tasks = ...`) and replaces the
|
|
160
|
+
* records it changes rather than mutating them, so the records a caller receives
|
|
161
|
+
* are stable values. Copying them as well only rebuilt the entire document on
|
|
162
|
+
* every board read - measured at 0.43 ms per read for 400 cards with 2
|
|
163
|
+
* executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
|
|
164
|
+
* consumer relies on. Callers still must not mutate a record in place; that is
|
|
165
|
+
* what the shared record objects now make possible, and no consumer in this
|
|
166
|
+
* package does it.
|
|
167
|
+
*/
|
|
168
|
+
function tasksForRead(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
169
|
+
return [...tasks]
|
|
130
170
|
}
|
|
131
171
|
|
|
132
172
|
/**
|
|
@@ -397,17 +437,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
397
437
|
const rawSchedule = rawById.get(task.id)?.schedule
|
|
398
438
|
if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
|
|
399
439
|
const schedule = rawSchedule as Record<string, unknown>
|
|
440
|
+
// A one-shot carries no cron; its instant is validated by the wire gate and
|
|
441
|
+
// the store repair, so there is nothing to salvage here.
|
|
442
|
+
if (schedule.mode === 'once') return task
|
|
400
443
|
if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
|
|
401
444
|
return {
|
|
402
445
|
...task,
|
|
403
446
|
schedule: {
|
|
404
447
|
enabled: false,
|
|
448
|
+
mode: 'cron',
|
|
405
449
|
cron: schedule.cron,
|
|
406
450
|
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
407
451
|
nextRunAt: undefined,
|
|
408
452
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
409
453
|
? schedule.lastTriggeredAt
|
|
410
454
|
: undefined,
|
|
455
|
+
runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
|
|
456
|
+
? schedule.runCount
|
|
457
|
+
: 0,
|
|
411
458
|
},
|
|
412
459
|
}
|
|
413
460
|
})
|
|
@@ -443,11 +490,15 @@ export class HostTaskLedger {
|
|
|
443
490
|
*/
|
|
444
491
|
private depthLimit: number
|
|
445
492
|
|
|
446
|
-
|
|
493
|
+
/** Live reader of the deployment's new-card defaults (a volatile config read). */
|
|
494
|
+
private readonly cardDefaults: () => CardDefaults
|
|
495
|
+
|
|
496
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number; cardDefaults?: () => CardDefaults } = {}) {
|
|
447
497
|
// Resolved before the load/repair passes below: they can evaluate the gate.
|
|
448
498
|
const baseline = options.sessionDefaultPermission
|
|
449
499
|
this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
|
|
450
500
|
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
501
|
+
this.cardDefaults = options.cardDefaults ?? (() => ({}))
|
|
451
502
|
mkdirSync(dir, { recursive: true })
|
|
452
503
|
this.file = join(dir, 'ledger-v2.json')
|
|
453
504
|
this.lockFile = join(dir, 'ledger-v2.lock')
|
|
@@ -517,16 +568,17 @@ export class HostTaskLedger {
|
|
|
517
568
|
|
|
518
569
|
state(): LedgerState {
|
|
519
570
|
const { revision, scheduler } = this.summary()
|
|
520
|
-
return { revision, tasks:
|
|
571
|
+
return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
|
|
521
572
|
}
|
|
522
573
|
|
|
523
574
|
/**
|
|
524
|
-
* Runtime-only projection for the
|
|
575
|
+
* Runtime-only projection for the Host poll. It copies just primitive
|
|
525
576
|
* identifiers and timestamps, never the complete task/execution history or
|
|
526
577
|
* an authoritative mutable object from the ledger.
|
|
527
578
|
*/
|
|
528
579
|
runtimeView(): LedgerRuntimeView {
|
|
529
580
|
let armedSchedules = 0
|
|
581
|
+
let runningCards = 0
|
|
530
582
|
// Run groups opened by a team-mode card: every other member of those groups
|
|
531
583
|
// runs as a teammate inside that card's Lead session.
|
|
532
584
|
const teamGroups = new Set<string>()
|
|
@@ -537,10 +589,13 @@ export class HostTaskLedger {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
const openExecutions: OpenExecutionReference[] = []
|
|
592
|
+
const openSessionIds: string[] = []
|
|
540
593
|
for (const task of this.document.tasks) {
|
|
541
594
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
595
|
+
if (task.status === 'running') runningCards += 1
|
|
542
596
|
for (const execution of task.executions) {
|
|
543
597
|
if (execution.endedAt !== undefined) continue
|
|
598
|
+
if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
|
|
544
599
|
// A deferred cascade parent already knows its own outcome; the monitor
|
|
545
600
|
// has nothing left to inspect, and its children's settles finalize it.
|
|
546
601
|
if (execution.ownResult !== undefined) continue
|
|
@@ -555,7 +610,12 @@ export class HostTaskLedger {
|
|
|
555
610
|
})
|
|
556
611
|
}
|
|
557
612
|
}
|
|
558
|
-
return {
|
|
613
|
+
return {
|
|
614
|
+
armedSchedules,
|
|
615
|
+
openExecutions,
|
|
616
|
+
openSessionIds,
|
|
617
|
+
needsSessionState: openExecutions.length > 0 || runningCards > 0,
|
|
618
|
+
}
|
|
559
619
|
}
|
|
560
620
|
|
|
561
621
|
/** Count armed, non-archived schedules without cloning task histories. */
|
|
@@ -594,7 +654,13 @@ export class HostTaskLedger {
|
|
|
594
654
|
if (task.archivedAt !== undefined) continue
|
|
595
655
|
const schedule = task.schedule
|
|
596
656
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
597
|
-
due.push({
|
|
657
|
+
due.push({
|
|
658
|
+
taskId: task.id,
|
|
659
|
+
mode: schedule.mode,
|
|
660
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
661
|
+
timeZone: scheduleZone(schedule),
|
|
662
|
+
nextRunAt: schedule.nextRunAt,
|
|
663
|
+
})
|
|
598
664
|
}
|
|
599
665
|
return due
|
|
600
666
|
}
|
|
@@ -646,38 +712,140 @@ export class HostTaskLedger {
|
|
|
646
712
|
/**
|
|
647
713
|
* Open the cascade one due schedule triggers: an empty array means nothing
|
|
648
714
|
* ran (already running, or a participant whose elevated permission is still
|
|
649
|
-
* unconfirmed
|
|
715
|
+
* unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
|
|
716
|
+
* and the rule's own state says which.
|
|
717
|
+
*
|
|
718
|
+
* The run budget is enforced HERE, inside the single ledger writer: a rule
|
|
719
|
+
* whose counter already reached its cap is stopped instead of opened, so no
|
|
720
|
+
* restart, repeated timer fire or re-arm can overrun it.
|
|
721
|
+
* @param taskId - the task whose rule came due.
|
|
722
|
+
* @param triggeredAt - the instant the occurrence came due.
|
|
723
|
+
* @returns the executions this occurrence opened.
|
|
650
724
|
*/
|
|
651
|
-
openScheduled(taskId: string,
|
|
725
|
+
openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
|
|
652
726
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
653
727
|
if (task === undefined || task.archivedAt !== undefined) return []
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
728
|
+
const schedule = task.schedule
|
|
729
|
+
if (schedule === undefined || !schedule.enabled) return []
|
|
730
|
+
|
|
731
|
+
// A rule that has spent its budget never opens another run. The stop is
|
|
732
|
+
// recorded here rather than trusted to the firing timer, so a rule that
|
|
733
|
+
// was hand-edited into an exhausted state still terminates visibly.
|
|
734
|
+
if (scheduleExhausted(schedule)) {
|
|
735
|
+
this.progressSchedule(taskId, {
|
|
736
|
+
consumed: 0,
|
|
737
|
+
endedReason: schedule.mode === 'once' ? 'fired' : 'limit',
|
|
738
|
+
}, triggeredAt)
|
|
739
|
+
return []
|
|
657
740
|
}
|
|
741
|
+
|
|
658
742
|
const refusal = this.bindingRefusal(task)
|
|
659
743
|
if (refusal !== undefined) {
|
|
660
744
|
// An unconfirmed above-default permission must never run unattended: the
|
|
661
|
-
// whole tree is refused and the
|
|
662
|
-
// exactly like the already-running refusal.
|
|
745
|
+
// whole tree is refused and the occurrence is skipped without consuming
|
|
746
|
+
// the run budget, exactly like the already-running refusal.
|
|
663
747
|
this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
|
|
664
|
-
|
|
748
|
+
this.skipOccurrence(taskId, 'permission', triggeredAt)
|
|
665
749
|
return []
|
|
666
750
|
}
|
|
667
751
|
if (hasOpenExecution(task)) {
|
|
668
|
-
|
|
752
|
+
this.skipOccurrence(taskId, 'busy', triggeredAt)
|
|
753
|
+
return []
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (schedule.mode === 'once') {
|
|
757
|
+
// The single planned instant is due: open its one run and stop the rule
|
|
758
|
+
// in the same commit, so the budget can never produce a second one.
|
|
759
|
+
return this.startCascade(task, triggeredAt, undefined, false, {
|
|
760
|
+
consumed: 1,
|
|
761
|
+
lastTriggeredAt: triggeredAt,
|
|
762
|
+
endedReason: 'fired',
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule))
|
|
767
|
+
if (next === undefined) {
|
|
768
|
+
this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, triggeredAt)
|
|
669
769
|
return []
|
|
670
770
|
}
|
|
671
|
-
|
|
771
|
+
const consumed = schedule.runCount + 1
|
|
772
|
+
const budget = scheduleRunBudget(schedule)
|
|
773
|
+
const spent = budget !== undefined && consumed >= budget
|
|
774
|
+
return this.startCascade(task, triggeredAt, undefined, false, {
|
|
775
|
+
consumed: 1,
|
|
776
|
+
nextRunAt: spent ? undefined : next,
|
|
777
|
+
lastTriggeredAt: triggeredAt,
|
|
778
|
+
...(spent ? { endedReason: 'limit' as const } : {}),
|
|
779
|
+
})
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Record a due occurrence that opened no execution: roll a recurring rule to
|
|
784
|
+
* its next occurrence (budget untouched), or stop it when there is none. A
|
|
785
|
+
* one-shot has no second occurrence, so it always stops here with the reason.
|
|
786
|
+
*/
|
|
787
|
+
private skipOccurrence(taskId: string, reason: ScheduleStopReason, now: number): void {
|
|
788
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
789
|
+
const schedule = task?.schedule
|
|
790
|
+
if (schedule === undefined) return
|
|
791
|
+
if (schedule.mode === 'once') {
|
|
792
|
+
this.progressSchedule(taskId, { consumed: 0, endedReason: reason }, now)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? now, scheduleZone(schedule))
|
|
796
|
+
if (next === undefined) this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, now)
|
|
797
|
+
else this.progressSchedule(taskId, { consumed: 0, nextRunAt: next, skippedReason: reason }, now)
|
|
672
798
|
}
|
|
673
799
|
|
|
800
|
+
/** Apply a schedule-occurrence transition and persist it atomically. */
|
|
801
|
+
private progressSchedule(taskId: string, progress: ScheduleProgress, now: number): void {
|
|
802
|
+
this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)]
|
|
803
|
+
this.commit()
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Refund the one scheduled run an occurrence consumed before its launch
|
|
808
|
+
* failed without creating a session (see
|
|
809
|
+
* {@link applyScheduleRefund}). Called by the service on the failed-launch
|
|
810
|
+
* path only; a manual run and a launch that did reach a session are never
|
|
811
|
+
* refunded. A no-op for a task with no rule, so a failed cascade member does
|
|
812
|
+
* not touch anything.
|
|
813
|
+
* @param taskId - the task whose occurrence is being refunded.
|
|
814
|
+
* @param now - clock instant (ms epoch).
|
|
815
|
+
*/
|
|
816
|
+
refundScheduledOccurrence(taskId: string, now: number): void {
|
|
817
|
+
const before = this.document.tasks.find(item => item.id === taskId)?.schedule
|
|
818
|
+
if (before === undefined) return
|
|
819
|
+
const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone())
|
|
820
|
+
const after = next.find(item => item.id === taskId)?.schedule
|
|
821
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return
|
|
822
|
+
this.document.tasks = [...next]
|
|
823
|
+
this.commit()
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Boot / resume recovery: skip every occurrence that came due while the board
|
|
828
|
+
* was not running through it. A recurring rule rolls to its next future
|
|
829
|
+
* target; a one-shot whose instant has passed is stopped as missed, because
|
|
830
|
+
* there is no later occurrence left to run. Rendering the missed occurrence
|
|
831
|
+
* is deliberately not attempted: the ACL of a card that fired hours ago is
|
|
832
|
+
* stale, and the board's own recovery contract is "missed triggers are
|
|
833
|
+
* skipped, never replayed".
|
|
834
|
+
*/
|
|
674
835
|
skipMissed(now: number): void {
|
|
675
836
|
let changed = false
|
|
676
837
|
this.document.tasks = this.document.tasks.map(task => {
|
|
677
838
|
const schedule = task.schedule
|
|
678
839
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
679
840
|
changed = true
|
|
680
|
-
|
|
841
|
+
if (schedule.mode === 'once') {
|
|
842
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
|
|
843
|
+
}
|
|
844
|
+
const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
|
|
845
|
+
if (next === undefined) {
|
|
846
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
847
|
+
}
|
|
848
|
+
return withSchedule(task, { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }, now)
|
|
681
849
|
})
|
|
682
850
|
if (changed) this.commit()
|
|
683
851
|
}
|
|
@@ -782,7 +950,9 @@ export class HostTaskLedger {
|
|
|
782
950
|
const sources = new Set(this.document.scheduler.importedSources ?? [])
|
|
783
951
|
if (sources.has(action.sourceId)) return { state: this.state() }
|
|
784
952
|
const invalidScheduleIds = action.tasks
|
|
785
|
-
.filter(task => task.schedule !== undefined
|
|
953
|
+
.filter(task => task.schedule !== undefined
|
|
954
|
+
&& task.schedule.mode !== 'once'
|
|
955
|
+
&& !isValidCron(task.schedule.cron ?? ''))
|
|
786
956
|
.map(task => task.id)
|
|
787
957
|
const incoming = parseHostTasks(action.tasks)
|
|
788
958
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
@@ -803,15 +973,14 @@ export class HostTaskLedger {
|
|
|
803
973
|
}
|
|
804
974
|
case 'create': {
|
|
805
975
|
if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|| nextRunAtMs(action.input.schedule.cron, now, action.input.schedule.timeZone ?? timeZone()) === undefined)) {
|
|
976
|
+
const requested = action.input.schedule
|
|
977
|
+
if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) {
|
|
809
978
|
throw new Error('invalid schedule')
|
|
810
979
|
}
|
|
811
980
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
812
981
|
? action.input
|
|
813
982
|
: { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } }
|
|
814
|
-
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone())
|
|
983
|
+
const result = applyCreateTask(this.document.tasks, input, now, action.id, this.maxSubtaskDepth, timeZone(), this.cardDefaults())
|
|
815
984
|
if (result.task === undefined) throw new Error(result.error ?? 'invalid task')
|
|
816
985
|
this.document.tasks = [...result.tasks]
|
|
817
986
|
break
|
|
@@ -873,6 +1042,37 @@ export class HostTaskLedger {
|
|
|
873
1042
|
this.document.tasks = [...result.tasks]
|
|
874
1043
|
break
|
|
875
1044
|
}
|
|
1045
|
+
case 'record-external-outcome': {
|
|
1046
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
1047
|
+
if (task === undefined) throw new Error('task not found')
|
|
1048
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
1049
|
+
// The one guardrail that matters: while the Host still owns a run it is
|
|
1050
|
+
// the only authority on the card, so an outside agent may not write the
|
|
1051
|
+
// terminal verdict over it. This is also what keeps a stale report from
|
|
1052
|
+
// racing the session it claims to have replaced.
|
|
1053
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot receive an external outcome')
|
|
1054
|
+
const execution: ExecutionRecord = {
|
|
1055
|
+
// A fresh id, exactly like a Host-launched run: the external work is a
|
|
1056
|
+
// new attempt in the history, never an overwrite of an earlier record.
|
|
1057
|
+
id: crypto.randomUUID(),
|
|
1058
|
+
sessionId: undefined,
|
|
1059
|
+
startedAt: now,
|
|
1060
|
+
endedAt: now,
|
|
1061
|
+
result: action.result,
|
|
1062
|
+
error: action.summary,
|
|
1063
|
+
initiatedBy: action.initiatedBy,
|
|
1064
|
+
external: true,
|
|
1065
|
+
}
|
|
1066
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId
|
|
1067
|
+
? item
|
|
1068
|
+
: {
|
|
1069
|
+
...item,
|
|
1070
|
+
status: settledStatus(item, action.result),
|
|
1071
|
+
updatedAt: now,
|
|
1072
|
+
executions: retainRecentExecutions([...item.executions, execution]),
|
|
1073
|
+
})
|
|
1074
|
+
break
|
|
1075
|
+
}
|
|
876
1076
|
case 'settle': {
|
|
877
1077
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
878
1078
|
if (task === undefined) throw new Error('task not found')
|
|
@@ -901,6 +1101,32 @@ export class HostTaskLedger {
|
|
|
901
1101
|
this.finalizeReadyRuns(false)
|
|
902
1102
|
break
|
|
903
1103
|
}
|
|
1104
|
+
case 'reset-verification': {
|
|
1105
|
+
// The explicit user reset of the acceptance anomaly counter (issue
|
|
1106
|
+
// #1828). It clears the attempts the ENVIRONMENT produced — timeouts,
|
|
1107
|
+
// authentication failures, unresolvable routes, and attempts the time
|
|
1108
|
+
// budget ended — and nothing else: a quality verdict is the board's own
|
|
1109
|
+
// judgement of the work and survives every reset. Only the open
|
|
1110
|
+
// execution is addressable, because the reset exists to unblock a
|
|
1111
|
+
// completion claim on a run that is still going.
|
|
1112
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
1113
|
+
if (task === undefined) throw new Error('task not found')
|
|
1114
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
1115
|
+
const execution = [...task.executions].reverse().find(entry => entry.endedAt === undefined)
|
|
1116
|
+
if (execution === undefined) throw new Error('task has no open execution')
|
|
1117
|
+
const verification = execution.verification
|
|
1118
|
+
if (verification === undefined || verification.applicability !== 'enforced') {
|
|
1119
|
+
throw new Error('this execution is not gated by task acceptance')
|
|
1120
|
+
}
|
|
1121
|
+
const cleared = withoutAcceptanceAnomalies(verification)
|
|
1122
|
+
if (cleared === undefined) throw new Error('this execution has no acceptance anomaly to reset')
|
|
1123
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId ? item : {
|
|
1124
|
+
...item,
|
|
1125
|
+
updatedAt: now,
|
|
1126
|
+
executions: item.executions.map(entry => entry.id === execution.id ? { ...entry, verification: cleared } : entry),
|
|
1127
|
+
})
|
|
1128
|
+
break
|
|
1129
|
+
}
|
|
904
1130
|
case 'restore': {
|
|
905
1131
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
906
1132
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -957,6 +1183,31 @@ export class HostTaskLedger {
|
|
|
957
1183
|
return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
|
|
958
1184
|
}
|
|
959
1185
|
|
|
1186
|
+
/**
|
|
1187
|
+
* Whether a creation-time schedule request is usable. A one-shot must carry
|
|
1188
|
+
* a whole-millisecond instant in the future; a recurring rule must carry a
|
|
1189
|
+
* valid expression with a reachable occurrence and, when capped, a positive
|
|
1190
|
+
* whole run budget. An unusable zone is refused rather than reinterpreted as
|
|
1191
|
+
* the Host zone.
|
|
1192
|
+
*/
|
|
1193
|
+
private validScheduleRequest(request: {
|
|
1194
|
+
readonly enabled: boolean
|
|
1195
|
+
readonly mode?: ScheduleMode
|
|
1196
|
+
readonly cron?: string
|
|
1197
|
+
readonly at?: number
|
|
1198
|
+
readonly timeZone?: string
|
|
1199
|
+
readonly maxRuns?: number
|
|
1200
|
+
}, now: number): boolean {
|
|
1201
|
+
if (request.timeZone !== undefined && !isValidTimeZone(request.timeZone)) return false
|
|
1202
|
+
if (request.mode === 'once') {
|
|
1203
|
+
return typeof request.at === 'number' && Number.isInteger(request.at) && request.at > now
|
|
1204
|
+
}
|
|
1205
|
+
const cron = (request.cron ?? '').trim()
|
|
1206
|
+
if (cron === '' || !isValidCron(cron)) return false
|
|
1207
|
+
if (request.maxRuns !== undefined && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false
|
|
1208
|
+
return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== undefined
|
|
1209
|
+
}
|
|
1210
|
+
|
|
960
1211
|
/**
|
|
961
1212
|
* The binding that makes a run illegal, if any.
|
|
962
1213
|
*
|
|
@@ -1017,17 +1268,20 @@ export class HostTaskLedger {
|
|
|
1017
1268
|
}
|
|
1018
1269
|
|
|
1019
1270
|
/**
|
|
1020
|
-
* Open one execution per cascade participant
|
|
1021
|
-
* on-board descendants, within the depth limit) under
|
|
1022
|
-
* commit the ledger once.
|
|
1023
|
-
*
|
|
1271
|
+
* Open one execution per cascade participant that still has work (the
|
|
1272
|
+
* requested task and its on-board descendants, within the depth limit) under
|
|
1273
|
+
* a single run group, and commit the ledger once. A descendant in 'done' is
|
|
1274
|
+
* not a participant ({@link joinsCascadeRun}). The returned runs are ordered
|
|
1275
|
+
* root-first, so the Host launches the parent before its subtasks.
|
|
1024
1276
|
* @param root - the task the user ran.
|
|
1025
1277
|
* @param now - clock instant (ms epoch).
|
|
1026
1278
|
* @param initiator - the DSH session that asked for the run (audit only).
|
|
1027
1279
|
* @param rerun - true to reset the root to 'todo' before starting it.
|
|
1028
|
-
* @param
|
|
1280
|
+
* @param progress - when set, this run was a due occurrence and the root's
|
|
1281
|
+
* rule is advanced (counter, next target, stop/skip record) in the same
|
|
1282
|
+
* atomic commit that opened the executions.
|
|
1029
1283
|
*/
|
|
1030
|
-
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false,
|
|
1284
|
+
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, progress?: ScheduleProgress): OpenedRun[] {
|
|
1031
1285
|
const before = this.document.tasks
|
|
1032
1286
|
const groupId = crypto.randomUUID()
|
|
1033
1287
|
// A team-mode root runs as the Team Lead: every other member of the same
|
|
@@ -1038,6 +1292,11 @@ export class HostTaskLedger {
|
|
|
1038
1292
|
const runs: OpenedRun[] = []
|
|
1039
1293
|
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
1040
1294
|
if (task.archivedAt !== undefined) continue
|
|
1295
|
+
// A subtask that already sits in 'done' is left alone: running the parent
|
|
1296
|
+
// repeats the parent, never the work a subtask has already finished. The
|
|
1297
|
+
// rule is the card's column, so it holds for a manual run, a re-run and a
|
|
1298
|
+
// due occurrence alike.
|
|
1299
|
+
if (!joinsCascadeRun(task, root.id)) continue
|
|
1041
1300
|
if (hasOpenExecution(task)) continue
|
|
1042
1301
|
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
1043
1302
|
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
@@ -1050,7 +1309,7 @@ export class HostTaskLedger {
|
|
|
1050
1309
|
}
|
|
1051
1310
|
if (runs.length === 0) return []
|
|
1052
1311
|
let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
|
|
1053
|
-
if (
|
|
1312
|
+
if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
|
|
1054
1313
|
this.document.tasks = [...tasks]
|
|
1055
1314
|
this.commit()
|
|
1056
1315
|
return runs
|
|
@@ -1181,6 +1440,14 @@ export class HostTaskLedger {
|
|
|
1181
1440
|
return changed
|
|
1182
1441
|
}
|
|
1183
1442
|
|
|
1443
|
+
/**
|
|
1444
|
+
* Re-align every armed rule at load/import. A recurring rule is recomputed
|
|
1445
|
+
* from the current instant; a one-shot is armed at its planned instant when
|
|
1446
|
+
* that is still in the future, and stopped as missed once it has passed
|
|
1447
|
+
* (there is no later occurrence to run). A recurring rule recomputed past an
|
|
1448
|
+
* occurrence records the skip, so the board can show that a fire was passed
|
|
1449
|
+
* over rather than silently rewinding.
|
|
1450
|
+
*/
|
|
1184
1451
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
1185
1452
|
const now = this.now()
|
|
1186
1453
|
let changed = false
|
|
@@ -1188,15 +1455,32 @@ export class HostTaskLedger {
|
|
|
1188
1455
|
const schedule = task.schedule
|
|
1189
1456
|
if (schedule === undefined || !schedule.enabled) return task
|
|
1190
1457
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
1191
|
-
|
|
1458
|
+
if (schedule.mode === 'once') {
|
|
1459
|
+
const at = schedule.at
|
|
1460
|
+
if (at === undefined) {
|
|
1461
|
+
changed = true
|
|
1462
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
1463
|
+
}
|
|
1464
|
+
if (at <= now) {
|
|
1465
|
+
changed = true
|
|
1466
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
|
|
1467
|
+
}
|
|
1468
|
+
if (schedule.nextRunAt === at) return task
|
|
1469
|
+
changed = true
|
|
1470
|
+
return withSchedule(task, { nextRunAt: at }, now)
|
|
1471
|
+
}
|
|
1472
|
+
const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
|
|
1192
1473
|
if (next === undefined) {
|
|
1193
1474
|
changed = true
|
|
1194
1475
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
1195
|
-
return
|
|
1476
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
1196
1477
|
}
|
|
1197
|
-
|
|
1478
|
+
const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
|
|
1479
|
+
if (schedule.nextRunAt === next && !passed) return task
|
|
1198
1480
|
changed = true
|
|
1199
|
-
return
|
|
1481
|
+
return withSchedule(task, passed
|
|
1482
|
+
? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
|
|
1483
|
+
: { nextRunAt: next }, now)
|
|
1200
1484
|
})
|
|
1201
1485
|
if (changed && persist) this.commit()
|
|
1202
1486
|
}
|
|
@@ -1245,15 +1529,23 @@ export class HostTaskLedger {
|
|
|
1245
1529
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
1246
1530
|
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
1247
1531
|
}
|
|
1532
|
+
// Only generations before the stored zone was introduced (plus the one that
|
|
1533
|
+
// introduced it) get the Host zone stamped: a v5 rule either carries its own
|
|
1534
|
+
// zone or deliberately follows the Host, and re-stamping it would freeze it
|
|
1535
|
+
// to the zone in effect at this load.
|
|
1536
|
+
const stampZone = typeof parsed.schemaVersion === 'number'
|
|
1537
|
+
&& parsed.schemaVersion <= TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION
|
|
1248
1538
|
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1249
1539
|
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1250
|
-
const rows =
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1540
|
+
const rows = stampZone
|
|
1541
|
+
? (parsed.tasks as readonly unknown[]).map((value) => {
|
|
1542
|
+
const row = value as { schedule?: unknown }
|
|
1543
|
+
const schedule = row.schedule
|
|
1544
|
+
if (typeof schedule !== 'object' || schedule === null) return value
|
|
1545
|
+
if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
|
|
1546
|
+
return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
|
|
1547
|
+
})
|
|
1548
|
+
: parsed.tasks
|
|
1257
1549
|
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1258
1550
|
}
|
|
1259
1551
|
|
|
@@ -1339,8 +1631,14 @@ export class HostTaskLedger {
|
|
|
1339
1631
|
if (typeof value !== 'object' || value === null) return []
|
|
1340
1632
|
const row = value as { id?: unknown; schedule?: unknown }
|
|
1341
1633
|
if (typeof row.schedule !== 'object' || row.schedule === null) return []
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1634
|
+
const rule = row.schedule as { cron?: unknown; mode?: unknown; at?: unknown }
|
|
1635
|
+
// A one-shot has no cron: its instant is what must be usable.
|
|
1636
|
+
if (rule.mode === 'once') {
|
|
1637
|
+
return typeof rule.at === 'number' && Number.isFinite(rule.at)
|
|
1638
|
+
? []
|
|
1639
|
+
: [typeof row.id === 'string' ? row.id : 'unknown']
|
|
1640
|
+
}
|
|
1641
|
+
return typeof rule.cron !== 'string' || !isValidCron(rule.cron)
|
|
1344
1642
|
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
1345
1643
|
: []
|
|
1346
1644
|
})
|