fix(admin): separate a job's run STATE from its OUTCOME
Ed's diagnosis, and it is the root of three symptoms I had been patching
one at a time: a job has two independent statuses, and the panel was
collapsing them into one column.
* STATE — where the run is in its lifecycle: running, paused,
cancelled, completed, failed.
* OUTCOME — how the work turned out: ok, issues, notices, err.
They are orthogonal. A paused run has no outcome yet. A completed run's
outcome may still be "issues". Conflating them produced, in order:
1. a paused migration rendering as a green "ok" — the outcome was
genuinely ok, the STATE was Paused, and only the outcome was shown;
2. my first fix, which put "blocked" into the OUTCOME column — a
category error, encoding lifecycle into the result axis;
3. a cancelled job still reading "blocked", because that outcome was
cached in memory while the cancel had flipped the row in SQL.
The layout already had both columns. State just never rendered anything
but "running" or "—", so the status axis had no home and the information
leaked into Outcome.
Now:
* State renders `last_run_status`, sourced from the run ROW. Memory
cannot answer this — it is empty after a restart and stale after a
cancel, both of which the row gets right. The retryable reason, when
there is one, is the pill's tooltip.
* Outcome goes back to describing only the work: ok / issues /
notices / err. No lifecycle in it.
`JobSummary` gains `last_run_status`, and `last_run_at` falls back to
the row's `started_at` when memory has none — a restart left the column
reading "never" for a job whose last run was hours earlier.
"never" is now reserved for jobs that genuinely never ran. With a run
row present but no cached outcome the cell reads "—": the honest "no
outcome recorded", rather than a claim the run history immediately
contradicts.
The enrichment query generalises rather than multiplying — it already
fetched Paused rows for the Resume button, so it now takes the latest
row per job via `DISTINCT ON` and derives state, timestamp and paused
brief from it. Sound as "the current run" because the
`one_active_run_per_job` partial unique index permits one non-terminal
row per job and a resume reuses it, so a non-terminal row is always
newest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -718,6 +718,14 @@ export interface JobSummary {
|
||||
* for this job. Distinct from `running` — a paused run is
|
||||
* resumable via the same trigger endpoint. */
|
||||
paused_run?: PausedRunBrief;
|
||||
/** Status of this job's most recent run row (recoverable jobs only).
|
||||
*
|
||||
* **Prefer this over `last_outcome` wherever they could disagree.**
|
||||
* `last_outcome` is the backend's in-memory record of the last
|
||||
* dispatch, so anything that changes a run row without running the
|
||||
* handler leaves it stale — cancelling a Paused run is a direct SQL
|
||||
* flip, and the panel went on rendering the pause it replaced. */
|
||||
last_run_status?: RunStatus;
|
||||
/** Present iff `OXICLOUD_STARTUP_JOBS` names this job — the flags it
|
||||
* is dispatched with at every boot. Worth showing: a job configured
|
||||
* with `repair: true` deletes on every restart, and the row would
|
||||
|
||||
@@ -524,8 +524,23 @@
|
||||
// green "ok" hides the thing worth acting on. A paused
|
||||
// `backend_migration` is still holding `migration_readonly` and
|
||||
// refusing writes across the whole app; the row has to say so.
|
||||
function stoppedOnBackendFailure(job: JobSummary): boolean {
|
||||
return job.last_outcome?.outcome === 'ok' && job.last_outcome.extra?.retryable === true;
|
||||
/// Lifecycle label for the State column. Lower-cased to match the
|
||||
/// existing `running` pill rather than shouting the DB's PascalCase.
|
||||
function runStatusLabel(status: RunStatus): string {
|
||||
switch (status) {
|
||||
case 'Running':
|
||||
return t('admin.jobs.state_running', 'running');
|
||||
case 'Paused':
|
||||
return t('admin.jobs.state_paused', 'paused');
|
||||
case 'CancelRequested':
|
||||
return t('admin.jobs.state_cancelling', 'cancelling');
|
||||
case 'Cancelled':
|
||||
return t('admin.jobs.state_cancelled', 'cancelled');
|
||||
case 'Completed':
|
||||
return t('admin.jobs.state_completed', 'completed');
|
||||
case 'Failed':
|
||||
return t('admin.jobs.state_failed', 'failed');
|
||||
}
|
||||
}
|
||||
|
||||
function backendFailureReason(job: JobSummary): string | undefined {
|
||||
@@ -534,13 +549,24 @@
|
||||
return typeof reason === 'string' ? reason : undefined;
|
||||
}
|
||||
|
||||
/// How the last dispatch turned out. NOT where the run is in its
|
||||
/// lifecycle — that is the State column, driven by
|
||||
/// `last_run_status`. A paused run legitimately has no outcome yet,
|
||||
/// and saying so is the honest answer.
|
||||
function outcomeLabel(job: JobSummary): string {
|
||||
if (!job.last_outcome) return t('admin.jobs.never', 'never');
|
||||
// Checked before the findings branches: a run that never finished
|
||||
// has nothing meaningful to say about findings, and "0 issues" on
|
||||
// an aborted scan is a worse answer than "blocked".
|
||||
if (stoppedOnBackendFailure(job)) {
|
||||
return t('admin.jobs.outcome_blocked', 'blocked');
|
||||
if (!job.last_outcome) {
|
||||
// "never" means never ran. A job with a run row DID run — the
|
||||
// outcome simply is not in memory, because `last_outcome` is
|
||||
// populated per dispatch and a restart empties it. Saying
|
||||
// "never" there is a lie the run history immediately
|
||||
// contradicts: Ed saw it on a job whose last run was 8h ago.
|
||||
//
|
||||
// "—" is the honest answer: no outcome recorded. The State
|
||||
// column still shows what the run did, and the drawer has
|
||||
// the history.
|
||||
return job.last_run_status
|
||||
? t('admin.jobs.outcome_unknown', '—')
|
||||
: t('admin.jobs.never', 'never');
|
||||
}
|
||||
if (job.last_outcome.outcome === 'ok') {
|
||||
// `ok` on the wire = dispatch completed. If any actionable
|
||||
@@ -563,14 +589,6 @@
|
||||
if (job.last_outcome.outcome !== 'ok') {
|
||||
return 'jobs-panel__pill jobs-panel__pill--err';
|
||||
}
|
||||
// Amber, not red: nothing is broken and no data was lost — the
|
||||
// run is waiting for the backend to come back and a Resume
|
||||
// continues it. Red would read as "this job is failing" and
|
||||
// invite a cancel, which for a migration also throws away the
|
||||
// copy already done.
|
||||
if (stoppedOnBackendFailure(job)) {
|
||||
return 'jobs-panel__pill jobs-panel__pill--paused';
|
||||
}
|
||||
if (actionableFindingCount(job) > 0) {
|
||||
return 'jobs-panel__pill jobs-panel__pill--paused';
|
||||
}
|
||||
@@ -944,14 +962,7 @@
|
||||
<td class="jobs-panel__muted">{timeAgo(job.last_run_at)}</td>
|
||||
<td>
|
||||
<div class="jobs-panel__outcome-cell">
|
||||
<!-- The reason is on the pill itself, not only in the
|
||||
expanded drawer: it is the whole content of a
|
||||
"blocked" row, and folding it away is what made a
|
||||
migration paused by an unreachable endpoint read as
|
||||
a plain green "ok". -->
|
||||
<span class={outcomeClass(job)} title={backendFailureReason(job)}
|
||||
>{outcomeLabel(job)}</span
|
||||
>
|
||||
<span class={outcomeClass(job)}>{outcomeLabel(job)}</span>
|
||||
{#if actionableFindingCount(job) > 0}
|
||||
{@const findings = actionableFindingCount(job)}
|
||||
<span
|
||||
@@ -978,11 +989,26 @@
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<!-- STATE = where the run is in its lifecycle. Distinct
|
||||
from Outcome, which is how the work turned out.
|
||||
They are orthogonal: a Paused run has no outcome
|
||||
yet, and a Completed run's outcome may still be
|
||||
"issues". Conflating them is what made a paused
|
||||
migration render as a green "ok". -->
|
||||
<td>
|
||||
{#if isRunning(job)}
|
||||
<span class="jobs-panel__pill jobs-panel__pill--running">
|
||||
{t('admin.jobs.state_running', 'running')}
|
||||
</span>
|
||||
{:else if job.last_run_status}
|
||||
<!-- From the run ROW, not from memory: the
|
||||
in-memory outcome is empty after a restart and
|
||||
stale after a cancel, both of which the row
|
||||
gets right. Reason on hover when the run
|
||||
stopped on a backend failure. -->
|
||||
<span class={statusClass(job.last_run_status)} title={backendFailureReason(job)}>
|
||||
{runStatusLabel(job.last_run_status)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="jobs-panel__muted">—</span>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user