feat(jobs): jobs declare their own run parameters
`JobRunArgs` was a fixed struct — `force`, `deep`, `storage`, `repair` — and six places hardcoded that same list: the engine's persist/restore, the trigger endpoint's query type, the OXICLOUD_STARTUP_JOBS parser, the frontend API wrapper, the panel's checkboxes, and `StartupTrigger` on the wire. Two costs. Adding a parameter meant editing all six, and forgetting one dropped it silently — most damagingly in persist/restore, where a resumed run lost it and a `?repair=true` migration came back as discovery-only after a restart. And the panel offered the same knobs on every job: only two jobs read `deep`, six read `repair`, so most of those controls did nothing with no way to tell which. Now `JobHandler::parameters()` returns `&'static [JobParam]` — name, type (boolean/string/number), default, and the job's own description of what it does. `JobRunArgs` holds a map keyed by those names. Everything reads the declaration: * `run_or_resume` iterates it to persist and restore, replacing `const FLAGS` plus a `storage` special case. `storage` stops being special — it was the one Option<String> among three bools. * `dispatch` normalises every run against it, which is what makes "a handler sees its declared parameters with their declared defaults" true rather than usual. The periodic tick passes an empty `JobRunArgs::default()`, so a `default: true` parameter would otherwise read false on every scheduled run. * The trigger endpoint takes free-form query params and rejects undeclared ones with a 400 naming the real set, instead of ignoring them. * OXICLOUD_STARTUP_JOBS keeps raw pairs (config is parsed before the registry exists) and validates at dispatch, where the error can name the job's actual parameters. Still a boot panic, same as an unknown job name — a typo'd `?repare=true` must not leave a migration importing forever in discovery mode. * `JobSummary.parameters` carries it to the panel, whose `supportsDeep` was a hardcoded name allowlist (`consistency_batch || backend_consistency`). A job gaining a deep mode needed a frontend release; one losing it left a button that silently did nothing. The menu now renders from the declaration, so a newly-declared boolean appears with no frontend change. Three consistency tenants were hand-rolling persist-on-fresh / restore-on-resume for their own flag, under the same `params` key the engine already used. Deleted — they read `args.get_bool(…)` now. Fresh runs also filter to the declaration. `consistency_batch` forwards its args verbatim to sub-jobs, so a tenant's `params` row could grow `deep` with no deep mode, and the run-detail view would claim a mode the job never had. Two things found while wiring it, both worth knowing: `RecoverableAdapter` bridges the two traits, and `parameters` has to be forwarded there or the registry sees `&[]`. Both traits have defaults, so omitting it compiled cleanly — and the trigger endpoint then rejected `?repair=true` on the very jobs that declare it, with OXICLOUD_STARTUP_JOBS panicking at boot. Now covered by `adapter_forwards_job_metadata_from_inner_handler`. `TriggerJobQuery` was briefly a newtype over the map. `serde_urlencoded` cannot deserialize a newtype struct at the top level, so axum's `Query` rejected EVERY trigger with a 400 — even one with no query string — before the handler ran. It reads exactly like the new validation rejecting something, which sent the first diagnosis to the wrong layer. Now covered by `trigger_query_extracts_from_every_url_shape`. Wire names are a compatibility surface: `params` rows are keyed by them and the panel switches on them, so a rename breaks existing run history the same way renaming a `Mutates` variant does. The JSON shape is pinned in `snapshot_carries_job_metadata`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
cancelJob,
|
||||
purgeJobRuns
|
||||
} from '$lib/api/endpoints/adminJobs';
|
||||
import type { Finding, JobSummary, RunSummary, RunStatus } from '$lib/api/types';
|
||||
import type { Finding, JobParam, JobSummary, RunSummary, RunStatus } from '$lib/api/types';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -634,27 +634,43 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Jobs that respect `?deep=true`:
|
||||
// * `consistency_batch` — propagates deep to every child that
|
||||
// understands it
|
||||
// * `backend_consistency` — deep mode re-reads + re-hashes every
|
||||
// matched blob for silent bit-rot detection (severity
|
||||
// `data_loss`). Full read of storage; can take hours on big
|
||||
// installs — the "Run" button on the same row does the
|
||||
// enumeration merge-join only. This was `blobs_consistency`
|
||||
// until that tenant became database-only.
|
||||
function supportsDeep(name: string): boolean {
|
||||
return name === 'consistency_batch' || name === 'backend_consistency';
|
||||
// A declared parameter by name, or undefined.
|
||||
//
|
||||
// Everything below asks the JOB what it accepts
|
||||
// (`JobHandler::parameters()` on the backend) rather than deciding
|
||||
// here. `supportsDeep` used to be a hardcoded name allowlist —
|
||||
// `consistency_batch || backend_consistency` — which meant a job
|
||||
// gaining a deep mode needed a frontend release to become reachable,
|
||||
// and a job losing one left a menu item that silently did nothing.
|
||||
function paramOf(job: JobSummary, name: string): JobParam | undefined {
|
||||
return job.parameters?.find((p) => p.name === name);
|
||||
}
|
||||
|
||||
// Whether `?repair=true` does anything for this job — declared by the
|
||||
// handler itself via `repair_description()`, not by a name allowlist
|
||||
// here. The allowlist this replaces named only the two ref_count
|
||||
// tenants and silently omitted every repair-capable job added since,
|
||||
// so the thumbnail imports could not be run in repair mode from the
|
||||
// panel at all despite supporting it.
|
||||
function supportsDeep(job: JobSummary): boolean {
|
||||
return !!paramOf(job, 'deep');
|
||||
}
|
||||
|
||||
// Both signals must agree. `parameters` says the run accepts the
|
||||
// flag; `repair_description` is the confirmation copy, and a repair
|
||||
// action with no wording would be a destructive click with a blank
|
||||
// dialog. A job declaring one without the other is a backend bug —
|
||||
// render nothing rather than guess.
|
||||
function supportsRepair(job: JobSummary): boolean {
|
||||
return !!job.repair_description;
|
||||
return !!paramOf(job, 'repair') && !!job.repair_description;
|
||||
}
|
||||
|
||||
// Booleans the generic menu renders on its own, beyond the two with
|
||||
// bespoke entries above. This is what makes a newly-declared flag
|
||||
// appear with no frontend change.
|
||||
//
|
||||
// Booleans only: `storage` and any future string/number parameter
|
||||
// need a value, and the places that supply one (the storage tab's
|
||||
// audit / migrate actions) already pass it contextually. A generic
|
||||
// text box in a run menu would be a worse way to ask.
|
||||
function extraBooleanParams(job: JobSummary): JobParam[] {
|
||||
return (job.parameters ?? []).filter(
|
||||
(p) => p.type === 'boolean' && p.name !== 'deep' && p.name !== 'repair'
|
||||
);
|
||||
}
|
||||
|
||||
// What the repair adds, in the handler's own words. The backend owns
|
||||
@@ -872,11 +888,12 @@
|
||||
<td class="jobs-panel__muted">
|
||||
{cadenceLabel(job)}
|
||||
{#if job.startup}
|
||||
{@const bootRepair = job.startup.params?.repair === true}
|
||||
<span
|
||||
class="jobs-panel__pill"
|
||||
class:jobs-panel__pill--paused={job.startup.repair}
|
||||
class:jobs-panel__pill--neutral={!job.startup.repair}
|
||||
title={job.startup.repair
|
||||
class:jobs-panel__pill--paused={bootRepair}
|
||||
class:jobs-panel__pill--neutral={!bootRepair}
|
||||
title={bootRepair
|
||||
? t(
|
||||
'admin.jobs.startup_repair_tooltip',
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run in repair mode at every boot.'
|
||||
@@ -886,7 +903,7 @@
|
||||
'Configured in OXICLOUD_STARTUP_JOBS to run at every boot.'
|
||||
)}
|
||||
>
|
||||
{job.startup.repair
|
||||
{bootRepair
|
||||
? t('admin.jobs.startup_repair', 'at boot · repair')
|
||||
: t('admin.jobs.startup', 'at boot')}
|
||||
</span>
|
||||
@@ -974,7 +991,8 @@
|
||||
button — no chevron, no menu, no extra
|
||||
width. Preserves one-click discovery for
|
||||
the common case. -->
|
||||
{@const hasRunVariants = supportsDeep(job.name) || supportsRepair(job)}
|
||||
{@const hasRunVariants =
|
||||
supportsDeep(job) || supportsRepair(job) || extraBooleanParams(job).length > 0}
|
||||
<span class="jobs-panel__split">
|
||||
<button
|
||||
class="jobs-panel__btn jobs-panel__btn--small"
|
||||
@@ -1000,16 +1018,17 @@
|
||||
</button>
|
||||
{#if runMenuOpen[job.name]}
|
||||
<div class="jobs-panel__run-menu" role="menu">
|
||||
{#if supportsDeep(job.name)}
|
||||
{#if supportsDeep(job)}
|
||||
<button
|
||||
type="button"
|
||||
class="jobs-panel__run-menu-item"
|
||||
role="menuitem"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:deep`)}
|
||||
title={t(
|
||||
'admin.jobs.run_deep_hint',
|
||||
'Also runs slow variants (blob re-hash, bitrot detection).'
|
||||
)}
|
||||
title={paramOf(job, 'deep')?.description ||
|
||||
t(
|
||||
'admin.jobs.run_deep_hint',
|
||||
'Also runs slow variants (blob re-hash, bitrot detection).'
|
||||
)}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTrigger(job.name, { deep: true });
|
||||
@@ -1035,6 +1054,36 @@
|
||||
<span>{t('admin.jobs.run_repair', 'Repair')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Every other boolean the job declares, rendered from
|
||||
the declaration alone. This is the part that makes a
|
||||
newly-declared flag reachable with no frontend
|
||||
change — `force` on dedup_gc and grant_cleanup
|
||||
arrives here today. Label falls back to the
|
||||
parameter name because the backend owns the
|
||||
wording; there is no i18n key to invent for a flag
|
||||
the frontend has never heard of. -->
|
||||
{#each extraBooleanParams(job) as p (p.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="jobs-panel__run-menu-item"
|
||||
role="menuitem"
|
||||
disabled={busyKeys.has(`trigger:${job.name}:${p.name}`)}
|
||||
title={p.description}
|
||||
onclick={() => {
|
||||
closeAllRunMenus();
|
||||
void onTrigger(job.name, { [p.name]: true });
|
||||
}}
|
||||
>
|
||||
<Icon name="play" />
|
||||
<span
|
||||
>{t(
|
||||
'admin.jobs.run_with_param',
|
||||
{ param: p.name },
|
||||
'Run with {{param}}'
|
||||
)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user