@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +34 -18
- package/README.zh.md +34 -18
- package/lib/client.js +2804 -1024
- package/lib/client.js.map +1 -1
- package/lib/index.js +2576 -416
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +81 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +41 -17
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +163 -14
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +91 -11
- package/lib/types/host-runner.d.ts +111 -13
- package/lib/types/host-service.d.ts +199 -8
- package/lib/types/index.d.ts +141 -0
- package/lib/types/protocol.d.ts +63 -12
- package/package.json +29 -33
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +288 -17
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +237 -60
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +261 -24
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/status-key.ts +3 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +105 -108
- package/src/client/locales.ts +171 -6
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +55 -16
- package/src/core/launch-width.ts +38 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +69 -15
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +213 -39
- package/src/core/use-cases/task-create.ts +57 -25
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +9 -4
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +127 -29
- package/src/host/launch-admission.ts +140 -0
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +346 -48
- package/src/host-runner.ts +234 -35
- package/src/host-service.ts +539 -52
- package/src/index.ts +250 -7
- package/src/protocol.ts +160 -32
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
|
@@ -9,13 +9,13 @@ import type { BoardController, ControllerSnapshot } from '../../core/controller.
|
|
|
9
9
|
import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
|
|
10
10
|
import { useTaskBoardSeats } from '../seats.tsx'
|
|
11
11
|
import { isValidCron } from '../../core/schedule.ts'
|
|
12
|
-
import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
12
|
+
import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type ScheduleMode, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
13
13
|
import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
|
|
14
14
|
import { requiresPermissionConfirmation } from '../../core/handover.ts'
|
|
15
|
-
import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
|
|
15
|
+
import { DEFAULT_SUBTASK_DEPTH, directSubtasks, joinsCascadeRun, taskDepth } from '../../core/subtask.ts'
|
|
16
16
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
17
17
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
18
|
-
import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
|
|
18
|
+
import { budgetLabel, defaultZonedInputValue, endedLabel, nextRunLabel, parseZonedInput, runCapOption, skippedLabel, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
|
|
19
19
|
import css from '../board.module.css'
|
|
20
20
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
21
21
|
import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
|
|
@@ -24,6 +24,8 @@ import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
|
|
|
24
24
|
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
25
25
|
import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
|
|
26
26
|
import { inheritPresetLabel, presetLabel } from './preset-label.ts'
|
|
27
|
+
import { ReasoningEffortField } from './ReasoningEffortField.tsx'
|
|
28
|
+
import { reconcileReasoningEffort } from './model-options.ts'
|
|
27
29
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
28
30
|
import { STATUS_KEY } from './status-key.ts'
|
|
29
31
|
import { VerificationReport } from './VerificationReport.tsx'
|
|
@@ -39,8 +41,12 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
39
41
|
/** One execution-history row. */
|
|
40
42
|
function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
|
|
41
43
|
const result = execution.result
|
|
44
|
+
// An external record is one the Host never observed running: no session, no
|
|
45
|
+
// spinner, and a named outside agent instead of the initiating session. The
|
|
46
|
+
// data attribute keeps the distinction machine-readable too.
|
|
47
|
+
const external = execution.external === true
|
|
42
48
|
return (
|
|
43
|
-
<li className={css.executionRow} data-result={result}>
|
|
49
|
+
<li className={css.executionRow} data-result={result} data-external={external ? 'true' : undefined}>
|
|
44
50
|
<span className={css.executionBadge} data-result={result}>
|
|
45
51
|
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
46
52
|
</span>
|
|
@@ -48,7 +54,12 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
48
54
|
{t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
|
|
49
55
|
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
|
|
50
56
|
</span>
|
|
51
|
-
{execution.initiatedBy !== undefined && (
|
|
57
|
+
{external && execution.initiatedBy !== undefined && (
|
|
58
|
+
<span className={css.executionTimes} title={execution.initiatedBy}>
|
|
59
|
+
{t('detail.execution.external', { agent: execution.initiatedBy })}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
{!external && execution.initiatedBy !== undefined && (
|
|
52
63
|
<span className={css.executionTimes} title={execution.initiatedBy}>
|
|
53
64
|
{t('detail.execution.initiator', { session: execution.initiatedBy })}
|
|
54
65
|
</span>
|
|
@@ -73,7 +84,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
76
|
-
function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
|
|
87
|
+
function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable, goalRunEnabled }: {
|
|
77
88
|
controller: BoardController
|
|
78
89
|
task: TaskRecord
|
|
79
90
|
pending: boolean
|
|
@@ -81,18 +92,26 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
81
92
|
executionOptions: ControllerSnapshot['executionOptions']
|
|
82
93
|
/** Whether this deployment serves the Agent Teams service. */
|
|
83
94
|
teamRunAvailable: boolean
|
|
95
|
+
/**
|
|
96
|
+
* Live GLOBAL native-/goal switch. It only decides whether the card's own
|
|
97
|
+
* preference takes effect: the option stays operable while it is off, the
|
|
98
|
+
* stored value is never rewritten by it, and the explanation below says so.
|
|
99
|
+
*/
|
|
100
|
+
goalRunEnabled: boolean
|
|
84
101
|
}) {
|
|
85
102
|
const options = executionOptions
|
|
103
|
+
const models = options.models ?? []
|
|
86
104
|
const workspaceId = task.workspaceId ?? ''
|
|
87
105
|
const mode = task.mode ?? ''
|
|
88
106
|
const permission = task.permission ?? ''
|
|
89
107
|
const model = task.model ?? ''
|
|
108
|
+
const reasoningEffort = task.reasoningEffort ?? ''
|
|
90
109
|
// A pinned target may disappear from the runtime (workspace deleted,
|
|
91
110
|
// preset removed); keep it selectable as a stale row instead of silently
|
|
92
111
|
// dropping it, so the user sees exactly what the task will ask for.
|
|
93
112
|
const workspaceKnown = workspaceId === '' || options.workspaces.some(item => item.workspaceId === workspaceId)
|
|
94
113
|
const modeKnown = mode === '' || options.presets.some(item => item.id === mode)
|
|
95
|
-
const modelKnown = model === '' ||
|
|
114
|
+
const modelKnown = model === '' || models.some(item => item.id === model)
|
|
96
115
|
return (
|
|
97
116
|
<section className={css.detailSection}>
|
|
98
117
|
<h4>{t('detail.executionSettings')}</h4>
|
|
@@ -151,15 +170,35 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
151
170
|
className={css.select}
|
|
152
171
|
value={model}
|
|
153
172
|
disabled={pending}
|
|
154
|
-
onChange={event => {
|
|
173
|
+
onChange={event => {
|
|
174
|
+
// The effort is part of this pin: switching to a model that cannot
|
|
175
|
+
// honor the stored level clears it in the same action instead of
|
|
176
|
+
// leaving a value the next run would have to reject. The empty
|
|
177
|
+
// string carries a clear across the JSON transport, where an
|
|
178
|
+
// `undefined` value would lose the key entirely; the ledger
|
|
179
|
+
// normalizes blank targets back to "pinned nothing".
|
|
180
|
+
const next = event.target.value
|
|
181
|
+
const effort = reconcileReasoningEffort(models, next, reasoningEffort)
|
|
182
|
+
controller.updateTask(task.id, {
|
|
183
|
+
model: next,
|
|
184
|
+
...(effort === reasoningEffort ? {} : { reasoningEffort: effort }),
|
|
185
|
+
})
|
|
186
|
+
}}
|
|
155
187
|
>
|
|
156
188
|
<option value="">{t('exec.model.default')}</option>
|
|
157
189
|
{!modelKnown && <option value={model}>{model}{t('exec.model.unknown')}</option>}
|
|
158
|
-
{
|
|
190
|
+
{models.map(item => (
|
|
159
191
|
<option key={item.id} value={item.id}>{item.name ?? item.id}</option>
|
|
160
192
|
))}
|
|
161
193
|
</select>
|
|
162
194
|
</label>
|
|
195
|
+
<ReasoningEffortField
|
|
196
|
+
models={models}
|
|
197
|
+
modelId={model}
|
|
198
|
+
value={reasoningEffort}
|
|
199
|
+
pending={pending}
|
|
200
|
+
onChange={effort => { controller.updateTask(task.id, { reasoningEffort: effort }) }}
|
|
201
|
+
/>
|
|
163
202
|
<label className={css.scheduleToggle}>
|
|
164
203
|
<input
|
|
165
204
|
type="checkbox"
|
|
@@ -173,13 +212,17 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
173
212
|
<label className={css.scheduleToggle}>
|
|
174
213
|
<input
|
|
175
214
|
type="checkbox"
|
|
176
|
-
checked={task.goalRun
|
|
215
|
+
checked={task.goalRun === true}
|
|
216
|
+
// Always operable (only a pending execution locks it): the global
|
|
217
|
+
// switch decides whether the preference takes effect, never whether
|
|
218
|
+
// the user may state it, and it never rewrites what is stored here.
|
|
177
219
|
disabled={pending}
|
|
178
220
|
onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
|
|
179
221
|
/>
|
|
180
222
|
<span>{t('exec.goalRun')}</span>
|
|
181
223
|
</label>
|
|
182
224
|
<p className={css.detailText}>{t('exec.goalRunHint')}</p>
|
|
225
|
+
{!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
|
|
183
226
|
<label className={css.scheduleToggle}>
|
|
184
227
|
<input
|
|
185
228
|
type="checkbox"
|
|
@@ -204,31 +247,49 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
204
247
|
)
|
|
205
248
|
}
|
|
206
249
|
|
|
207
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* The scheduled-runs editor. A one-shot has one planned instant; a recurring
|
|
252
|
+
* rule has a cron expression, an optional run cap, and a zone. The editor
|
|
253
|
+
* shows the rule's own state (next target, run counter, stop/skip record) and
|
|
254
|
+
* can cancel a future plan.
|
|
255
|
+
*/
|
|
208
256
|
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
209
257
|
const schedule = task.schedule
|
|
258
|
+
const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
259
|
+
// The rule's own zone; '' means "follow the Host zone" (no stored zone).
|
|
260
|
+
const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
|
|
261
|
+
const [mode, setMode] = useState<ScheduleMode>(schedule?.mode ?? 'cron')
|
|
210
262
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
263
|
+
const [atText, setAtText] = useState<string>(() => schedule?.at === undefined
|
|
264
|
+
? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
|
|
265
|
+
: zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
|
|
266
|
+
const [maxRunsChoice, setMaxRunsChoice] = useState<string>(() => runCapOption(schedule?.maxRuns))
|
|
267
|
+
const [maxRunsText, setMaxRunsText] = useState<string>(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
211
268
|
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
212
269
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
213
270
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
214
271
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
215
|
-
const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
216
|
-
// The rule's own zone; '' means "follow the Host zone" (no stored zone).
|
|
217
|
-
const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
|
|
218
272
|
// One clock reading per render pass, so the absolute and relative halves of
|
|
219
273
|
// the label always describe the same instant.
|
|
220
274
|
const now = Date.now()
|
|
221
275
|
|
|
222
276
|
// Keep the editor in sync when the task record changes underneath (the
|
|
223
|
-
// schedule rolls forward as runs trigger
|
|
277
|
+
// schedule rolls forward as runs trigger, ends at its budget, or is edited
|
|
278
|
+
// elsewhere).
|
|
224
279
|
useEffect(() => {
|
|
280
|
+
setMode(schedule?.mode ?? 'cron')
|
|
225
281
|
setCron(schedule?.cron ?? '0 9 * * *')
|
|
282
|
+
setZone(schedule?.timeZone ?? '')
|
|
283
|
+
setAtText(schedule?.at === undefined
|
|
284
|
+
? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
|
|
285
|
+
: zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
|
|
286
|
+
setMaxRunsChoice(runCapOption(schedule?.maxRuns))
|
|
287
|
+
setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
226
288
|
setEnabled(schedule?.enabled ?? false)
|
|
227
289
|
setNextRunAt(schedule?.nextRunAt)
|
|
228
290
|
setLastTriggeredAt(schedule?.lastTriggeredAt)
|
|
229
|
-
setZone(schedule?.timeZone ?? '')
|
|
230
291
|
setError(undefined)
|
|
231
|
-
}, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
|
|
292
|
+
}, [task.id, hostTimeZone, schedule?.mode, schedule?.enabled, schedule?.cron, schedule?.at, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt, schedule?.maxRuns])
|
|
232
293
|
|
|
233
294
|
/** Validate + persist the current cron text (Enter or blur). */
|
|
234
295
|
const saveCron = (value: string): void => {
|
|
@@ -239,21 +300,101 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
239
300
|
return
|
|
240
301
|
}
|
|
241
302
|
setError(undefined)
|
|
242
|
-
controller.setSchedule(task.id, { cron: trimmed })
|
|
303
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Validate + persist the one-shot's planned instant. */
|
|
307
|
+
const saveAt = (value: string): void => {
|
|
308
|
+
setAtText(value)
|
|
309
|
+
const at = parseZonedInput(value, effectiveZone)
|
|
310
|
+
if (at === undefined || at <= now) {
|
|
311
|
+
setError(t('detail.schedule.atInvalid'))
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
setError(undefined)
|
|
315
|
+
controller.setSchedule(task.id, { mode: 'once', at })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Switch the kind of plan, persisting a usable target for the new kind. */
|
|
319
|
+
const changeMode = (next: ScheduleMode): void => {
|
|
320
|
+
if (next === 'once') {
|
|
321
|
+
const at = parseZonedInput(atText, effectiveZone)
|
|
322
|
+
if (at === undefined || at <= now) {
|
|
323
|
+
setError(t('detail.schedule.atInvalid'))
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
setMode('once')
|
|
327
|
+
setError(undefined)
|
|
328
|
+
controller.setSchedule(task.id, { mode: 'once', at })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const trimmed = cron.trim()
|
|
332
|
+
if (trimmed === '' || !isValidCron(trimmed)) {
|
|
333
|
+
setError(t('detail.schedule.invalid'))
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
setMode('cron')
|
|
337
|
+
setError(undefined)
|
|
338
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
|
|
243
339
|
}
|
|
244
340
|
|
|
245
341
|
/**
|
|
246
342
|
* Change the rule's zone. `''` clears the stored zone, which is how a user
|
|
247
|
-
* returns a rule to following the Host zone.
|
|
343
|
+
* returns a rule to following the Host zone. A one-shot's instant is
|
|
344
|
+
* absolute, so only its rendered wall clock follows the new zone; a cron
|
|
345
|
+
* rule's wall clock itself moves with the zone.
|
|
248
346
|
*/
|
|
249
347
|
const changeZone = (value: string): void => {
|
|
250
348
|
setZone(value)
|
|
251
349
|
setError(undefined)
|
|
350
|
+
if (mode === 'once' && schedule?.at !== undefined) {
|
|
351
|
+
setAtText(zonedInputValue(schedule.at, value === '' ? hostTimeZone : value))
|
|
352
|
+
controller.setSchedule(task.id, { mode: 'once', timeZone: value === '' ? null : value })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
252
355
|
controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
|
|
253
356
|
}
|
|
254
357
|
|
|
255
|
-
/**
|
|
358
|
+
/** Change the recurring rule's run cap (unlimited, a preset, or a custom value). */
|
|
359
|
+
const applyMaxRuns = (choice: string): void => {
|
|
360
|
+
setMaxRunsChoice(choice)
|
|
361
|
+
setError(undefined)
|
|
362
|
+
if (choice === 'unlimited') {
|
|
363
|
+
controller.setSchedule(task.id, { maxRuns: null })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (choice === 'custom') {
|
|
367
|
+
setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
controller.setSchedule(task.id, { maxRuns: Number(choice) })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Persist a custom run cap; a non-positive or fractional value is refused. */
|
|
374
|
+
const saveCustomMaxRuns = (value: string): void => {
|
|
375
|
+
setMaxRunsText(value)
|
|
376
|
+
const parsed = Number(value.trim())
|
|
377
|
+
if (value.trim() === '' || !Number.isInteger(parsed) || parsed < 1) {
|
|
378
|
+
setError(t('detail.schedule.maxRunsInvalid'))
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
setError(undefined)
|
|
382
|
+
controller.setSchedule(task.id, { maxRuns: parsed })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Arm/disarm the schedule (arming first persists the edited plan). */
|
|
256
386
|
const toggleEnabled = (next: boolean): void => {
|
|
387
|
+
if (next && mode === 'once') {
|
|
388
|
+
const at = parseZonedInput(atText, effectiveZone)
|
|
389
|
+
if (at === undefined || at <= now) {
|
|
390
|
+
setError(t('detail.schedule.atInvalid'))
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
setError(undefined)
|
|
394
|
+
const submitted = controller.setSchedule(task.id, { mode: 'once', at, enabled: true })
|
|
395
|
+
if (submitted && !controller.isHostBacked()) setEnabled(true)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
257
398
|
const trimmed = cron.trim()
|
|
258
399
|
if (next && (trimmed === '' || !isValidCron(trimmed))) {
|
|
259
400
|
setError(t('detail.schedule.invalid'))
|
|
@@ -267,11 +408,19 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
267
408
|
if (submitted && !controller.isHostBacked()) setEnabled(next)
|
|
268
409
|
}
|
|
269
410
|
|
|
411
|
+
/** Drop every future trigger without touching the card's execution history. */
|
|
412
|
+
const cancelSchedule = (): void => {
|
|
413
|
+
setError(undefined)
|
|
414
|
+
const submitted = controller.setSchedule(task.id, { enabled: false })
|
|
415
|
+
if (submitted && !controller.isHostBacked()) setEnabled(false)
|
|
416
|
+
}
|
|
417
|
+
|
|
270
418
|
const applyPreset = (preset: string): void => {
|
|
271
419
|
if (preset === '') return
|
|
272
420
|
setCron(preset)
|
|
421
|
+
setMode('cron')
|
|
273
422
|
setError(undefined)
|
|
274
|
-
controller.setSchedule(task.id, { cron: preset })
|
|
423
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: preset })
|
|
275
424
|
}
|
|
276
425
|
|
|
277
426
|
// The zone the rule's wall clock is actually read in: its own when set,
|
|
@@ -286,6 +435,11 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
286
435
|
// The rule's own stored zone rides the list even when this runtime's
|
|
287
436
|
// inventory does not carry it, so opening the editor cannot rewrite it.
|
|
288
437
|
const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
|
|
438
|
+
// The ledger's own view of the budget and stop record: derived here rather
|
|
439
|
+
// than tracked in local state, so the display cannot drift from the Host.
|
|
440
|
+
const budget = schedule === undefined ? undefined : budgetLabel(schedule)
|
|
441
|
+
const ended = schedule === undefined ? undefined : endedLabel(schedule)
|
|
442
|
+
const skipped = schedule === undefined ? undefined : skippedLabel(schedule)
|
|
289
443
|
|
|
290
444
|
return (
|
|
291
445
|
<section className={css.detailSection}>
|
|
@@ -299,6 +453,35 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
299
453
|
/>
|
|
300
454
|
<span>{t('detail.schedule.enable')}</span>
|
|
301
455
|
</label>
|
|
456
|
+
<label className={css.scheduleZone}>
|
|
457
|
+
<span>{t('detail.schedule.mode')}</span>
|
|
458
|
+
<select
|
|
459
|
+
className={css.schedulePreset}
|
|
460
|
+
value={mode}
|
|
461
|
+
disabled={pending}
|
|
462
|
+
aria-label={t('detail.schedule.mode')}
|
|
463
|
+
onChange={event => { changeMode(event.target.value as ScheduleMode) }}
|
|
464
|
+
>
|
|
465
|
+
<option value="cron">{t('detail.schedule.mode.cron')}</option>
|
|
466
|
+
<option value="once">{t('detail.schedule.mode.once')}</option>
|
|
467
|
+
</select>
|
|
468
|
+
</label>
|
|
469
|
+
{mode === 'once' ? (
|
|
470
|
+
<label className={css.scheduleZone}>
|
|
471
|
+
<span>{t('detail.schedule.at')}</span>
|
|
472
|
+
<input
|
|
473
|
+
className={css.input}
|
|
474
|
+
type="datetime-local"
|
|
475
|
+
value={atText}
|
|
476
|
+
disabled={pending}
|
|
477
|
+
aria-label={t('detail.schedule.at')}
|
|
478
|
+
title={t('detail.schedule.atHint')}
|
|
479
|
+
onChange={event => { setAtText(event.target.value); setError(undefined) }}
|
|
480
|
+
onBlur={() => { saveAt(atText) }}
|
|
481
|
+
/>
|
|
482
|
+
</label>
|
|
483
|
+
) : (
|
|
484
|
+
<>
|
|
302
485
|
<div className={css.scheduleRow}>
|
|
303
486
|
<input
|
|
304
487
|
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
@@ -324,6 +507,39 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
324
507
|
))}
|
|
325
508
|
</select>
|
|
326
509
|
</div>
|
|
510
|
+
<label className={css.scheduleZone}>
|
|
511
|
+
<span>{t('detail.schedule.maxRuns')}</span>
|
|
512
|
+
<select
|
|
513
|
+
className={css.schedulePreset}
|
|
514
|
+
value={maxRunsChoice}
|
|
515
|
+
disabled={pending}
|
|
516
|
+
aria-label={t('detail.schedule.maxRuns')}
|
|
517
|
+
onChange={event => { applyMaxRuns(event.target.value) }}
|
|
518
|
+
>
|
|
519
|
+
<option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
|
|
520
|
+
<option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
|
|
521
|
+
<option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
|
|
522
|
+
<option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
|
|
523
|
+
</select>
|
|
524
|
+
</label>
|
|
525
|
+
{maxRunsChoice === 'custom' && (
|
|
526
|
+
<label className={css.scheduleZone}>
|
|
527
|
+
<span>{t('detail.schedule.maxRuns')}</span>
|
|
528
|
+
<input
|
|
529
|
+
className={css.input}
|
|
530
|
+
type="number"
|
|
531
|
+
min={1}
|
|
532
|
+
step={1}
|
|
533
|
+
value={maxRunsText}
|
|
534
|
+
disabled={pending}
|
|
535
|
+
aria-label={t('detail.schedule.maxRuns')}
|
|
536
|
+
onChange={event => { setMaxRunsText(event.target.value) }}
|
|
537
|
+
onBlur={() => { saveCustomMaxRuns(maxRunsText) }}
|
|
538
|
+
/>
|
|
539
|
+
</label>
|
|
540
|
+
)}
|
|
541
|
+
</>
|
|
542
|
+
)}
|
|
327
543
|
{/* A list is used instead of a free-text field so only zones the Host can
|
|
328
544
|
also resolve are offered; the Host zone entry clears the stored value. */}
|
|
329
545
|
<label className={css.scheduleZone}>
|
|
@@ -333,7 +549,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
333
549
|
value={zone}
|
|
334
550
|
disabled={pending}
|
|
335
551
|
aria-label={t('detail.schedule.timeZone')}
|
|
336
|
-
title={t('detail.schedule.timeZoneHint')}
|
|
552
|
+
title={mode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
|
|
337
553
|
onChange={event => { changeZone(event.target.value) }}
|
|
338
554
|
>
|
|
339
555
|
{zones.map(choice => (
|
|
@@ -346,10 +562,30 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
346
562
|
{t('detail.schedule.nextRun')} {nextLabel}
|
|
347
563
|
{' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
|
|
348
564
|
</p>
|
|
565
|
+
{budget !== undefined && <p className={css.scheduleMeta}>{budget}</p>}
|
|
566
|
+
{ended !== undefined && <p className={css.scheduleMeta}>{ended}</p>}
|
|
567
|
+
{ended === undefined && skipped !== undefined && <p className={css.scheduleMeta}>{skipped}</p>}
|
|
568
|
+
{enabled && (
|
|
569
|
+
<button type="button" className={css.ghostButton} disabled={pending} onClick={cancelSchedule}>
|
|
570
|
+
{t('detail.schedule.cancel')}
|
|
571
|
+
</button>
|
|
572
|
+
)}
|
|
349
573
|
</section>
|
|
350
574
|
)
|
|
351
575
|
}
|
|
352
576
|
|
|
577
|
+
/**
|
|
578
|
+
* The run hint of one card: how many subtasks a run opens, counting the same
|
|
579
|
+
* members the Host will launch (`joinsCascadeRun` leaves a finished subtask
|
|
580
|
+
* alone), or why it opens none even though the card has subtasks.
|
|
581
|
+
*/
|
|
582
|
+
function subtaskRunHint(parentId: string, children: readonly TaskRecord[]): string {
|
|
583
|
+
const runnable = children.filter(child => joinsCascadeRun(child, parentId))
|
|
584
|
+
return runnable.length > 0
|
|
585
|
+
? t('detail.subtasks.runHint', { count: String(runnable.length) })
|
|
586
|
+
: t('detail.subtasks.runHintSettled')
|
|
587
|
+
}
|
|
588
|
+
|
|
353
589
|
/**
|
|
354
590
|
* The subtask block: the parent link, the direct subtasks with their status,
|
|
355
591
|
* and the actions that grow or prune the tree. Every gate here mirrors the
|
|
@@ -420,7 +656,7 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
|
|
|
420
656
|
</ul>
|
|
421
657
|
)}
|
|
422
658
|
{children.length > 0 && (
|
|
423
|
-
<p className={css.detailText}>{
|
|
659
|
+
<p className={css.detailText}>{subtaskRunHint(task.id, children)}</p>
|
|
424
660
|
)}
|
|
425
661
|
{!archived && (canAddChild
|
|
426
662
|
? (controller.isHostBacked()
|
|
@@ -611,6 +847,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
|
|
|
611
847
|
pending={pending}
|
|
612
848
|
executionOptions={snapshot.executionOptions}
|
|
613
849
|
teamRunAvailable={snapshot.host?.teamRunAvailable === true}
|
|
850
|
+
goalRunEnabled={snapshot.host?.goalRunEnabled === true}
|
|
614
851
|
/>
|
|
615
852
|
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
616
853
|
</>
|
|
@@ -693,7 +930,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
|
|
|
693
930
|
className={css.primaryButton}
|
|
694
931
|
disabled={busy || pending}
|
|
695
932
|
title={subtaskChildren.length > 0
|
|
696
|
-
?
|
|
933
|
+
? subtaskRunHint(current.id, subtaskChildren)
|
|
697
934
|
: undefined}
|
|
698
935
|
onClick={() => {
|
|
699
936
|
void controller.rerunTask(current.id).then(() => {
|