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
+31 -24
View File
@@ -8,7 +8,7 @@
*/
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { Finding, JobOutcome, JobSummary, RunSummary } from '$lib/api/types';
import type { Finding, JobOutcome, JobParamValues, JobSummary, RunSummary } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -60,21 +60,22 @@ export function listJobs(): Promise<JobSummary[]> {
}
/**
* `POST /api/admin/jobs/{name}/trigger?force=X&deep=X&repair=X` —
* dispatch a job on-demand.
* `POST /api/admin/jobs/{name}/trigger` — dispatch a job on-demand with
* whichever parameters it declares.
*
* - `force` bypasses per-tenant idempotency checks (e.g. `trash_cleanup`
* skipping when nothing is due).
* - `deep` opts into slow variants (currently only `storage_consistency`,
* propagated by `consistency_batch` to every child).
* - `repair` opts into corrective action on the refcount consistency
* tenants (`blobs_consistency`, `manifests_consistency`, and
* `consistency_batch` which fans out to both). Content-safe: only the
* stored counter changes to match the auditor's computed value. Race-
* safe: the corrective UPDATE recomputes the auditor formula in the
* same statement, so a concurrent write can't leave a stale value.
* Default `false` preserves discovery-only behaviour — surface a
* confirm-first flow when calling with `repair: true`.
* **Which parameters are valid is the job's answer, not this
* function's.** Read them from `JobSummary.parameters` (each carries a
* `type`, a `default` and the handler's own description) and pass the
* ones the operator chose. Anything undeclared comes back as a 400
* naming what the job does accept.
*
* This used to take fixed `force` / `deep` / `storage` / `repair`
* options, which meant callers could pass a flag to a job that ignored
* it and get a silent no-op — the panel offered exactly that on several
* jobs.
*
* Omitted parameters take their declared defaults server-side, so `{}`
* is a plain run.
*
* Throws on 4xx / 5xx with the backend's error message when present.
* A 404 means the job name isn't registered — surface that specifically
@@ -82,17 +83,23 @@ export function listJobs(): Promise<JobSummary[]> {
*/
export async function triggerJob(
name: string,
opts: { force?: boolean; deep?: boolean; storage?: string; repair?: boolean } = {}
opts: JobParamValues = {}
): Promise<TriggerResponse> {
// Free-form, because the accepted set is the job's to declare
// (`JobSummary.parameters`) — not this function's to enumerate. The
// backend validates: an undeclared name is a 400 listing what the
// job does accept, rather than being silently ignored the way the
// old fixed `force/deep/storage/repair` options were on jobs that
// read none of them.
const params = new URLSearchParams();
if (opts.force) params.set('force', 'true');
if (opts.deep) params.set('deep', 'true');
if (opts.repair) params.set('repair', 'true');
// `storage` scopes tenants that respect JobRunArgs.storage —
// currently blobs_consistency / backend_consistency (probes the
// named entry instead of the live backend). See
// `docs/plan/storage-multi-entry.md` slice 7.
if (opts.storage) params.set('storage', opts.storage);
for (const [key, value] of Object.entries(opts)) {
// Skip `false` so a URL carries only what was asked for — the
// backend applies each parameter's declared default for the rest,
// and an explicit `force=false` would read identically while
// making the audit line noisier.
if (value === false || value === undefined || value === '') continue;
params.set(key, String(value));
}
const q = params.toString();
const url = `/api/admin/jobs/${encodeURIComponent(name)}/trigger${q ? `?${q}` : ''}`;
const res = await apiFetch(url, {
+40 -5
View File
@@ -632,6 +632,34 @@ export interface PausedRunBrief {
*/
export type Mutates = 'never' | 'always' | 'on_repair_only';
/** Wire type of a declared job parameter — `JobParamType` on the backend. */
export type JobParamType = 'boolean' | 'string' | 'number';
/**
* One run parameter a job accepts, declared by the handler itself
* (`JobHandler::parameters()`).
*
* This is how the panel knows which knobs a job actually reads. It used
* to guess: `deep` came from a hardcoded name allowlist here, so a job
* gaining a deep mode needed a frontend release, and a job losing one
* left a button that silently did nothing. `force` was offered on every
* job whether or not it was read.
*
* `default` is the value the run uses when the parameter is omitted —
* `null` for a string with no default.
*/
export interface JobParam {
name: string;
type: JobParamType;
default: boolean | number | string | null;
/** The job's own wording for THIS parameter, for the control's
* tooltip. Absent when the handler left it blank. */
description?: string;
}
/** Values for one trigger, keyed by declared parameter name. */
export type JobParamValues = Record<string, boolean | number | string>;
export interface JobSummary {
name: string;
/** One or two sentences on what the job does, in English, authored
@@ -644,6 +672,9 @@ export interface JobSummary {
* the text is the confirmation copy. Independent of `mutates` — the
* thumbnail import jobs are `always` AND repair-capable. */
repair_description?: string;
/** What this job accepts on a trigger. Absent — not `[]` — when the
* job takes none, so "render no controls" is the natural default. */
parameters?: JobParam[];
interval_ms?: number;
next_run_at?: string;
last_run_at?: string;
@@ -670,12 +701,16 @@ export interface JobSummary {
startup?: StartupTrigger;
}
/** Flags a job configured in `OXICLOUD_STARTUP_JOBS` runs with. */
/**
* Parameters a job configured in `OXICLOUD_STARTUP_JOBS` runs with,
* keyed by declared name.
*
* A map for the same reason `JobSummary.parameters` is one: the four
* fixed fields it replaced meant a job growing a parameter silently
* dropped it from the "at boot" pill.
*/
export interface StartupTrigger {
force: boolean;
deep: boolean;
repair: boolean;
storage?: string;
params?: JobParamValues;
}
/**
@@ -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}