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:
Edouard Vanbelle
2026-09-07 12:30:40 +02:00
parent ff286f8159
commit a4101743e0
23 changed files with 1242 additions and 397 deletions
@@ -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}