@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
|
@@ -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
15
|
import { DEFAULT_SUBTASK_DEPTH, directSubtasks, 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'
|
|
@@ -39,8 +39,12 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
39
39
|
/** One execution-history row. */
|
|
40
40
|
function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
|
|
41
41
|
const result = execution.result
|
|
42
|
+
// An external record is one the Host never observed running: no session, no
|
|
43
|
+
// spinner, and a named outside agent instead of the initiating session. The
|
|
44
|
+
// data attribute keeps the distinction machine-readable too.
|
|
45
|
+
const external = execution.external === true
|
|
42
46
|
return (
|
|
43
|
-
<li className={css.executionRow} data-result={result}>
|
|
47
|
+
<li className={css.executionRow} data-result={result} data-external={external ? 'true' : undefined}>
|
|
44
48
|
<span className={css.executionBadge} data-result={result}>
|
|
45
49
|
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
46
50
|
</span>
|
|
@@ -48,7 +52,12 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
48
52
|
{t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
|
|
49
53
|
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
|
|
50
54
|
</span>
|
|
51
|
-
{execution.initiatedBy !== undefined && (
|
|
55
|
+
{external && execution.initiatedBy !== undefined && (
|
|
56
|
+
<span className={css.executionTimes} title={execution.initiatedBy}>
|
|
57
|
+
{t('detail.execution.external', { agent: execution.initiatedBy })}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
{!external && execution.initiatedBy !== undefined && (
|
|
52
61
|
<span className={css.executionTimes} title={execution.initiatedBy}>
|
|
53
62
|
{t('detail.execution.initiator', { session: execution.initiatedBy })}
|
|
54
63
|
</span>
|
|
@@ -73,7 +82,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
|
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
76
|
-
function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
|
|
85
|
+
function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable, goalRunEnabled }: {
|
|
77
86
|
controller: BoardController
|
|
78
87
|
task: TaskRecord
|
|
79
88
|
pending: boolean
|
|
@@ -81,6 +90,12 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
81
90
|
executionOptions: ControllerSnapshot['executionOptions']
|
|
82
91
|
/** Whether this deployment serves the Agent Teams service. */
|
|
83
92
|
teamRunAvailable: boolean
|
|
93
|
+
/**
|
|
94
|
+
* Live GLOBAL native-/goal switch. It only decides whether the card's own
|
|
95
|
+
* preference takes effect: the option stays operable while it is off, the
|
|
96
|
+
* stored value is never rewritten by it, and the explanation below says so.
|
|
97
|
+
*/
|
|
98
|
+
goalRunEnabled: boolean
|
|
84
99
|
}) {
|
|
85
100
|
const options = executionOptions
|
|
86
101
|
const workspaceId = task.workspaceId ?? ''
|
|
@@ -173,13 +188,17 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
173
188
|
<label className={css.scheduleToggle}>
|
|
174
189
|
<input
|
|
175
190
|
type="checkbox"
|
|
176
|
-
checked={task.goalRun
|
|
191
|
+
checked={task.goalRun === true}
|
|
192
|
+
// Always operable (only a pending execution locks it): the global
|
|
193
|
+
// switch decides whether the preference takes effect, never whether
|
|
194
|
+
// the user may state it, and it never rewrites what is stored here.
|
|
177
195
|
disabled={pending}
|
|
178
196
|
onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
|
|
179
197
|
/>
|
|
180
198
|
<span>{t('exec.goalRun')}</span>
|
|
181
199
|
</label>
|
|
182
200
|
<p className={css.detailText}>{t('exec.goalRunHint')}</p>
|
|
201
|
+
{!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
|
|
183
202
|
<label className={css.scheduleToggle}>
|
|
184
203
|
<input
|
|
185
204
|
type="checkbox"
|
|
@@ -204,31 +223,49 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
|
|
|
204
223
|
)
|
|
205
224
|
}
|
|
206
225
|
|
|
207
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* The scheduled-runs editor. A one-shot has one planned instant; a recurring
|
|
228
|
+
* rule has a cron expression, an optional run cap, and a zone. The editor
|
|
229
|
+
* shows the rule's own state (next target, run counter, stop/skip record) and
|
|
230
|
+
* can cancel a future plan.
|
|
231
|
+
*/
|
|
208
232
|
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
209
233
|
const schedule = task.schedule
|
|
234
|
+
const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
235
|
+
// The rule's own zone; '' means "follow the Host zone" (no stored zone).
|
|
236
|
+
const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
|
|
237
|
+
const [mode, setMode] = useState<ScheduleMode>(schedule?.mode ?? 'cron')
|
|
210
238
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
239
|
+
const [atText, setAtText] = useState<string>(() => schedule?.at === undefined
|
|
240
|
+
? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
|
|
241
|
+
: zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
|
|
242
|
+
const [maxRunsChoice, setMaxRunsChoice] = useState<string>(() => runCapOption(schedule?.maxRuns))
|
|
243
|
+
const [maxRunsText, setMaxRunsText] = useState<string>(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
211
244
|
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
212
245
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
213
246
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
214
247
|
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
248
|
// One clock reading per render pass, so the absolute and relative halves of
|
|
219
249
|
// the label always describe the same instant.
|
|
220
250
|
const now = Date.now()
|
|
221
251
|
|
|
222
252
|
// Keep the editor in sync when the task record changes underneath (the
|
|
223
|
-
// schedule rolls forward as runs trigger
|
|
253
|
+
// schedule rolls forward as runs trigger, ends at its budget, or is edited
|
|
254
|
+
// elsewhere).
|
|
224
255
|
useEffect(() => {
|
|
256
|
+
setMode(schedule?.mode ?? 'cron')
|
|
225
257
|
setCron(schedule?.cron ?? '0 9 * * *')
|
|
258
|
+
setZone(schedule?.timeZone ?? '')
|
|
259
|
+
setAtText(schedule?.at === undefined
|
|
260
|
+
? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
|
|
261
|
+
: zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
|
|
262
|
+
setMaxRunsChoice(runCapOption(schedule?.maxRuns))
|
|
263
|
+
setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
226
264
|
setEnabled(schedule?.enabled ?? false)
|
|
227
265
|
setNextRunAt(schedule?.nextRunAt)
|
|
228
266
|
setLastTriggeredAt(schedule?.lastTriggeredAt)
|
|
229
|
-
setZone(schedule?.timeZone ?? '')
|
|
230
267
|
setError(undefined)
|
|
231
|
-
}, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
|
|
268
|
+
}, [task.id, hostTimeZone, schedule?.mode, schedule?.enabled, schedule?.cron, schedule?.at, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt, schedule?.maxRuns])
|
|
232
269
|
|
|
233
270
|
/** Validate + persist the current cron text (Enter or blur). */
|
|
234
271
|
const saveCron = (value: string): void => {
|
|
@@ -239,21 +276,101 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
239
276
|
return
|
|
240
277
|
}
|
|
241
278
|
setError(undefined)
|
|
242
|
-
controller.setSchedule(task.id, { cron: trimmed })
|
|
279
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Validate + persist the one-shot's planned instant. */
|
|
283
|
+
const saveAt = (value: string): void => {
|
|
284
|
+
setAtText(value)
|
|
285
|
+
const at = parseZonedInput(value, effectiveZone)
|
|
286
|
+
if (at === undefined || at <= now) {
|
|
287
|
+
setError(t('detail.schedule.atInvalid'))
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
setError(undefined)
|
|
291
|
+
controller.setSchedule(task.id, { mode: 'once', at })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Switch the kind of plan, persisting a usable target for the new kind. */
|
|
295
|
+
const changeMode = (next: ScheduleMode): void => {
|
|
296
|
+
if (next === 'once') {
|
|
297
|
+
const at = parseZonedInput(atText, effectiveZone)
|
|
298
|
+
if (at === undefined || at <= now) {
|
|
299
|
+
setError(t('detail.schedule.atInvalid'))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
setMode('once')
|
|
303
|
+
setError(undefined)
|
|
304
|
+
controller.setSchedule(task.id, { mode: 'once', at })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const trimmed = cron.trim()
|
|
308
|
+
if (trimmed === '' || !isValidCron(trimmed)) {
|
|
309
|
+
setError(t('detail.schedule.invalid'))
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
setMode('cron')
|
|
313
|
+
setError(undefined)
|
|
314
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
|
|
243
315
|
}
|
|
244
316
|
|
|
245
317
|
/**
|
|
246
318
|
* Change the rule's zone. `''` clears the stored zone, which is how a user
|
|
247
|
-
* returns a rule to following the Host zone.
|
|
319
|
+
* returns a rule to following the Host zone. A one-shot's instant is
|
|
320
|
+
* absolute, so only its rendered wall clock follows the new zone; a cron
|
|
321
|
+
* rule's wall clock itself moves with the zone.
|
|
248
322
|
*/
|
|
249
323
|
const changeZone = (value: string): void => {
|
|
250
324
|
setZone(value)
|
|
251
325
|
setError(undefined)
|
|
326
|
+
if (mode === 'once' && schedule?.at !== undefined) {
|
|
327
|
+
setAtText(zonedInputValue(schedule.at, value === '' ? hostTimeZone : value))
|
|
328
|
+
controller.setSchedule(task.id, { mode: 'once', timeZone: value === '' ? null : value })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
252
331
|
controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
|
|
253
332
|
}
|
|
254
333
|
|
|
255
|
-
/**
|
|
334
|
+
/** Change the recurring rule's run cap (unlimited, a preset, or a custom value). */
|
|
335
|
+
const applyMaxRuns = (choice: string): void => {
|
|
336
|
+
setMaxRunsChoice(choice)
|
|
337
|
+
setError(undefined)
|
|
338
|
+
if (choice === 'unlimited') {
|
|
339
|
+
controller.setSchedule(task.id, { maxRuns: null })
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
if (choice === 'custom') {
|
|
343
|
+
setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
controller.setSchedule(task.id, { maxRuns: Number(choice) })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Persist a custom run cap; a non-positive or fractional value is refused. */
|
|
350
|
+
const saveCustomMaxRuns = (value: string): void => {
|
|
351
|
+
setMaxRunsText(value)
|
|
352
|
+
const parsed = Number(value.trim())
|
|
353
|
+
if (value.trim() === '' || !Number.isInteger(parsed) || parsed < 1) {
|
|
354
|
+
setError(t('detail.schedule.maxRunsInvalid'))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
setError(undefined)
|
|
358
|
+
controller.setSchedule(task.id, { maxRuns: parsed })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Arm/disarm the schedule (arming first persists the edited plan). */
|
|
256
362
|
const toggleEnabled = (next: boolean): void => {
|
|
363
|
+
if (next && mode === 'once') {
|
|
364
|
+
const at = parseZonedInput(atText, effectiveZone)
|
|
365
|
+
if (at === undefined || at <= now) {
|
|
366
|
+
setError(t('detail.schedule.atInvalid'))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
setError(undefined)
|
|
370
|
+
const submitted = controller.setSchedule(task.id, { mode: 'once', at, enabled: true })
|
|
371
|
+
if (submitted && !controller.isHostBacked()) setEnabled(true)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
257
374
|
const trimmed = cron.trim()
|
|
258
375
|
if (next && (trimmed === '' || !isValidCron(trimmed))) {
|
|
259
376
|
setError(t('detail.schedule.invalid'))
|
|
@@ -267,11 +384,19 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
267
384
|
if (submitted && !controller.isHostBacked()) setEnabled(next)
|
|
268
385
|
}
|
|
269
386
|
|
|
387
|
+
/** Drop every future trigger without touching the card's execution history. */
|
|
388
|
+
const cancelSchedule = (): void => {
|
|
389
|
+
setError(undefined)
|
|
390
|
+
const submitted = controller.setSchedule(task.id, { enabled: false })
|
|
391
|
+
if (submitted && !controller.isHostBacked()) setEnabled(false)
|
|
392
|
+
}
|
|
393
|
+
|
|
270
394
|
const applyPreset = (preset: string): void => {
|
|
271
395
|
if (preset === '') return
|
|
272
396
|
setCron(preset)
|
|
397
|
+
setMode('cron')
|
|
273
398
|
setError(undefined)
|
|
274
|
-
controller.setSchedule(task.id, { cron: preset })
|
|
399
|
+
controller.setSchedule(task.id, { mode: 'cron', cron: preset })
|
|
275
400
|
}
|
|
276
401
|
|
|
277
402
|
// The zone the rule's wall clock is actually read in: its own when set,
|
|
@@ -286,6 +411,11 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
286
411
|
// The rule's own stored zone rides the list even when this runtime's
|
|
287
412
|
// inventory does not carry it, so opening the editor cannot rewrite it.
|
|
288
413
|
const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
|
|
414
|
+
// The ledger's own view of the budget and stop record: derived here rather
|
|
415
|
+
// than tracked in local state, so the display cannot drift from the Host.
|
|
416
|
+
const budget = schedule === undefined ? undefined : budgetLabel(schedule)
|
|
417
|
+
const ended = schedule === undefined ? undefined : endedLabel(schedule)
|
|
418
|
+
const skipped = schedule === undefined ? undefined : skippedLabel(schedule)
|
|
289
419
|
|
|
290
420
|
return (
|
|
291
421
|
<section className={css.detailSection}>
|
|
@@ -299,6 +429,35 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
299
429
|
/>
|
|
300
430
|
<span>{t('detail.schedule.enable')}</span>
|
|
301
431
|
</label>
|
|
432
|
+
<label className={css.scheduleZone}>
|
|
433
|
+
<span>{t('detail.schedule.mode')}</span>
|
|
434
|
+
<select
|
|
435
|
+
className={css.schedulePreset}
|
|
436
|
+
value={mode}
|
|
437
|
+
disabled={pending}
|
|
438
|
+
aria-label={t('detail.schedule.mode')}
|
|
439
|
+
onChange={event => { changeMode(event.target.value as ScheduleMode) }}
|
|
440
|
+
>
|
|
441
|
+
<option value="cron">{t('detail.schedule.mode.cron')}</option>
|
|
442
|
+
<option value="once">{t('detail.schedule.mode.once')}</option>
|
|
443
|
+
</select>
|
|
444
|
+
</label>
|
|
445
|
+
{mode === 'once' ? (
|
|
446
|
+
<label className={css.scheduleZone}>
|
|
447
|
+
<span>{t('detail.schedule.at')}</span>
|
|
448
|
+
<input
|
|
449
|
+
className={css.input}
|
|
450
|
+
type="datetime-local"
|
|
451
|
+
value={atText}
|
|
452
|
+
disabled={pending}
|
|
453
|
+
aria-label={t('detail.schedule.at')}
|
|
454
|
+
title={t('detail.schedule.atHint')}
|
|
455
|
+
onChange={event => { setAtText(event.target.value); setError(undefined) }}
|
|
456
|
+
onBlur={() => { saveAt(atText) }}
|
|
457
|
+
/>
|
|
458
|
+
</label>
|
|
459
|
+
) : (
|
|
460
|
+
<>
|
|
302
461
|
<div className={css.scheduleRow}>
|
|
303
462
|
<input
|
|
304
463
|
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
@@ -324,6 +483,39 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
324
483
|
))}
|
|
325
484
|
</select>
|
|
326
485
|
</div>
|
|
486
|
+
<label className={css.scheduleZone}>
|
|
487
|
+
<span>{t('detail.schedule.maxRuns')}</span>
|
|
488
|
+
<select
|
|
489
|
+
className={css.schedulePreset}
|
|
490
|
+
value={maxRunsChoice}
|
|
491
|
+
disabled={pending}
|
|
492
|
+
aria-label={t('detail.schedule.maxRuns')}
|
|
493
|
+
onChange={event => { applyMaxRuns(event.target.value) }}
|
|
494
|
+
>
|
|
495
|
+
<option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
|
|
496
|
+
<option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
|
|
497
|
+
<option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
|
|
498
|
+
<option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
|
|
499
|
+
</select>
|
|
500
|
+
</label>
|
|
501
|
+
{maxRunsChoice === 'custom' && (
|
|
502
|
+
<label className={css.scheduleZone}>
|
|
503
|
+
<span>{t('detail.schedule.maxRuns')}</span>
|
|
504
|
+
<input
|
|
505
|
+
className={css.input}
|
|
506
|
+
type="number"
|
|
507
|
+
min={1}
|
|
508
|
+
step={1}
|
|
509
|
+
value={maxRunsText}
|
|
510
|
+
disabled={pending}
|
|
511
|
+
aria-label={t('detail.schedule.maxRuns')}
|
|
512
|
+
onChange={event => { setMaxRunsText(event.target.value) }}
|
|
513
|
+
onBlur={() => { saveCustomMaxRuns(maxRunsText) }}
|
|
514
|
+
/>
|
|
515
|
+
</label>
|
|
516
|
+
)}
|
|
517
|
+
</>
|
|
518
|
+
)}
|
|
327
519
|
{/* A list is used instead of a free-text field so only zones the Host can
|
|
328
520
|
also resolve are offered; the Host zone entry clears the stored value. */}
|
|
329
521
|
<label className={css.scheduleZone}>
|
|
@@ -333,7 +525,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
333
525
|
value={zone}
|
|
334
526
|
disabled={pending}
|
|
335
527
|
aria-label={t('detail.schedule.timeZone')}
|
|
336
|
-
title={t('detail.schedule.timeZoneHint')}
|
|
528
|
+
title={mode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
|
|
337
529
|
onChange={event => { changeZone(event.target.value) }}
|
|
338
530
|
>
|
|
339
531
|
{zones.map(choice => (
|
|
@@ -346,6 +538,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
346
538
|
{t('detail.schedule.nextRun')} {nextLabel}
|
|
347
539
|
{' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
|
|
348
540
|
</p>
|
|
541
|
+
{budget !== undefined && <p className={css.scheduleMeta}>{budget}</p>}
|
|
542
|
+
{ended !== undefined && <p className={css.scheduleMeta}>{ended}</p>}
|
|
543
|
+
{ended === undefined && skipped !== undefined && <p className={css.scheduleMeta}>{skipped}</p>}
|
|
544
|
+
{enabled && (
|
|
545
|
+
<button type="button" className={css.ghostButton} disabled={pending} onClick={cancelSchedule}>
|
|
546
|
+
{t('detail.schedule.cancel')}
|
|
547
|
+
</button>
|
|
548
|
+
)}
|
|
349
549
|
</section>
|
|
350
550
|
)
|
|
351
551
|
}
|
|
@@ -611,6 +811,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
|
|
|
611
811
|
pending={pending}
|
|
612
812
|
executionOptions={snapshot.executionOptions}
|
|
613
813
|
teamRunAvailable={snapshot.host?.teamRunAvailable === true}
|
|
814
|
+
goalRunEnabled={snapshot.host?.goalRunEnabled === true}
|
|
614
815
|
/>
|
|
615
816
|
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
616
817
|
</>
|
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
* execution row's existing markup instead of extending that enum.
|
|
14
14
|
*/
|
|
15
15
|
import {
|
|
16
|
+
MAX_INVALID_ATTEMPTS,
|
|
16
17
|
MAX_QUALITY_ATTEMPTS,
|
|
17
18
|
exceptionAttempts,
|
|
19
|
+
invalidAttempts,
|
|
18
20
|
qualityAttempts,
|
|
19
21
|
verificationPhase,
|
|
20
22
|
verificationTotals,
|
|
23
|
+
type AcceptanceAuditRecord,
|
|
24
|
+
type AcceptanceCleanupRecord,
|
|
21
25
|
type ExecutionVerification,
|
|
22
26
|
type VerificationAttempt,
|
|
27
|
+
type VerificationCriterionFinding,
|
|
23
28
|
} from '../../core/verification.ts'
|
|
24
29
|
import { t } from '../locales.ts'
|
|
25
30
|
import type { TaskBoardKey } from '../locales.ts'
|
|
@@ -46,24 +51,67 @@ const PHASE_STATUS_KEY: Record<ReturnType<typeof verificationPhase>, TaskBoardKe
|
|
|
46
51
|
repairing: 'verify.status.failed',
|
|
47
52
|
passed: 'verify.status.passed',
|
|
48
53
|
failed: 'verify.status.failed',
|
|
54
|
+
invalid: 'verify.status.invalidHeld',
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
/** Evidence location → locale key, for one structured finding's citation. */
|
|
58
|
+
const LOCATION_KEY: Record<VerificationCriterionFinding['location'], TaskBoardKey> = {
|
|
59
|
+
task: 'verify.location.task',
|
|
60
|
+
trajectory: 'verify.location.trajectory',
|
|
61
|
+
workspace: 'verify.location.workspace',
|
|
62
|
+
baseline: 'verify.location.baseline',
|
|
63
|
+
unknown: 'verify.location.unknown',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Invalid reason → locale key. */
|
|
67
|
+
function invalidReasonKey(attempt: VerificationAttempt): TaskBoardKey {
|
|
68
|
+
switch (attempt.invalidReason) {
|
|
69
|
+
case 'missing-finding': return 'verify.invalidReason.missing-finding'
|
|
70
|
+
case 'unlocatable-quote': return 'verify.invalidReason.unlocatable-quote'
|
|
71
|
+
case 'baseline-finding': return 'verify.invalidReason.baseline-finding'
|
|
72
|
+
case 'vacuous-finding': return 'verify.invalidReason.vacuous-finding'
|
|
73
|
+
case 'insufficient-evidence': return 'verify.invalidReason.insufficient-evidence'
|
|
74
|
+
default: return 'verify.invalidReason.unknown'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Audit stage → locale key (the lightweight credential after cleanup). */
|
|
79
|
+
const AUDIT_STAGE_KEY: Record<AcceptanceAuditRecord['stage'], TaskBoardKey> = {
|
|
80
|
+
quality: 'verify.cleanup.stage.quality',
|
|
81
|
+
invalid: 'verify.cleanup.stage.invalid',
|
|
82
|
+
exception: 'verify.cleanup.stage.exception',
|
|
83
|
+
budget: 'verify.cleanup.stage.budget',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One recorded attempt: a quality verdict, an invalid veto, an anomaly, or a budget stop. */
|
|
87
|
+
function AttemptRow({ attempt, detailRemoved }: { attempt: VerificationAttempt, detailRemoved: boolean }) {
|
|
88
|
+
// Everything that is not a quality verdict renders as an anomaly row: a
|
|
89
|
+
// budget stop carries no score either, and an invalid acceptance carries a
|
|
90
|
+
// score the board REFUSED to book, so showing either as a scored failure
|
|
91
|
+
// would read as a judgement the board never made. The invalid row gets its
|
|
92
|
+
// own badge and reason line, because it is a distinct outcome.
|
|
93
|
+
const invalid = attempt.stage === 'invalid'
|
|
94
|
+
const exception = attempt.stage !== 'quality' && !invalid
|
|
54
95
|
const result = exception ? 'failed' : attempt.passed ? 'succeeded' : 'failed'
|
|
55
96
|
return (
|
|
56
97
|
<li className={css.executionRow} data-result={result}>
|
|
57
98
|
<span className={css.executionBadge} data-result={result}>
|
|
58
|
-
{exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
|
|
99
|
+
{invalid ? t('verify.status.invalid') : exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
|
|
59
100
|
</span>
|
|
60
101
|
<span className={css.executionTimes}>
|
|
61
|
-
{
|
|
62
|
-
? t('verify.
|
|
63
|
-
:
|
|
102
|
+
{invalid
|
|
103
|
+
? t('verify.attemptInvalid', { index: String(attempt.index) })
|
|
104
|
+
: exception
|
|
105
|
+
? t('verify.attemptException', { index: String(attempt.index) })
|
|
106
|
+
: t('verify.attempt', { index: String(attempt.index) })}
|
|
64
107
|
{' · '}{routeLabel(attempt)}
|
|
65
|
-
{!exception && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
|
|
108
|
+
{!exception && !invalid && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
|
|
66
109
|
</span>
|
|
110
|
+
{invalid && (
|
|
111
|
+
<span className={css.executionTimes}>
|
|
112
|
+
{t('verify.invalidDetail', { reason: t(invalidReasonKey(attempt)) })}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
67
115
|
{attempt.error !== undefined && <span className={css.executionTimes}>{attempt.error}</span>}
|
|
68
116
|
{!exception && (
|
|
69
117
|
<>
|
|
@@ -88,6 +136,25 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
|
|
|
88
136
|
</ul>
|
|
89
137
|
</>
|
|
90
138
|
)}
|
|
139
|
+
{(attempt.criterionFindings ?? []).length > 0 && (
|
|
140
|
+
<>
|
|
141
|
+
<span className={css.executionTimes}>{t('verify.criterionFindings')}</span>
|
|
142
|
+
<ul className={css.executionList}>
|
|
143
|
+
{(attempt.criterionFindings ?? []).map((finding, index) => (
|
|
144
|
+
<li key={findingKey(attempt, index)} className={css.executionTimes}>
|
|
145
|
+
{t('verify.criterionFindingFull', {
|
|
146
|
+
criterion: finding.criterionId,
|
|
147
|
+
location: t(LOCATION_KEY[finding.location]),
|
|
148
|
+
quote: finding.quote,
|
|
149
|
+
requirement: finding.requirement,
|
|
150
|
+
observation: finding.observation,
|
|
151
|
+
gap: finding.gap,
|
|
152
|
+
})}
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
91
158
|
{attempt.findings.length > 0 && (
|
|
92
159
|
<>
|
|
93
160
|
<span className={css.executionTimes}>{t('verify.findings')}</span>
|
|
@@ -98,8 +165,12 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
|
|
|
98
165
|
</ul>
|
|
99
166
|
</>
|
|
100
167
|
)}
|
|
101
|
-
{!exception && attempt.findings.length === 0 && (
|
|
102
|
-
|
|
168
|
+
{!exception && !invalid && attempt.findings.length === 0 && (
|
|
169
|
+
// A cleaned attempt has no findings because the acceptance removed them,
|
|
170
|
+
// not because the judge gave none: saying "the judge reported no
|
|
171
|
+
// finding" here would invite exactly the misreading the cleanup rule
|
|
172
|
+
// exists to avoid.
|
|
173
|
+
<span className={css.executionTimes}>{t(detailRemoved ? 'verify.detailRemoved' : 'verify.noFindings')}</span>
|
|
103
174
|
)}
|
|
104
175
|
<span className={css.executionTimes}>
|
|
105
176
|
{t('verify.evidence', {
|
|
@@ -145,16 +216,20 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
|
|
|
145
216
|
? 'verify.applicability.skipped'
|
|
146
217
|
: verification.applicability === 'goal-unavailable'
|
|
147
218
|
? 'verify.applicability.goalUnavailable'
|
|
148
|
-
: verification.applicability === '
|
|
219
|
+
: verification.applicability === 'goal-disabled'
|
|
220
|
+
? 'verify.applicability.goalDisabled'
|
|
221
|
+
: verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
|
|
149
222
|
return (
|
|
150
223
|
<div className={css.executionTimes}>
|
|
151
224
|
<span className={css.executionBadge} data-result={phase === 'passed' ? 'succeeded' : phase === 'failed' ? 'failed' : undefined}>
|
|
152
225
|
{t('verify.title')} · {t(PHASE_STATUS_KEY[phase])}
|
|
153
226
|
</span>
|
|
154
227
|
<span className={css.executionTimes}>
|
|
155
|
-
{t('verify.
|
|
228
|
+
{t('verify.invalidCounts', {
|
|
156
229
|
quality: String(qualityAttempts(verification).length),
|
|
157
230
|
max: String(MAX_QUALITY_ATTEMPTS),
|
|
231
|
+
invalid: String(invalidAttempts(verification).length),
|
|
232
|
+
maxInvalid: String(MAX_INVALID_ATTEMPTS),
|
|
158
233
|
exceptions: String(exceptionAttempts(verification).length),
|
|
159
234
|
})}
|
|
160
235
|
</span>
|
|
@@ -194,10 +269,54 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
|
|
|
194
269
|
{verification.attempts.length > 0 && (
|
|
195
270
|
<ul className={css.executionList}>
|
|
196
271
|
{[...verification.attempts].reverse().map(attempt => (
|
|
197
|
-
<AttemptRow
|
|
272
|
+
<AttemptRow
|
|
273
|
+
key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)}
|
|
274
|
+
attempt={attempt}
|
|
275
|
+
detailRemoved={verification.cleanup?.state === 'cleaned'}
|
|
276
|
+
/>
|
|
198
277
|
))}
|
|
199
278
|
</ul>
|
|
200
279
|
)}
|
|
280
|
+
{verification.cleanup !== undefined && <CleanupNotice cleanup={verification.cleanup} />}
|
|
201
281
|
</div>
|
|
202
282
|
)
|
|
203
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Retention notice of one execution's acceptance detail.
|
|
287
|
+
*
|
|
288
|
+
* It exists so a reader never mistakes a cleaned-up report for a judge that
|
|
289
|
+
* produced no evidence: the notice says the detail WAS produced and was removed
|
|
290
|
+
* by the automatic cleanup, and lists the lightweight credential that survives.
|
|
291
|
+
* A pending or failed cleanup is reported the same way — never as a missing
|
|
292
|
+
* verdict and never as a revoked pass.
|
|
293
|
+
*/
|
|
294
|
+
function CleanupNotice({ cleanup }: { cleanup: AcceptanceCleanupRecord }) {
|
|
295
|
+
const key: TaskBoardKey = cleanup.state === 'cleaned'
|
|
296
|
+
? 'verify.cleanup.cleaned'
|
|
297
|
+
: cleanup.state === 'failed' ? 'verify.cleanup.failed' : 'verify.cleanup.pending'
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
<span className={css.executionTimes}>
|
|
301
|
+
{cleanup.state === 'failed'
|
|
302
|
+
? t(key, { attempts: String(cleanup.attempts), error: cleanup.lastError ?? '' })
|
|
303
|
+
: t(key)}
|
|
304
|
+
</span>
|
|
305
|
+
{cleanup.audit.length > 0 && (
|
|
306
|
+
<ul className={css.executionList}>
|
|
307
|
+
{cleanup.audit.map((record, index) => (
|
|
308
|
+
<li key={record.at.toString(36) + '-' + record.stage + '-' + String(index)} className={css.executionTimes}>
|
|
309
|
+
{t('verify.cleanup.audit', {
|
|
310
|
+
index: String(index + 1),
|
|
311
|
+
stage: t(AUDIT_STAGE_KEY[record.stage]),
|
|
312
|
+
score: percent(record.score),
|
|
313
|
+
hash: record.evidenceHash.slice(0, 12),
|
|
314
|
+
})}
|
|
315
|
+
{record.findingSummary === undefined ? '' : ' · ' + record.findingSummary}
|
|
316
|
+
</li>
|
|
317
|
+
))}
|
|
318
|
+
</ul>
|
|
319
|
+
)}
|
|
320
|
+
</>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
@@ -23,6 +23,9 @@ export const VERIFICATION_PHASE_KEY: Record<VerificationPhase, TaskBoardKey> = {
|
|
|
23
23
|
repairing: 'running.repairing',
|
|
24
24
|
passed: 'running.verificationPassed',
|
|
25
25
|
failed: 'running.verificationFailed',
|
|
26
|
+
// Held for a human: the judge produced no usable veto, so this is NOT the
|
|
27
|
+
// quality-failure label.
|
|
28
|
+
invalid: 'running.verificationInvalid',
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|