@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.6
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 +20 -12
- package/README.zh.md +20 -12
- package/lib/client.js +1327 -229
- package/lib/client.js.map +1 -1
- package/lib/index.js +1909 -348
- package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +65 -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/controller.d.ts +10 -11
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +144 -14
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- 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 +82 -7
- package/lib/types/host-runner.d.ts +32 -9
- package/lib/types/host-service.d.ts +117 -9
- package/lib/types/index.d.ts +54 -0
- package/lib/types/protocol.d.ts +50 -12
- package/package.json +2 -6
- package/src/client/TaskBoardSettingsCard.tsx +74 -1
- package/src/client/board/NewTaskModal.tsx +151 -20
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +219 -18
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/status-key.ts +3 -0
- package/src/client/index.ts +43 -50
- package/src/client/locales.ts +137 -4
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/core/controller.ts +12 -8
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +64 -15
- package/src/core/tasks.ts +193 -39
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +3 -3
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +106 -21
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +328 -42
- package/src/host-runner.ts +52 -19
- package/src/host-service.ts +299 -36
- package/src/index.ts +101 -5
- package/src/protocol.ts +132 -28
- 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,7 +5,7 @@ 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,
|
|
@@ -18,12 +18,12 @@ import {
|
|
|
18
18
|
import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts'
|
|
19
19
|
import { applyCreateTask } from './core/use-cases/task-create.ts'
|
|
20
20
|
import { applyDeleteTask } from './core/use-cases/task-delete.ts'
|
|
21
|
-
import { applySetSchedule,
|
|
21
|
+
import { applySetSchedule, applyScheduleProgress, applyScheduleRefund, type ScheduleProgress } from './core/use-cases/task-schedule.ts'
|
|
22
22
|
import { applySetParent } from './core/use-cases/task-parent.ts'
|
|
23
23
|
import { applyDeleteTag, applyRenameTag } from './core/use-cases/task-tag.ts'
|
|
24
24
|
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
|
|
25
|
+
import { TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS, TASK_BOARD_SCHEMA_VERSION, TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
26
|
+
import { withoutAcceptanceAnomalies, type ExecutionVerification } from './core/verification.ts'
|
|
27
27
|
import { DEFAULT_SESSION_PERMISSION, effectivePermission, permissionCarriedBy, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts'
|
|
28
28
|
|
|
29
29
|
interface PersistedScheduler extends TaskBoardSchedulerSnapshot {
|
|
@@ -98,7 +98,10 @@ export interface OpenExecutionReference {
|
|
|
98
98
|
/** Minimal value copy used by the Host scheduler. */
|
|
99
99
|
export interface DueScheduleReference {
|
|
100
100
|
readonly taskId: string
|
|
101
|
-
|
|
101
|
+
/** Whether this due rule is a recurring cron rule or a single planned instant. */
|
|
102
|
+
readonly mode: ScheduleMode
|
|
103
|
+
/** Cron expression of a recurring rule; absent on a one-shot. */
|
|
104
|
+
readonly cron?: string
|
|
102
105
|
/** Zone this rule's wall clock is read in. */
|
|
103
106
|
readonly timeZone: string
|
|
104
107
|
readonly nextRunAt: number
|
|
@@ -108,6 +111,24 @@ export interface DueScheduleReference {
|
|
|
108
111
|
export interface LedgerRuntimeView {
|
|
109
112
|
readonly armedSchedules: number
|
|
110
113
|
readonly openExecutions: readonly OpenExecutionReference[]
|
|
114
|
+
/**
|
|
115
|
+
* Session ids of every open execution, including the ones
|
|
116
|
+
* {@link openExecutions} skips because their own outcome is already
|
|
117
|
+
* recorded. A caller that asks whether the board still observes any session
|
|
118
|
+
* must use this list: a deferred cascade parent is settled by its members,
|
|
119
|
+
* but it is still an open execution.
|
|
120
|
+
*/
|
|
121
|
+
readonly openSessionIds: readonly string[]
|
|
122
|
+
/**
|
|
123
|
+
* Whether this document holds anything the session roster can still decide:
|
|
124
|
+
* an open execution that needs inspecting, or a card in the running column
|
|
125
|
+
* whose verdict may arrive from a settle this process never saw.
|
|
126
|
+
*
|
|
127
|
+
* A board with no running card, no open execution and no armed schedule has
|
|
128
|
+
* no use for the roster at all, which is what lets the Host poll stand down
|
|
129
|
+
* instead of re-reading every persisted session forever.
|
|
130
|
+
*/
|
|
131
|
+
readonly needsSessionState: boolean
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
const MAX_REQUEST_CACHE = 256
|
|
@@ -125,8 +146,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
|
|
|
125
146
|
return schedule.timeZone ?? timeZone()
|
|
126
147
|
}
|
|
127
148
|
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The task collection one board read hands out.
|
|
151
|
+
*
|
|
152
|
+
* A read must not hand a consumer the ledger's own array: the state route, every
|
|
153
|
+
* action response and the agent tools all receive this value in-process, so an
|
|
154
|
+
* in-place rewrite of that array (splice, sort, push, `length = 0`) would edit
|
|
155
|
+
* the authoritative document from outside the write path. One level of copying
|
|
156
|
+
* is what that contract needs, and it is all it needs: every write in this class
|
|
157
|
+
* replaces the document's array (`this.document.tasks = ...`) and replaces the
|
|
158
|
+
* records it changes rather than mutating them, so the records a caller receives
|
|
159
|
+
* are stable values. Copying them as well only rebuilt the entire document on
|
|
160
|
+
* every board read - measured at 0.43 ms per read for 400 cards with 2
|
|
161
|
+
* executions each and 2.72 ms for 400 cards with 20 - and bought nothing a
|
|
162
|
+
* consumer relies on. Callers still must not mutate a record in place; that is
|
|
163
|
+
* what the shared record objects now make possible, and no consumer in this
|
|
164
|
+
* package does it.
|
|
165
|
+
*/
|
|
166
|
+
function tasksForRead(tasks: readonly TaskRecord[]): TaskRecord[] {
|
|
167
|
+
return [...tasks]
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
/**
|
|
@@ -397,17 +435,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
397
435
|
const rawSchedule = rawById.get(task.id)?.schedule
|
|
398
436
|
if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
|
|
399
437
|
const schedule = rawSchedule as Record<string, unknown>
|
|
438
|
+
// A one-shot carries no cron; its instant is validated by the wire gate and
|
|
439
|
+
// the store repair, so there is nothing to salvage here.
|
|
440
|
+
if (schedule.mode === 'once') return task
|
|
400
441
|
if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
|
|
401
442
|
return {
|
|
402
443
|
...task,
|
|
403
444
|
schedule: {
|
|
404
445
|
enabled: false,
|
|
446
|
+
mode: 'cron',
|
|
405
447
|
cron: schedule.cron,
|
|
406
448
|
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
407
449
|
nextRunAt: undefined,
|
|
408
450
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
409
451
|
? schedule.lastTriggeredAt
|
|
410
452
|
: undefined,
|
|
453
|
+
runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
|
|
454
|
+
? schedule.runCount
|
|
455
|
+
: 0,
|
|
411
456
|
},
|
|
412
457
|
}
|
|
413
458
|
})
|
|
@@ -517,16 +562,17 @@ export class HostTaskLedger {
|
|
|
517
562
|
|
|
518
563
|
state(): LedgerState {
|
|
519
564
|
const { revision, scheduler } = this.summary()
|
|
520
|
-
return { revision, tasks:
|
|
565
|
+
return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
|
|
521
566
|
}
|
|
522
567
|
|
|
523
568
|
/**
|
|
524
|
-
* Runtime-only projection for the
|
|
569
|
+
* Runtime-only projection for the Host poll. It copies just primitive
|
|
525
570
|
* identifiers and timestamps, never the complete task/execution history or
|
|
526
571
|
* an authoritative mutable object from the ledger.
|
|
527
572
|
*/
|
|
528
573
|
runtimeView(): LedgerRuntimeView {
|
|
529
574
|
let armedSchedules = 0
|
|
575
|
+
let runningCards = 0
|
|
530
576
|
// Run groups opened by a team-mode card: every other member of those groups
|
|
531
577
|
// runs as a teammate inside that card's Lead session.
|
|
532
578
|
const teamGroups = new Set<string>()
|
|
@@ -537,10 +583,13 @@ export class HostTaskLedger {
|
|
|
537
583
|
}
|
|
538
584
|
}
|
|
539
585
|
const openExecutions: OpenExecutionReference[] = []
|
|
586
|
+
const openSessionIds: string[] = []
|
|
540
587
|
for (const task of this.document.tasks) {
|
|
541
588
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
589
|
+
if (task.status === 'running') runningCards += 1
|
|
542
590
|
for (const execution of task.executions) {
|
|
543
591
|
if (execution.endedAt !== undefined) continue
|
|
592
|
+
if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
|
|
544
593
|
// A deferred cascade parent already knows its own outcome; the monitor
|
|
545
594
|
// has nothing left to inspect, and its children's settles finalize it.
|
|
546
595
|
if (execution.ownResult !== undefined) continue
|
|
@@ -555,7 +604,12 @@ export class HostTaskLedger {
|
|
|
555
604
|
})
|
|
556
605
|
}
|
|
557
606
|
}
|
|
558
|
-
return {
|
|
607
|
+
return {
|
|
608
|
+
armedSchedules,
|
|
609
|
+
openExecutions,
|
|
610
|
+
openSessionIds,
|
|
611
|
+
needsSessionState: openExecutions.length > 0 || runningCards > 0,
|
|
612
|
+
}
|
|
559
613
|
}
|
|
560
614
|
|
|
561
615
|
/** Count armed, non-archived schedules without cloning task histories. */
|
|
@@ -594,7 +648,13 @@ export class HostTaskLedger {
|
|
|
594
648
|
if (task.archivedAt !== undefined) continue
|
|
595
649
|
const schedule = task.schedule
|
|
596
650
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
597
|
-
due.push({
|
|
651
|
+
due.push({
|
|
652
|
+
taskId: task.id,
|
|
653
|
+
mode: schedule.mode,
|
|
654
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
655
|
+
timeZone: scheduleZone(schedule),
|
|
656
|
+
nextRunAt: schedule.nextRunAt,
|
|
657
|
+
})
|
|
598
658
|
}
|
|
599
659
|
return due
|
|
600
660
|
}
|
|
@@ -646,38 +706,140 @@ export class HostTaskLedger {
|
|
|
646
706
|
/**
|
|
647
707
|
* Open the cascade one due schedule triggers: an empty array means nothing
|
|
648
708
|
* ran (already running, or a participant whose elevated permission is still
|
|
649
|
-
* unconfirmed
|
|
709
|
+
* unconfirmed, a one-shot whose instant was missed, or an exhausted budget),
|
|
710
|
+
* and the rule's own state says which.
|
|
711
|
+
*
|
|
712
|
+
* The run budget is enforced HERE, inside the single ledger writer: a rule
|
|
713
|
+
* whose counter already reached its cap is stopped instead of opened, so no
|
|
714
|
+
* restart, repeated timer fire or re-arm can overrun it.
|
|
715
|
+
* @param taskId - the task whose rule came due.
|
|
716
|
+
* @param triggeredAt - the instant the occurrence came due.
|
|
717
|
+
* @returns the executions this occurrence opened.
|
|
650
718
|
*/
|
|
651
|
-
openScheduled(taskId: string,
|
|
719
|
+
openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
|
|
652
720
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
653
721
|
if (task === undefined || task.archivedAt !== undefined) return []
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
722
|
+
const schedule = task.schedule
|
|
723
|
+
if (schedule === undefined || !schedule.enabled) return []
|
|
724
|
+
|
|
725
|
+
// A rule that has spent its budget never opens another run. The stop is
|
|
726
|
+
// recorded here rather than trusted to the firing timer, so a rule that
|
|
727
|
+
// was hand-edited into an exhausted state still terminates visibly.
|
|
728
|
+
if (scheduleExhausted(schedule)) {
|
|
729
|
+
this.progressSchedule(taskId, {
|
|
730
|
+
consumed: 0,
|
|
731
|
+
endedReason: schedule.mode === 'once' ? 'fired' : 'limit',
|
|
732
|
+
}, triggeredAt)
|
|
733
|
+
return []
|
|
657
734
|
}
|
|
735
|
+
|
|
658
736
|
const refusal = this.bindingRefusal(task)
|
|
659
737
|
if (refusal !== undefined) {
|
|
660
738
|
// An unconfirmed above-default permission must never run unattended: the
|
|
661
|
-
// whole tree is refused and the
|
|
662
|
-
// exactly like the already-running refusal.
|
|
739
|
+
// whole tree is refused and the occurrence is skipped without consuming
|
|
740
|
+
// the run budget, exactly like the already-running refusal.
|
|
663
741
|
this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
|
|
664
|
-
|
|
742
|
+
this.skipOccurrence(taskId, 'permission', triggeredAt)
|
|
665
743
|
return []
|
|
666
744
|
}
|
|
667
745
|
if (hasOpenExecution(task)) {
|
|
668
|
-
|
|
746
|
+
this.skipOccurrence(taskId, 'busy', triggeredAt)
|
|
669
747
|
return []
|
|
670
748
|
}
|
|
671
|
-
|
|
749
|
+
|
|
750
|
+
if (schedule.mode === 'once') {
|
|
751
|
+
// The single planned instant is due: open its one run and stop the rule
|
|
752
|
+
// in the same commit, so the budget can never produce a second one.
|
|
753
|
+
return this.startCascade(task, triggeredAt, undefined, false, {
|
|
754
|
+
consumed: 1,
|
|
755
|
+
lastTriggeredAt: triggeredAt,
|
|
756
|
+
endedReason: 'fired',
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? triggeredAt, scheduleZone(schedule))
|
|
761
|
+
if (next === undefined) {
|
|
762
|
+
this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, triggeredAt)
|
|
763
|
+
return []
|
|
764
|
+
}
|
|
765
|
+
const consumed = schedule.runCount + 1
|
|
766
|
+
const budget = scheduleRunBudget(schedule)
|
|
767
|
+
const spent = budget !== undefined && consumed >= budget
|
|
768
|
+
return this.startCascade(task, triggeredAt, undefined, false, {
|
|
769
|
+
consumed: 1,
|
|
770
|
+
nextRunAt: spent ? undefined : next,
|
|
771
|
+
lastTriggeredAt: triggeredAt,
|
|
772
|
+
...(spent ? { endedReason: 'limit' as const } : {}),
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Record a due occurrence that opened no execution: roll a recurring rule to
|
|
778
|
+
* its next occurrence (budget untouched), or stop it when there is none. A
|
|
779
|
+
* one-shot has no second occurrence, so it always stops here with the reason.
|
|
780
|
+
*/
|
|
781
|
+
private skipOccurrence(taskId: string, reason: ScheduleStopReason, now: number): void {
|
|
782
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
783
|
+
const schedule = task?.schedule
|
|
784
|
+
if (schedule === undefined) return
|
|
785
|
+
if (schedule.mode === 'once') {
|
|
786
|
+
this.progressSchedule(taskId, { consumed: 0, endedReason: reason }, now)
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
const next = nextRunAtMs(schedule.cron ?? '', schedule.nextRunAt ?? now, scheduleZone(schedule))
|
|
790
|
+
if (next === undefined) this.progressSchedule(taskId, { consumed: 0, endedReason: 'no-target' }, now)
|
|
791
|
+
else this.progressSchedule(taskId, { consumed: 0, nextRunAt: next, skippedReason: reason }, now)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Apply a schedule-occurrence transition and persist it atomically. */
|
|
795
|
+
private progressSchedule(taskId: string, progress: ScheduleProgress, now: number): void {
|
|
796
|
+
this.document.tasks = [...applyScheduleProgress(this.document.tasks, taskId, progress, now)]
|
|
797
|
+
this.commit()
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Refund the one scheduled run an occurrence consumed before its launch
|
|
802
|
+
* failed without creating a session (see
|
|
803
|
+
* {@link applyScheduleRefund}). Called by the service on the failed-launch
|
|
804
|
+
* path only; a manual run and a launch that did reach a session are never
|
|
805
|
+
* refunded. A no-op for a task with no rule, so a failed cascade member does
|
|
806
|
+
* not touch anything.
|
|
807
|
+
* @param taskId - the task whose occurrence is being refunded.
|
|
808
|
+
* @param now - clock instant (ms epoch).
|
|
809
|
+
*/
|
|
810
|
+
refundScheduledOccurrence(taskId: string, now: number): void {
|
|
811
|
+
const before = this.document.tasks.find(item => item.id === taskId)?.schedule
|
|
812
|
+
if (before === undefined) return
|
|
813
|
+
const next = applyScheduleRefund(this.document.tasks, taskId, now, timeZone())
|
|
814
|
+
const after = next.find(item => item.id === taskId)?.schedule
|
|
815
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return
|
|
816
|
+
this.document.tasks = [...next]
|
|
817
|
+
this.commit()
|
|
672
818
|
}
|
|
673
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Boot / resume recovery: skip every occurrence that came due while the board
|
|
822
|
+
* was not running through it. A recurring rule rolls to its next future
|
|
823
|
+
* target; a one-shot whose instant has passed is stopped as missed, because
|
|
824
|
+
* there is no later occurrence left to run. Rendering the missed occurrence
|
|
825
|
+
* is deliberately not attempted: the ACL of a card that fired hours ago is
|
|
826
|
+
* stale, and the board's own recovery contract is "missed triggers are
|
|
827
|
+
* skipped, never replayed".
|
|
828
|
+
*/
|
|
674
829
|
skipMissed(now: number): void {
|
|
675
830
|
let changed = false
|
|
676
831
|
this.document.tasks = this.document.tasks.map(task => {
|
|
677
832
|
const schedule = task.schedule
|
|
678
833
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
679
834
|
changed = true
|
|
680
|
-
|
|
835
|
+
if (schedule.mode === 'once') {
|
|
836
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
|
|
837
|
+
}
|
|
838
|
+
const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
|
|
839
|
+
if (next === undefined) {
|
|
840
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
841
|
+
}
|
|
842
|
+
return withSchedule(task, { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }, now)
|
|
681
843
|
})
|
|
682
844
|
if (changed) this.commit()
|
|
683
845
|
}
|
|
@@ -782,7 +944,9 @@ export class HostTaskLedger {
|
|
|
782
944
|
const sources = new Set(this.document.scheduler.importedSources ?? [])
|
|
783
945
|
if (sources.has(action.sourceId)) return { state: this.state() }
|
|
784
946
|
const invalidScheduleIds = action.tasks
|
|
785
|
-
.filter(task => task.schedule !== undefined
|
|
947
|
+
.filter(task => task.schedule !== undefined
|
|
948
|
+
&& task.schedule.mode !== 'once'
|
|
949
|
+
&& !isValidCron(task.schedule.cron ?? ''))
|
|
786
950
|
.map(task => task.id)
|
|
787
951
|
const incoming = parseHostTasks(action.tasks)
|
|
788
952
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
@@ -803,9 +967,8 @@ export class HostTaskLedger {
|
|
|
803
967
|
}
|
|
804
968
|
case 'create': {
|
|
805
969
|
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)) {
|
|
970
|
+
const requested = action.input.schedule
|
|
971
|
+
if (requested?.enabled === true && !this.validScheduleRequest(requested, now)) {
|
|
809
972
|
throw new Error('invalid schedule')
|
|
810
973
|
}
|
|
811
974
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
@@ -873,6 +1036,37 @@ export class HostTaskLedger {
|
|
|
873
1036
|
this.document.tasks = [...result.tasks]
|
|
874
1037
|
break
|
|
875
1038
|
}
|
|
1039
|
+
case 'record-external-outcome': {
|
|
1040
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
1041
|
+
if (task === undefined) throw new Error('task not found')
|
|
1042
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
1043
|
+
// The one guardrail that matters: while the Host still owns a run it is
|
|
1044
|
+
// the only authority on the card, so an outside agent may not write the
|
|
1045
|
+
// terminal verdict over it. This is also what keeps a stale report from
|
|
1046
|
+
// racing the session it claims to have replaced.
|
|
1047
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot receive an external outcome')
|
|
1048
|
+
const execution: ExecutionRecord = {
|
|
1049
|
+
// A fresh id, exactly like a Host-launched run: the external work is a
|
|
1050
|
+
// new attempt in the history, never an overwrite of an earlier record.
|
|
1051
|
+
id: crypto.randomUUID(),
|
|
1052
|
+
sessionId: undefined,
|
|
1053
|
+
startedAt: now,
|
|
1054
|
+
endedAt: now,
|
|
1055
|
+
result: action.result,
|
|
1056
|
+
error: action.summary,
|
|
1057
|
+
initiatedBy: action.initiatedBy,
|
|
1058
|
+
external: true,
|
|
1059
|
+
}
|
|
1060
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId
|
|
1061
|
+
? item
|
|
1062
|
+
: {
|
|
1063
|
+
...item,
|
|
1064
|
+
status: settledStatus(item, action.result),
|
|
1065
|
+
updatedAt: now,
|
|
1066
|
+
executions: retainRecentExecutions([...item.executions, execution]),
|
|
1067
|
+
})
|
|
1068
|
+
break
|
|
1069
|
+
}
|
|
876
1070
|
case 'settle': {
|
|
877
1071
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
878
1072
|
if (task === undefined) throw new Error('task not found')
|
|
@@ -901,6 +1095,32 @@ export class HostTaskLedger {
|
|
|
901
1095
|
this.finalizeReadyRuns(false)
|
|
902
1096
|
break
|
|
903
1097
|
}
|
|
1098
|
+
case 'reset-verification': {
|
|
1099
|
+
// The explicit user reset of the acceptance anomaly counter (issue
|
|
1100
|
+
// #1828). It clears the attempts the ENVIRONMENT produced — timeouts,
|
|
1101
|
+
// authentication failures, unresolvable routes, and attempts the time
|
|
1102
|
+
// budget ended — and nothing else: a quality verdict is the board's own
|
|
1103
|
+
// judgement of the work and survives every reset. Only the open
|
|
1104
|
+
// execution is addressable, because the reset exists to unblock a
|
|
1105
|
+
// completion claim on a run that is still going.
|
|
1106
|
+
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
1107
|
+
if (task === undefined) throw new Error('task not found')
|
|
1108
|
+
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
1109
|
+
const execution = [...task.executions].reverse().find(entry => entry.endedAt === undefined)
|
|
1110
|
+
if (execution === undefined) throw new Error('task has no open execution')
|
|
1111
|
+
const verification = execution.verification
|
|
1112
|
+
if (verification === undefined || verification.applicability !== 'enforced') {
|
|
1113
|
+
throw new Error('this execution is not gated by task acceptance')
|
|
1114
|
+
}
|
|
1115
|
+
const cleared = withoutAcceptanceAnomalies(verification)
|
|
1116
|
+
if (cleared === undefined) throw new Error('this execution has no acceptance anomaly to reset')
|
|
1117
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== action.taskId ? item : {
|
|
1118
|
+
...item,
|
|
1119
|
+
updatedAt: now,
|
|
1120
|
+
executions: item.executions.map(entry => entry.id === execution.id ? { ...entry, verification: cleared } : entry),
|
|
1121
|
+
})
|
|
1122
|
+
break
|
|
1123
|
+
}
|
|
904
1124
|
case 'restore': {
|
|
905
1125
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
906
1126
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -957,6 +1177,31 @@ export class HostTaskLedger {
|
|
|
957
1177
|
return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
|
|
958
1178
|
}
|
|
959
1179
|
|
|
1180
|
+
/**
|
|
1181
|
+
* Whether a creation-time schedule request is usable. A one-shot must carry
|
|
1182
|
+
* a whole-millisecond instant in the future; a recurring rule must carry a
|
|
1183
|
+
* valid expression with a reachable occurrence and, when capped, a positive
|
|
1184
|
+
* whole run budget. An unusable zone is refused rather than reinterpreted as
|
|
1185
|
+
* the Host zone.
|
|
1186
|
+
*/
|
|
1187
|
+
private validScheduleRequest(request: {
|
|
1188
|
+
readonly enabled: boolean
|
|
1189
|
+
readonly mode?: ScheduleMode
|
|
1190
|
+
readonly cron?: string
|
|
1191
|
+
readonly at?: number
|
|
1192
|
+
readonly timeZone?: string
|
|
1193
|
+
readonly maxRuns?: number
|
|
1194
|
+
}, now: number): boolean {
|
|
1195
|
+
if (request.timeZone !== undefined && !isValidTimeZone(request.timeZone)) return false
|
|
1196
|
+
if (request.mode === 'once') {
|
|
1197
|
+
return typeof request.at === 'number' && Number.isInteger(request.at) && request.at > now
|
|
1198
|
+
}
|
|
1199
|
+
const cron = (request.cron ?? '').trim()
|
|
1200
|
+
if (cron === '' || !isValidCron(cron)) return false
|
|
1201
|
+
if (request.maxRuns !== undefined && (!Number.isInteger(request.maxRuns) || request.maxRuns < 1)) return false
|
|
1202
|
+
return nextRunAtMs(cron, now, request.timeZone ?? timeZone()) !== undefined
|
|
1203
|
+
}
|
|
1204
|
+
|
|
960
1205
|
/**
|
|
961
1206
|
* The binding that makes a run illegal, if any.
|
|
962
1207
|
*
|
|
@@ -1025,9 +1270,11 @@ export class HostTaskLedger {
|
|
|
1025
1270
|
* @param now - clock instant (ms epoch).
|
|
1026
1271
|
* @param initiator - the DSH session that asked for the run (audit only).
|
|
1027
1272
|
* @param rerun - true to reset the root to 'todo' before starting it.
|
|
1028
|
-
* @param
|
|
1273
|
+
* @param progress - when set, this run was a due occurrence and the root's
|
|
1274
|
+
* rule is advanced (counter, next target, stop/skip record) in the same
|
|
1275
|
+
* atomic commit that opened the executions.
|
|
1029
1276
|
*/
|
|
1030
|
-
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false,
|
|
1277
|
+
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, progress?: ScheduleProgress): OpenedRun[] {
|
|
1031
1278
|
const before = this.document.tasks
|
|
1032
1279
|
const groupId = crypto.randomUUID()
|
|
1033
1280
|
// A team-mode root runs as the Team Lead: every other member of the same
|
|
@@ -1050,7 +1297,7 @@ export class HostTaskLedger {
|
|
|
1050
1297
|
}
|
|
1051
1298
|
if (runs.length === 0) return []
|
|
1052
1299
|
let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
|
|
1053
|
-
if (
|
|
1300
|
+
if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
|
|
1054
1301
|
this.document.tasks = [...tasks]
|
|
1055
1302
|
this.commit()
|
|
1056
1303
|
return runs
|
|
@@ -1181,6 +1428,14 @@ export class HostTaskLedger {
|
|
|
1181
1428
|
return changed
|
|
1182
1429
|
}
|
|
1183
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Re-align every armed rule at load/import. A recurring rule is recomputed
|
|
1433
|
+
* from the current instant; a one-shot is armed at its planned instant when
|
|
1434
|
+
* that is still in the future, and stopped as missed once it has passed
|
|
1435
|
+
* (there is no later occurrence to run). A recurring rule recomputed past an
|
|
1436
|
+
* occurrence records the skip, so the board can show that a fire was passed
|
|
1437
|
+
* over rather than silently rewinding.
|
|
1438
|
+
*/
|
|
1184
1439
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
1185
1440
|
const now = this.now()
|
|
1186
1441
|
let changed = false
|
|
@@ -1188,15 +1443,32 @@ export class HostTaskLedger {
|
|
|
1188
1443
|
const schedule = task.schedule
|
|
1189
1444
|
if (schedule === undefined || !schedule.enabled) return task
|
|
1190
1445
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
1191
|
-
|
|
1446
|
+
if (schedule.mode === 'once') {
|
|
1447
|
+
const at = schedule.at
|
|
1448
|
+
if (at === undefined) {
|
|
1449
|
+
changed = true
|
|
1450
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
1451
|
+
}
|
|
1452
|
+
if (at <= now) {
|
|
1453
|
+
changed = true
|
|
1454
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'missed' }, now)
|
|
1455
|
+
}
|
|
1456
|
+
if (schedule.nextRunAt === at) return task
|
|
1457
|
+
changed = true
|
|
1458
|
+
return withSchedule(task, { nextRunAt: at }, now)
|
|
1459
|
+
}
|
|
1460
|
+
const next = nextRunAtMs(schedule.cron ?? '', now, scheduleZone(schedule))
|
|
1192
1461
|
if (next === undefined) {
|
|
1193
1462
|
changed = true
|
|
1194
1463
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
1195
|
-
return
|
|
1464
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
1196
1465
|
}
|
|
1197
|
-
|
|
1466
|
+
const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
|
|
1467
|
+
if (schedule.nextRunAt === next && !passed) return task
|
|
1198
1468
|
changed = true
|
|
1199
|
-
return
|
|
1469
|
+
return withSchedule(task, passed
|
|
1470
|
+
? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
|
|
1471
|
+
: { nextRunAt: next }, now)
|
|
1200
1472
|
})
|
|
1201
1473
|
if (changed && persist) this.commit()
|
|
1202
1474
|
}
|
|
@@ -1245,15 +1517,23 @@ export class HostTaskLedger {
|
|
|
1245
1517
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
1246
1518
|
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
1247
1519
|
}
|
|
1520
|
+
// Only generations before the stored zone was introduced (plus the one that
|
|
1521
|
+
// introduced it) get the Host zone stamped: a v5 rule either carries its own
|
|
1522
|
+
// zone or deliberately follows the Host, and re-stamping it would freeze it
|
|
1523
|
+
// to the zone in effect at this load.
|
|
1524
|
+
const stampZone = typeof parsed.schemaVersion === 'number'
|
|
1525
|
+
&& parsed.schemaVersion <= TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION
|
|
1248
1526
|
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1249
1527
|
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1250
|
-
const rows =
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1528
|
+
const rows = stampZone
|
|
1529
|
+
? (parsed.tasks as readonly unknown[]).map((value) => {
|
|
1530
|
+
const row = value as { schedule?: unknown }
|
|
1531
|
+
const schedule = row.schedule
|
|
1532
|
+
if (typeof schedule !== 'object' || schedule === null) return value
|
|
1533
|
+
if (typeof (schedule as { timeZone?: unknown }).timeZone === 'string') return value
|
|
1534
|
+
return { ...(value as object), schedule: { ...(schedule as object), timeZone: timeZone() } }
|
|
1535
|
+
})
|
|
1536
|
+
: parsed.tasks
|
|
1257
1537
|
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1258
1538
|
}
|
|
1259
1539
|
|
|
@@ -1339,8 +1619,14 @@ export class HostTaskLedger {
|
|
|
1339
1619
|
if (typeof value !== 'object' || value === null) return []
|
|
1340
1620
|
const row = value as { id?: unknown; schedule?: unknown }
|
|
1341
1621
|
if (typeof row.schedule !== 'object' || row.schedule === null) return []
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1622
|
+
const rule = row.schedule as { cron?: unknown; mode?: unknown; at?: unknown }
|
|
1623
|
+
// A one-shot has no cron: its instant is what must be usable.
|
|
1624
|
+
if (rule.mode === 'once') {
|
|
1625
|
+
return typeof rule.at === 'number' && Number.isFinite(rule.at)
|
|
1626
|
+
? []
|
|
1627
|
+
: [typeof row.id === 'string' ? row.id : 'unknown']
|
|
1628
|
+
}
|
|
1629
|
+
return typeof rule.cron !== 'string' || !isValidCron(rule.cron)
|
|
1344
1630
|
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
1345
1631
|
: []
|
|
1346
1632
|
})
|