@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roster-poll cadence: the one recurring Host timer the board holds.
|
|
3
|
+
*
|
|
4
|
+
* The cadence is a user setting (seconds), so the bounds and the seconds to
|
|
5
|
+
* milliseconds conversion live here rather than in either half: the settings
|
|
6
|
+
* card validates a draft against the same range the Host clamps a hand-edited
|
|
7
|
+
* configuration to, and a programmatic mount that passes no cadence gets the
|
|
8
|
+
* default.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-task-board/core/poll-cadence
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Cadence used when neither the settings document nor the mount names one. */
|
|
14
|
+
export const DEFAULT_SESSION_POLL_SECONDS = 5
|
|
15
|
+
|
|
16
|
+
/** Shortest accepted cadence: a faster poll only burns CPU on the Host. */
|
|
17
|
+
export const SESSION_POLL_MIN_SECONDS = 1
|
|
18
|
+
|
|
19
|
+
/** Longest accepted cadence, five minutes. */
|
|
20
|
+
export const SESSION_POLL_MAX_SECONDS = 300
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clamp a configured cadence into the supported range. A value the schema
|
|
24
|
+
* would refuse (non-finite, below the floor, above the ceiling) still leaves
|
|
25
|
+
* the board polling: an out-of-range setting never disables the timer.
|
|
26
|
+
* @param seconds - configured cadence in seconds, or undefined for the default.
|
|
27
|
+
* @returns the effective cadence in seconds.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeSessionPollSeconds(seconds: number | undefined): number {
|
|
30
|
+
if (seconds === undefined || !Number.isFinite(seconds)) return DEFAULT_SESSION_POLL_SECONDS
|
|
31
|
+
return Math.min(Math.max(Math.trunc(seconds), SESSION_POLL_MIN_SECONDS), SESSION_POLL_MAX_SECONDS)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The cadence in the milliseconds the Host timer face takes.
|
|
36
|
+
* @param seconds - configured cadence in seconds, or undefined for the default.
|
|
37
|
+
* @returns the effective cadence in milliseconds.
|
|
38
|
+
*/
|
|
39
|
+
export function sessionPollMs(seconds: number | undefined): number {
|
|
40
|
+
return normalizeSessionPollSeconds(seconds) * 1_000
|
|
41
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron, isValidTimeZone } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { SCHEDULE_STOP_REASONS, isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type ScheduleStopReason, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import { isExecutionOutcome } from './subtask.ts'
|
|
15
15
|
import type { TaskHandover } from './handover.ts'
|
|
16
16
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
@@ -87,6 +87,10 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
87
87
|
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
88
88
|
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
89
89
|
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
90
|
+
// An external-outcome marker is optional in BOTH directions: a ledger
|
|
91
|
+
// written before the field existed carries none, and a non-boolean value
|
|
92
|
+
// is dropped by the repair below rather than trusted as provenance.
|
|
93
|
+
if (entry.external !== undefined && typeof entry.external !== 'boolean') return false
|
|
90
94
|
// The acceptance block is repaired like every other optional field: an
|
|
91
95
|
// unusable one is dropped, which can only make the board MORE strict.
|
|
92
96
|
if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
|
|
@@ -104,29 +108,73 @@ function normalizeStatus(status: unknown): TaskStatus {
|
|
|
104
108
|
return isTaskStatus(status) ? status : 'todo'
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/** A persisted ms timestamp, kept only when it is a usable finite number. */
|
|
112
|
+
function optionalInstant(value: unknown): number | undefined {
|
|
113
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A persisted positive-integer run cap, or undefined for unlimited/repair. */
|
|
117
|
+
function optionalRunCap(value: unknown): number | undefined {
|
|
118
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A persisted stop reason, kept only when it names a reason this build knows. */
|
|
122
|
+
function optionalStopReason(value: unknown): ScheduleStopReason | undefined {
|
|
123
|
+
return typeof value === 'string' && SCHEDULE_STOP_REASONS.includes(value as ScheduleStopReason)
|
|
124
|
+
? value as ScheduleStopReason
|
|
125
|
+
: undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
/**
|
|
108
|
-
* Repair a persisted schedule rule
|
|
109
|
-
*
|
|
110
|
-
*
|
|
129
|
+
* Repair a persisted schedule rule. Both modes are repaired field by field and
|
|
130
|
+
* a rule that carries no usable plan is dropped (the task row survives):
|
|
131
|
+
* a recurring rule without a well-formed 5-field cron, or a one-shot without a
|
|
132
|
+
* finite planned instant. A rule written before `mode` existed is a cron rule.
|
|
111
133
|
*/
|
|
112
134
|
function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
113
135
|
if (typeof schedule !== 'object' || schedule === null) return undefined
|
|
114
136
|
const rule = schedule as Record<string, unknown>
|
|
137
|
+
// A stored zone survives only when this runtime can resolve it; an unusable
|
|
138
|
+
// name is dropped (the rule then follows the Host zone) instead of being
|
|
139
|
+
// kept to fail every later resolve.
|
|
140
|
+
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
141
|
+
const runCountValue = rule.runCount
|
|
142
|
+
const runCount = typeof runCountValue === 'number' && Number.isInteger(runCountValue) && runCountValue >= 0
|
|
143
|
+
? runCountValue
|
|
144
|
+
: 0
|
|
145
|
+
const endedAt = optionalInstant(rule.endedAt)
|
|
146
|
+
const endedReason = optionalStopReason(rule.endedReason)
|
|
147
|
+
const skippedAt = optionalInstant(rule.skippedAt)
|
|
148
|
+
const skippedReason = optionalStopReason(rule.skippedReason)
|
|
149
|
+
const common = {
|
|
150
|
+
enabled: rule.enabled === true,
|
|
151
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
152
|
+
nextRunAt: optionalInstant(rule.nextRunAt),
|
|
153
|
+
lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
|
|
154
|
+
runCount,
|
|
155
|
+
...(endedAt === undefined ? {} : { endedAt }),
|
|
156
|
+
...(endedReason === undefined ? {} : { endedReason }),
|
|
157
|
+
...(skippedAt === undefined ? {} : { skippedAt }),
|
|
158
|
+
...(skippedReason === undefined ? {} : { skippedReason }),
|
|
159
|
+
}
|
|
160
|
+
if (rule.mode === 'once') {
|
|
161
|
+
const at = optionalInstant(rule.at)
|
|
162
|
+
// A one-shot with no instant can never fire; drop it rather than keep a
|
|
163
|
+
// rule the scheduler would have to interpret.
|
|
164
|
+
if (at === undefined) return undefined
|
|
165
|
+
return { ...common, mode: 'once', at }
|
|
166
|
+
}
|
|
115
167
|
// Reject (drop) a schedule whose cron is not a well-formed 5-field
|
|
116
168
|
// expression: a malformed rule would otherwise linger as a never-firing
|
|
117
169
|
// schedule instead of being dropped for later repair.
|
|
118
170
|
if (typeof rule.cron !== 'string') return undefined
|
|
119
171
|
if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
|
|
120
|
-
|
|
121
|
-
// name is dropped (the rule then follows the Host zone) instead of being
|
|
122
|
-
// kept to fail every later resolve.
|
|
123
|
-
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
172
|
+
const maxRuns = optionalRunCap(rule.maxRuns)
|
|
124
173
|
return {
|
|
125
|
-
|
|
174
|
+
...common,
|
|
175
|
+
mode: 'cron',
|
|
126
176
|
cron: rule.cron,
|
|
127
|
-
...(
|
|
128
|
-
nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
|
|
129
|
-
lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
|
|
177
|
+
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
130
178
|
}
|
|
131
179
|
}
|
|
132
180
|
|
|
@@ -210,6 +258,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
210
258
|
// A malformed acceptance block is dropped rather than dropping the
|
|
211
259
|
// execution record: the board then treats that run as unverified.
|
|
212
260
|
verification: normalizeVerification(execution.verification),
|
|
261
|
+
external: execution.external === true ? true : undefined,
|
|
213
262
|
}))
|
|
214
263
|
// Execution targets are normalized like the schedule: blank strings
|
|
215
264
|
// clear the pin and unknown permission strings from a future version
|
|
@@ -219,9 +268,9 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
219
268
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
220
269
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
221
270
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
222
|
-
// The goal opt-in is stored only when it is
|
|
223
|
-
// (
|
|
224
|
-
task.goalRun = row.goalRun ===
|
|
271
|
+
// The goal opt-in is stored only when it is ON: absent means the default
|
|
272
|
+
// (one plain turn per run), so a hand-edited false normalizes back to it.
|
|
273
|
+
task.goalRun = row.goalRun === true ? true : undefined
|
|
225
274
|
// The acceptance opt-out is stored only when it is ON: absent inherits the
|
|
226
275
|
// board-wide switch, so a hand-edited false normalizes back to inherit.
|
|
227
276
|
task.skipVerification = row.skipVerification === true ? true : undefined
|
package/src/core/tasks.ts
CHANGED
|
@@ -37,8 +37,23 @@ export interface ExecutionRecord {
|
|
|
37
37
|
* Session id of the DSH session that issued the run/rerun action (issue #6
|
|
38
38
|
* audit origin). Client-asserted, not a trust boundary; absent when the run
|
|
39
39
|
* was triggered by cron (source unknown).
|
|
40
|
+
*
|
|
41
|
+
* On an EXTERNAL record (issue #1826) this names the agent that completed the
|
|
42
|
+
* work outside the Host instead, which is what makes the history readable.
|
|
40
43
|
*/
|
|
41
44
|
initiatedBy?: string
|
|
45
|
+
/**
|
|
46
|
+
* True when this outcome was recorded from outside the Host by
|
|
47
|
+
* `record-external-outcome` rather than observed from a DSH session
|
|
48
|
+
* (issue #1826).
|
|
49
|
+
*
|
|
50
|
+
* The board never manufactures one of these itself: every Host-side path
|
|
51
|
+
* (`run`, `settle`, cascade folding, restart recovery) writes an execution
|
|
52
|
+
* without it, so `external` is the single field separating work this Host ran
|
|
53
|
+
* from work an outside agent reported. Such a record never carries a
|
|
54
|
+
* `sessionId` and never claims a verdict the Host observed.
|
|
55
|
+
*/
|
|
56
|
+
external?: boolean
|
|
42
57
|
/** Freeze instant captured from the card snapshot when the run opened. */
|
|
43
58
|
frozenAt?: number
|
|
44
59
|
/** Freeze source session captured from the card snapshot when the run opened. */
|
|
@@ -88,17 +103,74 @@ export function retainRecentExecutions(executions: readonly ExecutionRecord[]):
|
|
|
88
103
|
return [...settled.slice(Math.max(settled.length - keepSettled, 0)), ...open]
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
/**
|
|
107
|
+
* How a rule produces its triggers: a recurring cron rule, or one single
|
|
108
|
+
* planned instant. The distinction is stored rather than inferred so a
|
|
109
|
+
* one-shot is never represented as a date-restricted cron expression.
|
|
110
|
+
*/
|
|
111
|
+
export type ScheduleMode = 'cron' | 'once'
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Why a due occurrence produced no run, or why a rule stopped on its own.
|
|
115
|
+
*
|
|
116
|
+
* These are stable codes, not display text: the board renders them through its
|
|
117
|
+
* locale dictionary, so the ledger never carries a translated string.
|
|
118
|
+
*/
|
|
119
|
+
export type ScheduleStopReason =
|
|
120
|
+
/** The single planned instant produced its run. */
|
|
121
|
+
| 'fired'
|
|
122
|
+
/** The recurring rule reached its run budget. */
|
|
123
|
+
| 'limit'
|
|
124
|
+
/** The plan has no reachable instant any more (an impossible cron). */
|
|
125
|
+
| 'no-target'
|
|
126
|
+
/** The instant passed while the board was not running through it. */
|
|
127
|
+
| 'missed'
|
|
128
|
+
/** The task already had an open execution, so this occurrence was skipped. */
|
|
129
|
+
| 'busy'
|
|
130
|
+
/** An above-default permission was still unconfirmed, so this occurrence was skipped. */
|
|
131
|
+
| 'permission'
|
|
132
|
+
/**
|
|
133
|
+
* A scheduled launch failed before any session existed, so the occurrence
|
|
134
|
+
* created no execution and its run was refunded rather than spent.
|
|
135
|
+
*/
|
|
136
|
+
| 'launch-failed'
|
|
137
|
+
|
|
138
|
+
/** Every stop reason this build understands; the repair and wire gates share it. */
|
|
139
|
+
export const SCHEDULE_STOP_REASONS: readonly ScheduleStopReason[] = [
|
|
140
|
+
'fired', 'limit', 'no-target', 'missed', 'busy', 'permission', 'launch-failed',
|
|
141
|
+
]
|
|
142
|
+
|
|
91
143
|
/**
|
|
92
144
|
* A scheduled-run rule attached to a task. The Host scheduler triggers the
|
|
93
145
|
* task when `nextRunAt` is due and persists the rule in the Host ledger.
|
|
146
|
+
*
|
|
147
|
+
* A rule is either `cron` (the historical shape: a 5-field expression with an
|
|
148
|
+
* explicit IANA zone, optionally capped by `maxRuns`) or `once` (one planned
|
|
149
|
+
* instant, `at`, with an implicit budget of one run). Both modes count the
|
|
150
|
+
* executions the scheduler actually started in `runCount`; a rule that reaches
|
|
151
|
+
* its budget stops itself in the ledger (`enabled: false` + `endedReason`)
|
|
152
|
+
* instead of relying on the execution agent to disable it.
|
|
94
153
|
*/
|
|
95
154
|
export interface ScheduleRule {
|
|
96
155
|
/** Whether the schedule is armed. */
|
|
97
156
|
enabled: boolean
|
|
98
|
-
/** 5-field cron expression: `分 时 日 月 周`. */
|
|
99
|
-
cron: string
|
|
100
157
|
/**
|
|
101
|
-
*
|
|
158
|
+
* Which kind of plan this rule carries. A rule loaded from a ledger written
|
|
159
|
+
* before the field existed normalizes to `cron` (its `cron` is the plan).
|
|
160
|
+
*/
|
|
161
|
+
mode: ScheduleMode
|
|
162
|
+
/**
|
|
163
|
+
* 5-field cron expression: `分 时 日 月 周`. Present on a `cron` rule and
|
|
164
|
+
* absent on a `once` rule.
|
|
165
|
+
*/
|
|
166
|
+
cron?: string
|
|
167
|
+
/**
|
|
168
|
+
* The instant a `once` rule executes (ms epoch). Kept after the run so the
|
|
169
|
+
* detail view can still show the planned time once the rule has ended.
|
|
170
|
+
*/
|
|
171
|
+
at?: number
|
|
172
|
+
/**
|
|
173
|
+
* IANA zone the wall clock is read in (issue #1722). Absent means the
|
|
102
174
|
* Host process zone, which is what a rule written before zones were
|
|
103
175
|
* persisted keeps using; the Host stamps its own zone at ledger migration so
|
|
104
176
|
* a later `TZ` change cannot silently move an existing rule.
|
|
@@ -108,6 +180,62 @@ export interface ScheduleRule {
|
|
|
108
180
|
nextRunAt: number | undefined
|
|
109
181
|
/** Instant of the latest scheduled trigger (ms epoch). */
|
|
110
182
|
lastTriggeredAt: number | undefined
|
|
183
|
+
/**
|
|
184
|
+
* Scheduled executions the Host has created under this rule. This is the
|
|
185
|
+
* run budget's counter: it counts executions that were successfully opened
|
|
186
|
+
* (whatever their later outcome), never completions, and a manual run never
|
|
187
|
+
* touches it. A skipped occurrence (busy / permission / missed / no target)
|
|
188
|
+
* creates no execution and therefore consumes nothing.
|
|
189
|
+
*/
|
|
190
|
+
runCount: number
|
|
191
|
+
/**
|
|
192
|
+
* Maximum scheduled executions for a `cron` rule; absent means unlimited
|
|
193
|
+
* (the historical behavior). A `once` rule has an implicit budget of one and
|
|
194
|
+
* stores no `maxRuns`.
|
|
195
|
+
*/
|
|
196
|
+
maxRuns?: number
|
|
197
|
+
/** Instant the rule stopped itself (ms epoch); absent while it may still fire. */
|
|
198
|
+
endedAt?: number
|
|
199
|
+
/** Why the rule stopped itself; present with `endedAt`. */
|
|
200
|
+
endedReason?: ScheduleStopReason
|
|
201
|
+
/** Instant of the latest occurrence that produced no run. */
|
|
202
|
+
skippedAt?: number
|
|
203
|
+
/** Why that occurrence produced no run; present with `skippedAt`. */
|
|
204
|
+
skippedReason?: ScheduleStopReason
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The rule's total run budget: one for a one-shot, the stored cap for a
|
|
209
|
+
* recurring rule, and undefined when the rule is unlimited.
|
|
210
|
+
* @param rule - the schedule rule.
|
|
211
|
+
* @returns the budget, or undefined for an uncapped rule.
|
|
212
|
+
*/
|
|
213
|
+
export function scheduleRunBudget(rule: ScheduleRule): number | undefined {
|
|
214
|
+
if (rule.mode === 'once') return 1
|
|
215
|
+
return rule.maxRuns
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether a rule has no runs left. The scheduler refuses to open another
|
|
220
|
+
* execution for an exhausted rule, which is what makes the budget terminal
|
|
221
|
+
* regardless of restarts or repeated timer fires.
|
|
222
|
+
* @param rule - the schedule rule.
|
|
223
|
+
* @returns true when the run budget is spent.
|
|
224
|
+
*/
|
|
225
|
+
export function scheduleExhausted(rule: ScheduleRule): boolean {
|
|
226
|
+
const budget = scheduleRunBudget(rule)
|
|
227
|
+
return budget !== undefined && rule.runCount >= budget
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The instant a rule plans to fire next, for display, whatever its mode: the
|
|
232
|
+
* planned `at` of a one-shot, or the stored `nextRunAt` of a recurring rule.
|
|
233
|
+
* @param rule - the schedule rule.
|
|
234
|
+
* @returns the planned instant, or undefined when the rule carries none.
|
|
235
|
+
*/
|
|
236
|
+
export function schedulePlannedAt(rule: ScheduleRule): number | undefined {
|
|
237
|
+
if (rule.mode === 'once') return rule.at
|
|
238
|
+
return rule.nextRunAt
|
|
111
239
|
}
|
|
112
240
|
|
|
113
241
|
/**
|
|
@@ -300,9 +428,11 @@ export interface TaskRecord {
|
|
|
300
428
|
* armed with the composed execution prompt as its objective. The session then
|
|
301
429
|
* keeps working automatic continuation rounds until the agent marks the goal
|
|
302
430
|
* complete, and this board settles the execution on the goal's own end
|
|
303
|
-
* instead of at the first turn end. Absent means
|
|
304
|
-
* by default, so only an explicit `
|
|
305
|
-
*
|
|
431
|
+
* instead of at the first turn end. Absent means OFF — the option is
|
|
432
|
+
* unchecked by default, so only an explicit `true` joins the goal run and
|
|
433
|
+
* every card that never touched it (including every card written before the
|
|
434
|
+
* field existed) runs one plain turn. A session whose `/goal` command is
|
|
435
|
+
* refused still runs the prompt.
|
|
306
436
|
*/
|
|
307
437
|
goalRun?: boolean
|
|
308
438
|
/**
|
|
@@ -410,8 +540,8 @@ export interface NewTaskInput {
|
|
|
410
540
|
/** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
|
|
411
541
|
teamRun?: boolean
|
|
412
542
|
/**
|
|
413
|
-
* Start the execution with dsh's built-in `/goal`. Absent
|
|
414
|
-
*
|
|
543
|
+
* Start the execution with dsh's built-in `/goal`. Absent means the default
|
|
544
|
+
* (one plain turn); only an explicit true joins the goal run.
|
|
415
545
|
*/
|
|
416
546
|
goalRun?: boolean
|
|
417
547
|
/**
|
|
@@ -420,12 +550,22 @@ export interface NewTaskInput {
|
|
|
420
550
|
*/
|
|
421
551
|
skipVerification?: boolean
|
|
422
552
|
/**
|
|
423
|
-
* Optional
|
|
424
|
-
* dialog): an enable flag, a 5-field cron
|
|
425
|
-
*
|
|
426
|
-
*
|
|
553
|
+
* Optional scheduling rule requested at creation time (the new-task
|
|
554
|
+
* dialog): an enable flag, the kind of plan (a recurring 5-field cron
|
|
555
|
+
* expression, or one planned instant for a one-shot), an optional run cap,
|
|
556
|
+
* and the IANA zone its wall clock is read in (absent means the Host zone).
|
|
557
|
+
* The create use case arms it only when enabled and the plan is valid.
|
|
427
558
|
*/
|
|
428
|
-
schedule?: {
|
|
559
|
+
schedule?: {
|
|
560
|
+
enabled: boolean
|
|
561
|
+
mode?: ScheduleMode
|
|
562
|
+
cron?: string
|
|
563
|
+
/** Planned instant of a one-shot rule (ms epoch). */
|
|
564
|
+
at?: number
|
|
565
|
+
timeZone?: string
|
|
566
|
+
/** Run cap of a recurring rule; absent means unlimited. */
|
|
567
|
+
maxRuns?: number
|
|
568
|
+
}
|
|
429
569
|
/**
|
|
430
570
|
* Optional frozen context snapshot (goal/progress/next, sanitized by the
|
|
431
571
|
* protocol gate) turning the new task into a continuation card.
|
|
@@ -561,10 +701,10 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
|
|
|
561
701
|
model: normalizeTargetId(input.model),
|
|
562
702
|
reuseSession: input.reuseSession === true ? true : undefined,
|
|
563
703
|
teamRun: input.teamRun === true ? true : undefined,
|
|
564
|
-
// Default
|
|
704
|
+
// Default OFF: only an explicit true is stored, so a card that never
|
|
565
705
|
// touched the option (and every card written before the field existed)
|
|
566
|
-
// keeps
|
|
567
|
-
goalRun: input.goalRun ===
|
|
706
|
+
// keeps running one plain turn.
|
|
707
|
+
goalRun: input.goalRun === true ? true : undefined,
|
|
568
708
|
// Default OFF: only an explicit true is stored, so a card that never
|
|
569
709
|
// touched the option inherits the board-wide acceptance switch exactly as
|
|
570
710
|
// every card written before the field existed does.
|
|
@@ -582,35 +722,38 @@ export function withStatus(task: TaskRecord, status: TaskStatus, now: number): T
|
|
|
582
722
|
return { ...task, status, updatedAt: now }
|
|
583
723
|
}
|
|
584
724
|
|
|
725
|
+
/** Every field a schedule patch may carry; the rest of the rule is carried over. */
|
|
726
|
+
const SCHEDULE_PATCH_KEYS = [
|
|
727
|
+
'enabled', 'mode', 'cron', 'at', 'timeZone', 'nextRunAt', 'lastTriggeredAt',
|
|
728
|
+
'runCount', 'maxRuns', 'endedAt', 'endedReason', 'skippedAt', 'skippedReason',
|
|
729
|
+
] as const satisfies ReadonlyArray<keyof ScheduleRule>
|
|
730
|
+
|
|
585
731
|
/**
|
|
586
732
|
* Merge a schedule patch into a task's schedule rule (creating it when
|
|
587
733
|
* absent), with a fresh updatedAt. Keys present in the patch overwrite the
|
|
588
734
|
* current value — including explicit `undefined`, which clears a field (used
|
|
589
|
-
* to disarm `nextRunAt`); absent keys keep
|
|
735
|
+
* to disarm `nextRunAt` or to drop an ended/skip record); absent keys keep
|
|
736
|
+
* their current value.
|
|
590
737
|
*/
|
|
591
738
|
export function withSchedule(
|
|
592
739
|
task: TaskRecord,
|
|
593
740
|
patch: Partial<ScheduleRule>,
|
|
594
741
|
now: number,
|
|
595
742
|
): TaskRecord {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
743
|
+
const schedule: ScheduleRule = task.schedule === undefined
|
|
744
|
+
? { enabled: false, mode: 'cron', nextRunAt: undefined, lastTriggeredAt: undefined, runCount: 0 }
|
|
745
|
+
: { ...task.schedule }
|
|
746
|
+
const fields = schedule as unknown as Record<string, unknown>
|
|
747
|
+
const next = patch as unknown as Record<string, unknown>
|
|
748
|
+
for (const key of SCHEDULE_PATCH_KEYS) {
|
|
749
|
+
if (!(key in patch)) continue
|
|
750
|
+
if (next[key] === undefined) delete fields[key]
|
|
751
|
+
else fields[key] = next[key]
|
|
603
752
|
}
|
|
604
|
-
|
|
605
|
-
if (
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if ('timeZone' in patch) {
|
|
609
|
-
if (patch.timeZone === undefined) delete schedule.timeZone
|
|
610
|
-
else schedule.timeZone = patch.timeZone
|
|
611
|
-
}
|
|
612
|
-
if ('nextRunAt' in patch) schedule.nextRunAt = patch.nextRunAt
|
|
613
|
-
if ('lastTriggeredAt' in patch) schedule.lastTriggeredAt = patch.lastTriggeredAt
|
|
753
|
+
// The three non-optional fields can never be cleared into an unusable rule.
|
|
754
|
+
if (schedule.enabled !== true) schedule.enabled = false
|
|
755
|
+
if (schedule.mode !== 'once') schedule.mode = 'cron'
|
|
756
|
+
if (!Number.isFinite(schedule.runCount) || schedule.runCount < 0) schedule.runCount = 0
|
|
614
757
|
return { ...task, updatedAt: now, schedule }
|
|
615
758
|
}
|
|
616
759
|
|
|
@@ -654,6 +797,21 @@ export function startExecution(
|
|
|
654
797
|
}
|
|
655
798
|
}
|
|
656
799
|
|
|
800
|
+
/**
|
|
801
|
+
* The column a settled outcome puts a card in. The ONE derivation shared by
|
|
802
|
+
* every settlement path (issue #1826): a Host-run execution and an outcome an
|
|
803
|
+
* outside agent reported both land in the same column from the same rule, so
|
|
804
|
+
* the column never becomes a second, independent piece of state.
|
|
805
|
+
@param task - the card being settled.
|
|
806
|
+
@param outcome - the settled outcome.
|
|
807
|
+
@returns the status the card shows afterwards.
|
|
808
|
+
*/
|
|
809
|
+
export function settledStatus(task: TaskRecord, outcome: ExecutionOutcome): TaskStatus {
|
|
810
|
+
if (outcome === 'succeeded') return task.schedule?.enabled === true ? 'todo' : 'done'
|
|
811
|
+
if (outcome === 'failed') return 'failed'
|
|
812
|
+
return task.status === 'running' ? 'todo' : task.status
|
|
813
|
+
}
|
|
814
|
+
|
|
657
815
|
/**
|
|
658
816
|
* Settle a running execution: record the outcome and move the task into the
|
|
659
817
|
* matching column. No-op (returns the input task) when the execution is not
|
|
@@ -673,11 +831,7 @@ export function settleExecution(
|
|
|
673
831
|
const settled: ExecutionRecord = { ...execution, endedAt: now, result: outcome, error }
|
|
674
832
|
const executions = [...task.executions]
|
|
675
833
|
executions[index] = settled
|
|
676
|
-
|
|
677
|
-
? (task.schedule?.enabled ? 'todo' : 'done')
|
|
678
|
-
: outcome === 'failed' ? 'failed'
|
|
679
|
-
: task.status === 'running' ? 'todo' : task.status
|
|
680
|
-
return { ...task, status, updatedAt: now, executions }
|
|
834
|
+
return { ...task, status: settledStatus(task, outcome), updatedAt: now, executions }
|
|
681
835
|
}
|
|
682
836
|
|
|
683
837
|
/** A settled-execution summary string for the detail view. */
|
|
@@ -67,22 +67,43 @@ export function applyCreateTask(
|
|
|
67
67
|
model: input.model ?? parent.model,
|
|
68
68
|
}
|
|
69
69
|
let task = createTask(effective, now, id)
|
|
70
|
-
// Arm the requested schedule (new-task dialog)
|
|
71
|
-
// a
|
|
72
|
-
//
|
|
70
|
+
// Arm the requested schedule (new-task dialog). The Host wire gate already
|
|
71
|
+
// rejects a malformed enabled plan; this arm re-checks it so a direct caller
|
|
72
|
+
// (a test or an embedder) cannot persist an unusable rule either.
|
|
73
73
|
const requested = input.schedule
|
|
74
|
-
if (requested?.enabled === true
|
|
75
|
-
const cron = requested.cron.trim()
|
|
74
|
+
if (requested?.enabled === true) {
|
|
76
75
|
const requestedZone = requested.timeZone
|
|
77
76
|
// An unusable zone is refused rather than silently reinterpreted as the
|
|
78
77
|
// Host zone: the caller asked for a specific wall clock.
|
|
79
78
|
if (requestedZone !== undefined && !isValidTimeZone(requestedZone)) {
|
|
80
79
|
return { task: undefined, tasks, error: 'invalid schedule time zone' }
|
|
81
80
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
if (requested.mode === 'once') {
|
|
82
|
+
const at = requested.at
|
|
83
|
+
if (at === undefined || !Number.isInteger(at) || at <= now) {
|
|
84
|
+
return { task: undefined, tasks, error: 'invalid schedule instant' }
|
|
85
|
+
}
|
|
86
|
+
task = withSchedule(task, {
|
|
87
|
+
enabled: true,
|
|
88
|
+
mode: 'once',
|
|
89
|
+
at,
|
|
90
|
+
timeZone: requestedZone,
|
|
91
|
+
nextRunAt: at,
|
|
92
|
+
runCount: 0,
|
|
93
|
+
}, now)
|
|
94
|
+
} else {
|
|
95
|
+
const cron = (requested.cron ?? '').trim()
|
|
96
|
+
if (cron !== '' && isValidCron(cron)) {
|
|
97
|
+
const maxRuns = requested.maxRuns
|
|
98
|
+
if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns < 1)) {
|
|
99
|
+
return { task: undefined, tasks, error: 'invalid schedule run cap' }
|
|
100
|
+
}
|
|
101
|
+
const zone = requestedZone ?? hostTimeZone
|
|
102
|
+
const nextRunAt = nextRunAtMs(cron, now, zone)
|
|
103
|
+
if (nextRunAt !== undefined) {
|
|
104
|
+
task = withSchedule(task, { enabled: true, mode: 'cron', cron, maxRuns, timeZone: requestedZone, nextRunAt, runCount: 0 }, now)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
return { task, tasks: [...tasks, task] }
|