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:
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user