@linxin666/dsh-client-ui-task-board 0.4.4 → 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 +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -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 +4 -1
- 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/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -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 +46 -11
- 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/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- 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-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -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 +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- 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 +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -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/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- 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 +105 -10
- 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/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- 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, 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,11 +18,13 @@ 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
|
+
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, TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts'
|
|
26
|
+
import { withoutAcceptanceAnomalies, 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') {
|
|
@@ -96,7 +98,10 @@ export interface OpenExecutionReference {
|
|
|
96
98
|
/** Minimal value copy used by the Host scheduler. */
|
|
97
99
|
export interface DueScheduleReference {
|
|
98
100
|
readonly taskId: string
|
|
99
|
-
|
|
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
|
|
100
105
|
/** Zone this rule's wall clock is read in. */
|
|
101
106
|
readonly timeZone: string
|
|
102
107
|
readonly nextRunAt: number
|
|
@@ -106,6 +111,24 @@ export interface DueScheduleReference {
|
|
|
106
111
|
export interface LedgerRuntimeView {
|
|
107
112
|
readonly armedSchedules: number
|
|
108
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
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
const MAX_REQUEST_CACHE = 256
|
|
@@ -123,12 +146,25 @@ function scheduleZone(schedule: { timeZone?: string }): string {
|
|
|
123
146
|
return schedule.timeZone ?? timeZone()
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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]
|
|
132
168
|
}
|
|
133
169
|
|
|
134
170
|
/**
|
|
@@ -341,7 +377,17 @@ function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord {
|
|
|
341
377
|
const previous = byId.get(entry.id)
|
|
342
378
|
byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry))
|
|
343
379
|
}
|
|
344
|
-
|
|
380
|
+
// Acceptance evidence is the one field "betterExecution" cannot choose: a
|
|
381
|
+
// browser half that also wrote this execution carries no attempts, and taking
|
|
382
|
+
// its copy would erase the Host's verdict. The copy with the most recorded
|
|
383
|
+
// attempts wins, and one execution's verdict is never copied onto another.
|
|
384
|
+
const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt).map(entry => {
|
|
385
|
+
if (entry.verification !== undefined) return entry
|
|
386
|
+
const richer = [...a.executions, ...b.executions]
|
|
387
|
+
.filter(other => other.id === entry.id && other.verification !== undefined)
|
|
388
|
+
.sort((x, y) => (y.verification?.attempts.length ?? 0) - (x.verification?.attempts.length ?? 0))[0]
|
|
389
|
+
return richer?.verification === undefined ? entry : { ...entry, verification: richer.verification }
|
|
390
|
+
})
|
|
345
391
|
return { ...newer, executions: retainRecentExecutions(executions) }
|
|
346
392
|
}
|
|
347
393
|
|
|
@@ -389,17 +435,24 @@ function parseHostTasks(values: readonly unknown[]): TaskRecord[] {
|
|
|
389
435
|
const rawSchedule = rawById.get(task.id)?.schedule
|
|
390
436
|
if (typeof rawSchedule !== 'object' || rawSchedule === null) return task
|
|
391
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
|
|
392
441
|
if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task
|
|
393
442
|
return {
|
|
394
443
|
...task,
|
|
395
444
|
schedule: {
|
|
396
445
|
enabled: false,
|
|
446
|
+
mode: 'cron',
|
|
397
447
|
cron: schedule.cron,
|
|
398
448
|
...(typeof schedule.timeZone === 'string' && isValidTimeZone(schedule.timeZone) ? { timeZone: schedule.timeZone } : {}),
|
|
399
449
|
nextRunAt: undefined,
|
|
400
450
|
lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt)
|
|
401
451
|
? schedule.lastTriggeredAt
|
|
402
452
|
: undefined,
|
|
453
|
+
runCount: typeof schedule.runCount === 'number' && Number.isInteger(schedule.runCount) && schedule.runCount >= 0
|
|
454
|
+
? schedule.runCount
|
|
455
|
+
: 0,
|
|
403
456
|
},
|
|
404
457
|
}
|
|
405
458
|
})
|
|
@@ -416,8 +469,18 @@ export class HostTaskLedger {
|
|
|
416
469
|
/** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
|
|
417
470
|
readonly schedulerFile: string
|
|
418
471
|
|
|
419
|
-
/**
|
|
420
|
-
readonly
|
|
472
|
+
/** Resolves the baseline the confirmation gate compares a binding against. */
|
|
473
|
+
private readonly sessionDefault: () => TaskPermission
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Session-default permission the confirmation gate compares against. Read
|
|
477
|
+
* through the resolver on every use, so a baseline that follows the Host's
|
|
478
|
+
* own default preset tracks a Settings change made while the board runs.
|
|
479
|
+
*/
|
|
480
|
+
get sessionDefaultPermission(): TaskPermission {
|
|
481
|
+
return this.sessionDefault()
|
|
482
|
+
}
|
|
483
|
+
|
|
421
484
|
/**
|
|
422
485
|
* Deployment subtask depth limit (1..3): the lineage gate every write obeys.
|
|
423
486
|
* The settings card edits it live, so {@link setMaxSubtaskDepth} mutates it
|
|
@@ -425,8 +488,10 @@ export class HostTaskLedger {
|
|
|
425
488
|
*/
|
|
426
489
|
private depthLimit: number
|
|
427
490
|
|
|
428
|
-
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission; maxSubtaskDepth?: number } = {}) {
|
|
429
|
-
|
|
491
|
+
constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission | (() => TaskPermission); maxSubtaskDepth?: number } = {}) {
|
|
492
|
+
// Resolved before the load/repair passes below: they can evaluate the gate.
|
|
493
|
+
const baseline = options.sessionDefaultPermission
|
|
494
|
+
this.sessionDefault = typeof baseline === 'function' ? baseline : () => baseline ?? DEFAULT_SESSION_PERMISSION
|
|
430
495
|
this.depthLimit = normalizeSubtaskDepth(options.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH)
|
|
431
496
|
mkdirSync(dir, { recursive: true })
|
|
432
497
|
this.file = join(dir, 'ledger-v2.json')
|
|
@@ -497,16 +562,17 @@ export class HostTaskLedger {
|
|
|
497
562
|
|
|
498
563
|
state(): LedgerState {
|
|
499
564
|
const { revision, scheduler } = this.summary()
|
|
500
|
-
return { revision, tasks:
|
|
565
|
+
return { revision, tasks: tasksForRead(this.document.tasks), scheduler }
|
|
501
566
|
}
|
|
502
567
|
|
|
503
568
|
/**
|
|
504
|
-
* Runtime-only projection for the
|
|
569
|
+
* Runtime-only projection for the Host poll. It copies just primitive
|
|
505
570
|
* identifiers and timestamps, never the complete task/execution history or
|
|
506
571
|
* an authoritative mutable object from the ledger.
|
|
507
572
|
*/
|
|
508
573
|
runtimeView(): LedgerRuntimeView {
|
|
509
574
|
let armedSchedules = 0
|
|
575
|
+
let runningCards = 0
|
|
510
576
|
// Run groups opened by a team-mode card: every other member of those groups
|
|
511
577
|
// runs as a teammate inside that card's Lead session.
|
|
512
578
|
const teamGroups = new Set<string>()
|
|
@@ -517,10 +583,13 @@ export class HostTaskLedger {
|
|
|
517
583
|
}
|
|
518
584
|
}
|
|
519
585
|
const openExecutions: OpenExecutionReference[] = []
|
|
586
|
+
const openSessionIds: string[] = []
|
|
520
587
|
for (const task of this.document.tasks) {
|
|
521
588
|
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
589
|
+
if (task.status === 'running') runningCards += 1
|
|
522
590
|
for (const execution of task.executions) {
|
|
523
591
|
if (execution.endedAt !== undefined) continue
|
|
592
|
+
if (execution.sessionId !== undefined) openSessionIds.push(execution.sessionId)
|
|
524
593
|
// A deferred cascade parent already knows its own outcome; the monitor
|
|
525
594
|
// has nothing left to inspect, and its children's settles finalize it.
|
|
526
595
|
if (execution.ownResult !== undefined) continue
|
|
@@ -535,7 +604,12 @@ export class HostTaskLedger {
|
|
|
535
604
|
})
|
|
536
605
|
}
|
|
537
606
|
}
|
|
538
|
-
return {
|
|
607
|
+
return {
|
|
608
|
+
armedSchedules,
|
|
609
|
+
openExecutions,
|
|
610
|
+
openSessionIds,
|
|
611
|
+
needsSessionState: openExecutions.length > 0 || runningCards > 0,
|
|
612
|
+
}
|
|
539
613
|
}
|
|
540
614
|
|
|
541
615
|
/** Count armed, non-archived schedules without cloning task histories. */
|
|
@@ -574,7 +648,13 @@ export class HostTaskLedger {
|
|
|
574
648
|
if (task.archivedAt !== undefined) continue
|
|
575
649
|
const schedule = task.schedule
|
|
576
650
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
577
|
-
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
|
+
})
|
|
578
658
|
}
|
|
579
659
|
return due
|
|
580
660
|
}
|
|
@@ -626,38 +706,140 @@ export class HostTaskLedger {
|
|
|
626
706
|
/**
|
|
627
707
|
* Open the cascade one due schedule triggers: an empty array means nothing
|
|
628
708
|
* ran (already running, or a participant whose elevated permission is still
|
|
629
|
-
* 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.
|
|
630
718
|
*/
|
|
631
|
-
openScheduled(taskId: string,
|
|
719
|
+
openScheduled(taskId: string, triggeredAt: number): OpenedRun[] {
|
|
632
720
|
const task = this.document.tasks.find(item => item.id === taskId)
|
|
633
721
|
if (task === undefined || task.archivedAt !== undefined) return []
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
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 []
|
|
637
734
|
}
|
|
735
|
+
|
|
638
736
|
const refusal = this.bindingRefusal(task)
|
|
639
737
|
if (refusal !== undefined) {
|
|
640
738
|
// An unconfirmed above-default permission must never run unattended: the
|
|
641
|
-
// whole tree is refused and the
|
|
642
|
-
// 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.
|
|
643
741
|
this.document.scheduler.error = `scheduled run refused for task ${taskId}: ${bindingRefusalMessage(refusal, this.sessionDefaultPermission, 'schedule')}`
|
|
644
|
-
|
|
742
|
+
this.skipOccurrence(taskId, 'permission', triggeredAt)
|
|
645
743
|
return []
|
|
646
744
|
}
|
|
647
|
-
if (
|
|
648
|
-
|
|
745
|
+
if (hasOpenExecution(task)) {
|
|
746
|
+
this.skipOccurrence(taskId, 'busy', triggeredAt)
|
|
649
747
|
return []
|
|
650
748
|
}
|
|
651
|
-
|
|
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
|
+
})
|
|
652
774
|
}
|
|
653
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()
|
|
818
|
+
}
|
|
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
|
+
*/
|
|
654
829
|
skipMissed(now: number): void {
|
|
655
830
|
let changed = false
|
|
656
831
|
this.document.tasks = this.document.tasks.map(task => {
|
|
657
832
|
const schedule = task.schedule
|
|
658
833
|
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task
|
|
659
834
|
changed = true
|
|
660
|
-
|
|
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)
|
|
661
843
|
})
|
|
662
844
|
if (changed) this.commit()
|
|
663
845
|
}
|
|
@@ -736,6 +918,24 @@ export class HostTaskLedger {
|
|
|
736
918
|
this.commit()
|
|
737
919
|
}
|
|
738
920
|
|
|
921
|
+
allTasks(): readonly TaskRecord[] {
|
|
922
|
+
return this.document.tasks
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
getTask(id: string): TaskRecord | undefined {
|
|
926
|
+
return this.document.tasks.find(item => item.id === id)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
saveTaskRecord(task: TaskRecord): void {
|
|
930
|
+
const exists = this.document.tasks.some(item => item.id === task.id)
|
|
931
|
+
if (exists) {
|
|
932
|
+
this.document.tasks = this.document.tasks.map(item => item.id === task.id ? task : item)
|
|
933
|
+
} else {
|
|
934
|
+
this.document.tasks = [...this.document.tasks, task]
|
|
935
|
+
}
|
|
936
|
+
this.commit()
|
|
937
|
+
}
|
|
938
|
+
|
|
739
939
|
private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; runs?: OpenedRun[] } {
|
|
740
940
|
const now = this.now()
|
|
741
941
|
let runs: OpenedRun[] | undefined
|
|
@@ -744,7 +944,9 @@ export class HostTaskLedger {
|
|
|
744
944
|
const sources = new Set(this.document.scheduler.importedSources ?? [])
|
|
745
945
|
if (sources.has(action.sourceId)) return { state: this.state() }
|
|
746
946
|
const invalidScheduleIds = action.tasks
|
|
747
|
-
.filter(task => task.schedule !== undefined
|
|
947
|
+
.filter(task => task.schedule !== undefined
|
|
948
|
+
&& task.schedule.mode !== 'once'
|
|
949
|
+
&& !isValidCron(task.schedule.cron ?? ''))
|
|
748
950
|
.map(task => task.id)
|
|
749
951
|
const incoming = parseHostTasks(action.tasks)
|
|
750
952
|
const merged = new Map(this.document.tasks.map(task => [task.id, task]))
|
|
@@ -765,9 +967,8 @@ export class HostTaskLedger {
|
|
|
765
967
|
}
|
|
766
968
|
case 'create': {
|
|
767
969
|
if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists')
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|| 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)) {
|
|
771
972
|
throw new Error('invalid schedule')
|
|
772
973
|
}
|
|
773
974
|
const input = action.input.freeze === undefined || initiator === undefined || initiator === ''
|
|
@@ -801,7 +1002,7 @@ export class HostTaskLedger {
|
|
|
801
1002
|
case 'delete': {
|
|
802
1003
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
803
1004
|
if (task === undefined) throw new Error('task not found')
|
|
804
|
-
if (
|
|
1005
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be deleted')
|
|
805
1006
|
// Subtasks keep their link: deleting the parent would leave dangling
|
|
806
1007
|
// children, so the user detaches or deletes them explicitly first.
|
|
807
1008
|
if (this.document.tasks.some(item => item.parentId === action.taskId)) {
|
|
@@ -820,7 +1021,10 @@ export class HostTaskLedger {
|
|
|
820
1021
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
821
1022
|
if (task === undefined) throw new Error('task not found')
|
|
822
1023
|
if (task.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
823
|
-
|
|
1024
|
+
// The lock belongs to an open execution, not to the column: a card
|
|
1025
|
+
// parked in 'running' by hand has no session and stays movable, while
|
|
1026
|
+
// a card the runner is executing cannot be moved out from under it.
|
|
1027
|
+
if (hasOpenExecution(task)) throw new Error('running task cannot be moved')
|
|
824
1028
|
if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status')
|
|
825
1029
|
this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item)
|
|
826
1030
|
break
|
|
@@ -832,6 +1036,37 @@ export class HostTaskLedger {
|
|
|
832
1036
|
this.document.tasks = [...result.tasks]
|
|
833
1037
|
break
|
|
834
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
|
+
}
|
|
835
1070
|
case 'settle': {
|
|
836
1071
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
837
1072
|
if (task === undefined) throw new Error('task not found')
|
|
@@ -860,6 +1095,32 @@ export class HostTaskLedger {
|
|
|
860
1095
|
this.finalizeReadyRuns(false)
|
|
861
1096
|
break
|
|
862
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
|
+
}
|
|
863
1124
|
case 'restore': {
|
|
864
1125
|
if (this.subtreeHasOpenExecution(action.taskId)) throw new Error('running task cannot be restored')
|
|
865
1126
|
const result = applyRestoreTask(this.document.tasks, action.taskId, now, this.maxSubtaskDepth)
|
|
@@ -876,6 +1137,18 @@ export class HostTaskLedger {
|
|
|
876
1137
|
: item)
|
|
877
1138
|
break
|
|
878
1139
|
}
|
|
1140
|
+
case 'rename-tag': {
|
|
1141
|
+
const result = applyRenameTag(this.document.tasks, action.from, action.to, now)
|
|
1142
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
1143
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
1144
|
+
break
|
|
1145
|
+
}
|
|
1146
|
+
case 'delete-tag': {
|
|
1147
|
+
const result = applyDeleteTag(this.document.tasks, action.name, now)
|
|
1148
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
1149
|
+
if (result.changed) this.document.tasks = [...result.tasks]
|
|
1150
|
+
break
|
|
1151
|
+
}
|
|
879
1152
|
case 'set-schedule': {
|
|
880
1153
|
const task = this.document.tasks.find(task => task.id === action.taskId)
|
|
881
1154
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
@@ -888,7 +1161,7 @@ export class HostTaskLedger {
|
|
|
888
1161
|
case 'run': {
|
|
889
1162
|
const task = this.document.tasks.find(item => item.id === action.taskId)
|
|
890
1163
|
if (task?.archivedAt !== undefined) throw new Error('archived task is read-only')
|
|
891
|
-
if (task === undefined ||
|
|
1164
|
+
if (task === undefined || hasOpenExecution(task)) throw new Error('task is already running or missing')
|
|
892
1165
|
// The confirmation gate judges the RESOLVED binding: a subtask that
|
|
893
1166
|
// inherits an elevated permission from its parent is exactly as
|
|
894
1167
|
// unconfirmed as the parent would be without its own stamp.
|
|
@@ -904,15 +1177,42 @@ export class HostTaskLedger {
|
|
|
904
1177
|
return { state: this.state(), ...(runs === undefined ? {} : { runs }) }
|
|
905
1178
|
}
|
|
906
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
|
+
|
|
907
1205
|
/**
|
|
908
1206
|
* The binding that makes a run illegal, if any.
|
|
909
1207
|
*
|
|
910
1208
|
* A plain cascade launches one session per participant, so every participant
|
|
911
1209
|
* carries its own resolved binding and each one gates the run. A team run
|
|
912
|
-
* launches only the Lead session: the Lead's binding gates it,
|
|
913
|
-
*
|
|
914
|
-
* refused
|
|
915
|
-
*
|
|
1210
|
+
* launches only the Lead session: the Lead's binding gates it, and a
|
|
1211
|
+
* teammate inherits that session's permission, so a subtask's OWN pin is
|
|
1212
|
+
* refused exactly when the Lead session does not already carry it — the
|
|
1213
|
+
* teammate could never be given that authority, and dropping the pin
|
|
1214
|
+
* silently would misreport the work (an inherited binding is the Lead's own
|
|
1215
|
+
* and stays allowed once the Lead is confirmed).
|
|
916
1216
|
* @param root - the task being run.
|
|
917
1217
|
* @returns the first refusal, or undefined when the run may start.
|
|
918
1218
|
*/
|
|
@@ -923,12 +1223,17 @@ export class HostTaskLedger {
|
|
|
923
1223
|
if (lead !== undefined && requiresPermissionConfirmation(lead, this.sessionDefaultPermission)) {
|
|
924
1224
|
return { kind: 'root', title: lead.title }
|
|
925
1225
|
}
|
|
926
|
-
// The
|
|
927
|
-
//
|
|
1226
|
+
// The Lead session runs at the Lead's own binding, or at the deployment
|
|
1227
|
+
// default when it pins none; a teammate inherits that permission and
|
|
1228
|
+
// cannot be narrowed below it, so it carries a subtask pin when it is
|
|
1229
|
+
// already at least as wide. The raw record carries the subtask's OWN
|
|
1230
|
+
// binding: the resolved participant above already folded an inherited one
|
|
1231
|
+
// into its permission.
|
|
1232
|
+
const leadPermission = effectivePermission(lead ?? root) ?? this.sessionDefaultPermission
|
|
928
1233
|
const pinned = participants.find(participant => {
|
|
929
1234
|
if (participant.id === root.id) return false
|
|
930
1235
|
const raw = this.document.tasks.find(task => task.id === participant.id)
|
|
931
|
-
return raw !== undefined &&
|
|
1236
|
+
return raw !== undefined && !permissionCarriedBy(leadPermission, effectivePermission(raw))
|
|
932
1237
|
})
|
|
933
1238
|
return pinned === undefined ? undefined : { kind: 'subtask-pin', title: pinned.title }
|
|
934
1239
|
}
|
|
@@ -947,14 +1252,13 @@ export class HostTaskLedger {
|
|
|
947
1252
|
return cascadeTargets(this.document.tasks, rootId, this.maxSubtaskDepth)
|
|
948
1253
|
.map(participant => resolveExecutionTargets(participant, this.document.tasks, this.maxSubtaskDepth))
|
|
949
1254
|
.filter(participant => participant.archivedAt === undefined
|
|
950
|
-
&& participant.status !== 'running'
|
|
951
1255
|
&& !hasOpenExecution(participant))
|
|
952
1256
|
}
|
|
953
1257
|
|
|
954
1258
|
/** Whether a task or any of its subtasks still has an open execution. */
|
|
955
1259
|
private subtreeHasOpenExecution(id: string): boolean {
|
|
956
1260
|
return cascadeTargets(this.document.tasks, id, this.maxSubtaskDepth)
|
|
957
|
-
.some(task =>
|
|
1261
|
+
.some(task => hasOpenExecution(task))
|
|
958
1262
|
}
|
|
959
1263
|
|
|
960
1264
|
/**
|
|
@@ -966,9 +1270,11 @@ export class HostTaskLedger {
|
|
|
966
1270
|
* @param now - clock instant (ms epoch).
|
|
967
1271
|
* @param initiator - the DSH session that asked for the run (audit only).
|
|
968
1272
|
* @param rerun - true to reset the root to 'todo' before starting it.
|
|
969
|
-
* @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.
|
|
970
1276
|
*/
|
|
971
|
-
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false,
|
|
1277
|
+
private startCascade(root: TaskRecord, now: number, initiator?: string, rerun = false, progress?: ScheduleProgress): OpenedRun[] {
|
|
972
1278
|
const before = this.document.tasks
|
|
973
1279
|
const groupId = crypto.randomUUID()
|
|
974
1280
|
// A team-mode root runs as the Team Lead: every other member of the same
|
|
@@ -979,7 +1285,7 @@ export class HostTaskLedger {
|
|
|
979
1285
|
const runs: OpenedRun[] = []
|
|
980
1286
|
for (const task of cascadeTargets(before, root.id, this.maxSubtaskDepth)) {
|
|
981
1287
|
if (task.archivedAt !== undefined) continue
|
|
982
|
-
if (
|
|
1288
|
+
if (hasOpenExecution(task)) continue
|
|
983
1289
|
const base = rerun && task.id === root.id ? withStatus(task, 'todo', now) : task
|
|
984
1290
|
const opened = startExecution(base, now, crypto.randomUUID(), initiator, groupId)
|
|
985
1291
|
started.set(task.id, opened.task)
|
|
@@ -991,7 +1297,7 @@ export class HostTaskLedger {
|
|
|
991
1297
|
}
|
|
992
1298
|
if (runs.length === 0) return []
|
|
993
1299
|
let tasks: readonly TaskRecord[] = before.map(task => started.get(task.id) ?? task)
|
|
994
|
-
if (
|
|
1300
|
+
if (progress !== undefined) tasks = applyScheduleProgress(tasks, root.id, progress, now)
|
|
995
1301
|
this.document.tasks = [...tasks]
|
|
996
1302
|
this.commit()
|
|
997
1303
|
return runs
|
|
@@ -1122,6 +1428,14 @@ export class HostTaskLedger {
|
|
|
1122
1428
|
return changed
|
|
1123
1429
|
}
|
|
1124
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
|
+
*/
|
|
1125
1439
|
private repairSchedules(skipPast: boolean, persist = true): void {
|
|
1126
1440
|
const now = this.now()
|
|
1127
1441
|
let changed = false
|
|
@@ -1129,15 +1443,32 @@ export class HostTaskLedger {
|
|
|
1129
1443
|
const schedule = task.schedule
|
|
1130
1444
|
if (schedule === undefined || !schedule.enabled) return task
|
|
1131
1445
|
if (!skipPast && schedule.nextRunAt !== undefined) return task
|
|
1132
|
-
|
|
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))
|
|
1133
1461
|
if (next === undefined) {
|
|
1134
1462
|
changed = true
|
|
1135
1463
|
this.document.scheduler.error = `invalid cron disabled for task: ${task.id}`
|
|
1136
|
-
return
|
|
1464
|
+
return withSchedule(task, { enabled: false, nextRunAt: undefined, endedAt: now, endedReason: 'no-target' }, now)
|
|
1137
1465
|
}
|
|
1138
|
-
|
|
1466
|
+
const passed = schedule.nextRunAt !== undefined && schedule.nextRunAt <= now
|
|
1467
|
+
if (schedule.nextRunAt === next && !passed) return task
|
|
1139
1468
|
changed = true
|
|
1140
|
-
return
|
|
1469
|
+
return withSchedule(task, passed
|
|
1470
|
+
? { nextRunAt: next, skippedAt: now, skippedReason: 'missed' }
|
|
1471
|
+
: { nextRunAt: next }, now)
|
|
1141
1472
|
})
|
|
1142
1473
|
if (changed && persist) this.commit()
|
|
1143
1474
|
}
|
|
@@ -1147,7 +1478,6 @@ export class HostTaskLedger {
|
|
|
1147
1478
|
let changed = false
|
|
1148
1479
|
const interrupted: Array<{ taskId: string; runGroupId: string | undefined }> = []
|
|
1149
1480
|
this.document.tasks = this.document.tasks.map(task => {
|
|
1150
|
-
if (task.status !== 'running') return task
|
|
1151
1481
|
const execution = task.executions.at(-1)
|
|
1152
1482
|
if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task
|
|
1153
1483
|
changed = true
|
|
@@ -1166,7 +1496,7 @@ export class HostTaskLedger {
|
|
|
1166
1496
|
}
|
|
1167
1497
|
|
|
1168
1498
|
/**
|
|
1169
|
-
* Field-preserving migration of
|
|
1499
|
+
* Field-preserving migration of an older document. It first proves every task
|
|
1170
1500
|
* row is structurally valid, so a document that would silently drop or coerce
|
|
1171
1501
|
* rows fails loudly instead (no quarantined-empty restart), and then reuses
|
|
1172
1502
|
* the current normalization.
|
|
@@ -1177,23 +1507,84 @@ export class HostTaskLedger {
|
|
|
1177
1507
|
* (the stored `nextRunAt` already encodes the old zone), but the rule stops
|
|
1178
1508
|
* following a later `TZ` change, which is what made an existing schedule
|
|
1179
1509
|
* silently move when the Host's zone changed.
|
|
1510
|
+
*
|
|
1511
|
+
* v5 adds the per-execution acceptance block. The migration deliberately does
|
|
1512
|
+
* NOT stamp one: an execution opened before v5 keeps no contract, so it
|
|
1513
|
+
* settles on its own historical verdict and no in-flight run is retroactively
|
|
1514
|
+
* judged by a gate that was never armed for it.
|
|
1180
1515
|
*/
|
|
1181
1516
|
private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument {
|
|
1182
1517
|
if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) {
|
|
1183
1518
|
throw new Error(`v${String(parsed.schemaVersion)} document contains structurally invalid task rows`)
|
|
1184
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
|
|
1185
1526
|
// Every row passed `isTaskRecord` above, so the stamped rows are still
|
|
1186
1527
|
// task records; the cast only re-narrows the unknown-typed on-disk array.
|
|
1187
|
-
const rows =
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
1194
1537
|
return this.normalizeDocument({ ...parsed, tasks: rows as TaskRecord[] })
|
|
1195
1538
|
}
|
|
1196
1539
|
|
|
1540
|
+
/**
|
|
1541
|
+
* Find the still-open execution that ran in one session, with its task.
|
|
1542
|
+
*
|
|
1543
|
+
* The completion gate keys on the session id its tool call arrives from: the
|
|
1544
|
+
* execution record is the only object binding a session to a card, and an
|
|
1545
|
+
* already-settled execution is deliberately not returned (a session the board
|
|
1546
|
+
* no longer owns must not be gated).
|
|
1547
|
+
* @param sessionId - the DSH session the tool call runs in.
|
|
1548
|
+
* @returns the open execution and its task, or undefined.
|
|
1549
|
+
*/
|
|
1550
|
+
findOpenExecutionBySession(sessionId: string): { task: TaskRecord; execution: ExecutionRecord } | undefined {
|
|
1551
|
+
if (sessionId === '') return undefined
|
|
1552
|
+
for (const task of this.document.tasks) {
|
|
1553
|
+
for (const execution of task.executions) {
|
|
1554
|
+
if (execution.endedAt !== undefined) continue
|
|
1555
|
+
if (execution.sessionId === sessionId) return { task, execution }
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return undefined
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Replace one execution's acceptance block (creating it when absent).
|
|
1563
|
+
*
|
|
1564
|
+
* A no-op when the serialized value is unchanged, so a coarse caller that
|
|
1565
|
+
* re-writes the same state never bumps the ledger revision. A settled
|
|
1566
|
+
* execution keeps its record: the report of a finished cycle must survive,
|
|
1567
|
+
* and only the board's own settlement moves the card.
|
|
1568
|
+
* @param taskId - the task owning the execution.
|
|
1569
|
+
* @param executionId - the execution to update.
|
|
1570
|
+
* @param verification - the new block.
|
|
1571
|
+
* @returns true when the ledger changed.
|
|
1572
|
+
*/
|
|
1573
|
+
setVerification(taskId: string, executionId: string, verification: ExecutionVerification): boolean {
|
|
1574
|
+
const now = this.now()
|
|
1575
|
+
const task = this.document.tasks.find(item => item.id === taskId)
|
|
1576
|
+
const execution = task?.executions.find(entry => entry.id === executionId)
|
|
1577
|
+
if (task === undefined || execution === undefined) return false
|
|
1578
|
+
if (JSON.stringify(execution.verification ?? null) === JSON.stringify(verification)) return false
|
|
1579
|
+
this.document.tasks = this.document.tasks.map(item => item.id !== taskId ? item : {
|
|
1580
|
+
...item,
|
|
1581
|
+
updatedAt: now,
|
|
1582
|
+
executions: item.executions.map(entry => entry.id === executionId ? { ...entry, verification } : entry),
|
|
1583
|
+
})
|
|
1584
|
+
this.commit()
|
|
1585
|
+
return true
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1197
1588
|
private load(dir: string): LedgerDocument {
|
|
1198
1589
|
const existed = existsSync(this.file)
|
|
1199
1590
|
// schemaVersion stays unknown-typed here: on-disk documents may be v2 or
|
|
@@ -1204,8 +1595,8 @@ export class HostTaskLedger {
|
|
|
1204
1595
|
} catch (error) {
|
|
1205
1596
|
return this.recoverCorrupt(dir, existed, error)
|
|
1206
1597
|
}
|
|
1207
|
-
if (parsed.schemaVersion ===
|
|
1208
|
-
|
|
1598
|
+
if (typeof parsed.schemaVersion === 'number'
|
|
1599
|
+
&& TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS.includes(parsed.schemaVersion)) {
|
|
1209
1600
|
try {
|
|
1210
1601
|
return this.migrateLegacyDocument(parsed)
|
|
1211
1602
|
} catch (error) {
|
|
@@ -1228,8 +1619,14 @@ export class HostTaskLedger {
|
|
|
1228
1619
|
if (typeof value !== 'object' || value === null) return []
|
|
1229
1620
|
const row = value as { id?: unknown; schedule?: unknown }
|
|
1230
1621
|
if (typeof row.schedule !== 'object' || row.schedule === null) return []
|
|
1231
|
-
const
|
|
1232
|
-
|
|
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)
|
|
1233
1630
|
? [typeof row.id === 'string' ? row.id : 'unknown']
|
|
1234
1631
|
: []
|
|
1235
1632
|
})
|
|
@@ -1426,4 +1823,4 @@ export class HostTaskLedger {
|
|
|
1426
1823
|
}
|
|
1427
1824
|
throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`)
|
|
1428
1825
|
}
|
|
1429
|
-
}
|
|
1826
|
+
}
|