diff --git a/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json b/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json deleted file mode 100644 index 9cdddc5121..0000000000 --- a/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status = 'failure'\n -- Resolution is a top-level triage state: a step resolved on its own\n -- would render orange inside a flow whose status is still red.\n AND j.flow_step_id IS NULL\n -- A supersession claim has to be proven, not trusted: a later success of the\n -- same identified runnable, itself visible to the caller. An unproven claim\n -- resolves nothing, so the caller learns it was rejected instead of having\n -- the fiction recorded as provenance.\n AND ($6::UUID IS NULL OR EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n WHERE sc.id = $6\n AND sc.workspace_id = $2\n -- Tag scope is a read restriction enforced outside RLS, so it has\n -- to bind the evidence as well: otherwise the result reveals\n -- whether an out-of-scope run succeeded.\n AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))\n AND sc.status = 'success'\n AND sc.completed_at >= c.completed_at\n AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)\n AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id\n AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path\n ))\n ON CONFLICT (job_id) DO UPDATE SET\n resolved_at = now(),\n -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the\n -- licence lapses, and bulk selections routinely include already-resolved rows,\n -- so overwriting would erase metadata recorded while it was valid. Clear either\n -- by unresolving first.\n resolved_by = COALESCE($4, job_resolution.resolved_by),\n -- A person's explanation replaces what was there; machine provenance only fills\n -- a blank, so re-running an already-explained failure never erases their words.\n note = COALESCE($5, job_resolution.note, $7),\n -- A human taking over an automatic resolution makes it no longer automatic.\n automatic = false\n RETURNING job_id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "job_id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "Text", - "TextArray", - "Varchar", - "Text", - "Uuid", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc" -} diff --git a/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json b/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json new file mode 100644 index 0000000000..224e1f62e7 --- /dev/null +++ b/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, $5, false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = $1\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status IN ('failure', 'canceled')\n AND j.flow_step_id IS NULL\n ON CONFLICT (job_id) DO NOTHING\n RETURNING job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f" +} diff --git a/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json b/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json new file mode 100644 index 0000000000..c1ea457a72 --- /dev/null +++ b/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status IN ('failure', 'canceled')\n -- Resolution is a top-level triage state: a step resolved on its own\n -- would render orange inside a flow whose status is still red.\n AND j.flow_step_id IS NULL\n -- A supersession claim has to be proven, not trusted: a later success of the\n -- same identified runnable, itself visible to the caller. An unproven claim\n -- resolves nothing, so the caller learns it was rejected instead of having\n -- the fiction recorded as provenance.\n AND ($6::UUID IS NULL OR EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n WHERE sc.id = $6\n AND sc.workspace_id = $2\n -- Tag scope is a read restriction enforced outside RLS, so it has\n -- to bind the evidence as well: otherwise the result reveals\n -- whether an out-of-scope run succeeded.\n AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))\n AND sc.status = 'success'\n AND sc.completed_at >= c.completed_at\n AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)\n AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id\n AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path\n ))\n ON CONFLICT (job_id) DO UPDATE SET\n resolved_at = now(),\n -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the\n -- licence lapses, and bulk selections routinely include already-resolved rows,\n -- so overwriting would erase metadata recorded while it was valid. Clear either\n -- by unresolving first.\n resolved_by = COALESCE($4, job_resolution.resolved_by),\n -- A person's explanation replaces what was there; machine provenance only fills\n -- a blank, so re-running an already-explained failure never erases their words.\n note = COALESCE($5, job_resolution.note, $7),\n -- A human taking over an automatic resolution makes it no longer automatic.\n automatic = false\n RETURNING job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text", + "TextArray", + "Varchar", + "Text", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1" +} diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index 58cf27db87..9663eea73b 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -29,12 +29,21 @@ const FLOW_VERSION: i64 = 2222222222; /// Mark a queued job as completed with success — needed so it's eligible for /// `list_selected_job_groups` / `batch_rerun_jobs` (both join `v2_job_completed`). async fn complete_job(db: &Pool, job_id: Uuid) -> anyhow::Result<()> { + complete_job_with_status(db, job_id, "success").await +} + +async fn complete_job_with_status( + db: &Pool, + job_id: Uuid, + status: &str, +) -> anyhow::Result<()> { sqlx::query( "INSERT INTO v2_job_completed (workspace_id, id, result, status, duration_ms, started_at, completed_at) - VALUES ($1, $2, '{}'::jsonb, 'success', 0, now(), now())", + VALUES ($1, $2, '{}'::jsonb, $3::job_status, 0, now(), now())", ) .bind(WORKSPACE) .bind(job_id) + .bind(status) .execute(db) .await?; Ok(()) @@ -660,3 +669,56 @@ async fn batch_rerun_denies_invisible_source_job(db: Pool) -> anyhow:: assert_eq!(pushed, 0); Ok(()) } + +/// Re-running a failed or canceled job resolves it, pointing at the re-run, which is what lets +/// "hide resolved" exclude it from the next batch. A success has nothing to resolve. +#[sqlx::test(fixtures("base", "batch_rerun"))] +async fn batch_rerun_resolves_failed_and_canceled_sources( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let client = windmill_api_client::create_client( + &format!("http://localhost:{}", server.addr.port()), + "SECRET_TOKEN".to_string(), + ); + + let mut sources = vec![]; + for status in ["success", "failure", "canceled"] { + let id = RunJob::from(script_payload()) + .arg("name".to_string(), json!(status)) + .push(&db) + .await; + complete_job_with_status(&db, id, status).await?; + sources.push(id); + } + + let results = batch_rerun( + &client, + json!({ + "job_ids": sources, + "script_options_by_path": {}, + "flow_options_by_path": {}, + }), + ) + .await?; + assert!(results.iter().all(|r| r.is_ok()), "{results:?}"); + + let resolved: Vec<(String, String)> = sqlx::query_as( + "SELECT o.args->>'name', n.args->>'name' + FROM job_resolution r + JOIN v2_job o ON o.id = r.job_id + JOIN v2_job n ON r.note = 'Re-run as ' || n.id + ORDER BY 1", + ) + .fetch_all(&db) + .await?; + assert_eq!( + resolved, + vec![ + ("canceled".to_string(), "canceled".to_string()), + ("failure".to_string(), "failure".to_string()), + ] + ); + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e9b5732f0a..d56115d9b0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -16117,6 +16117,9 @@ paths: /w/{workspace}/jobs/run/batch_rerun_jobs: post: summary: re-run multiple jobs + description: >- + Re-runs each job and marks every failed or canceled original that was re-run as + resolved, with the new job's id in its note. Operators re-run without resolving. operationId: batchReRunJobs tags: - job @@ -16878,6 +16881,7 @@ paths: - job parameters: - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/OrderDesc" - $ref: "#/components/parameters/CreatedBy" - $ref: "#/components/parameters/Label" - $ref: "#/components/parameters/Worker" @@ -16910,8 +16914,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -17222,8 +17226,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -17409,8 +17413,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -18268,11 +18272,12 @@ paths: /w/{workspace}/jobs/completed/resolve: post: - summary: mark failed jobs as resolved + summary: mark failed or canceled jobs as resolved description: >- - Marks failed jobs as handled so triage surfaces stop showing them as failures. - The job status itself is unchanged. Returns the ids actually resolved: ids that - are not visible to the caller or did not fail are silently skipped. + Marks failed or canceled jobs as handled so triage surfaces stop showing them as + unhandled. The job status itself is unchanged. Returns the ids actually resolved: ids + that are not visible to the caller or neither failed nor were canceled are silently + skipped. operationId: resolveCompletedJobs tags: - job @@ -26130,8 +26135,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -31052,7 +31057,7 @@ components: type: boolean resolved: type: boolean - description: whether this failure has been marked as handled + description: whether this failed or canceled run has been marked as handled resolved_by: type: string description: >- diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index b939ba017a..cb8d47df4f 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7058,6 +7058,7 @@ async fn batch_rerun_handle_job( ) .await; if let Ok((uuid, _, _, _)) = result { + resolve_batch_rerun_source(authed, user_db, w_id, job.id, uuid).await; return Ok(uuid.to_string()); } } @@ -7091,6 +7092,7 @@ async fn batch_rerun_handle_job( .map(|r| r.0) }; if let Ok(uuid) = result { + resolve_batch_rerun_source(authed, user_db, w_id, job.id, uuid).await; return Ok(uuid.to_string()); } } @@ -7101,6 +7103,67 @@ async fn batch_rerun_handle_job( )) } +/// Marks a failed or canceled source of a batch re-run as handled, with the re-run as its note. +/// This is the only moment the link exists (the re-run is pushed with no back-pointer), and it +/// is what lets "hide resolved" exclude already re-run jobs from the next batch. Never fails the +/// re-run, which is already queued: a source that cannot be resolved is logged and left as is. +async fn resolve_batch_rerun_source( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + source: Uuid, + rerun: Uuid, +) { + if authed.is_operator { + return; + } + let result: error::Result<()> = async { + let mut tx = user_db.clone().begin(authed).await?; + let tags = get_scope_tags(authed); + let (resolved_by, _) = resolution_attribution(authed, None); + // Same authorization and eligibility as `resolve_completed_jobs`. DO NOTHING keeps a + // resolution someone already explained. + let resolved = sqlx::query_scalar!( + "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) + SELECT c.id, c.workspace_id, $4, $5, false + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE c.id = $1 + AND c.workspace_id = $2 + AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) + AND c.status IN ('failure', 'canceled') + AND j.flow_step_id IS NULL + ON CONFLICT (job_id) DO NOTHING + RETURNING job_id", + source, + w_id, + tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>, + resolved_by, + format!("Re-run as {rerun}"), + ) + .fetch_optional(&mut *tx) + .await?; + if resolved.is_some() { + audit_log( + &mut *tx, + authed, + "jobs.resolve", + ActionKind::Update, + w_id, + Some(&source.to_string()), + None, + ) + .await?; + } + tx.commit().await?; + Ok(()) + } + .await; + if let Err(e) = result { + tracing::error!("Could not resolve job {source} after re-running it as {rerun}: {e}"); + } +} + pub async fn run_flow_by_path( authed: ApiAuthed, Extension(db): Extension, @@ -12531,8 +12594,8 @@ fn check_resolution_request( Ok(()) } -/// Marks failed jobs as handled. Returns the ids actually affected: an id that is not -/// visible to the caller, carries an out-of-scope tag, or did not fail is silently +/// Marks failed or canceled jobs as handled. Returns the ids actually affected: an id that is +/// not visible to the caller, carries an out-of-scope tag, or did not fail nor get canceled is silently /// absent rather than an error, so a bulk selection never fails as a whole. async fn resolve_completed_jobs( authed: ApiAuthed, @@ -12547,7 +12610,7 @@ async fn resolve_completed_jobs( let system_note = req.superseded_by.map(|_| SUPERSEDED_NOTE); // The join on v2_job is what authorizes this write: v2_job_completed has RLS // disabled, so v2_job's policies are the only thing scoping rows to the caller. - // `status = 'failure'` keeps the invariant that only a failure can be resolved. + // Only a failed or canceled run can be resolved: a success has nothing to handle. let resolved = sqlx::query_scalar!( "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false @@ -12556,7 +12619,7 @@ async fn resolve_completed_jobs( WHERE c.id = ANY($1) AND c.workspace_id = $2 AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) - AND c.status = 'failure' + AND c.status IN ('failure', 'canceled') -- Resolution is a top-level triage state: a step resolved on its own -- would render orange inside a flow whose status is still red. AND j.flow_step_id IS NULL diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index dd3c83a08d..8ff3e9f5c2 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -1846,7 +1846,7 @@ is, a different one moves it there and archives the old path"), }, "resolved": { "type": "boolean", - "description": "filter on whether a failure has been marked as handled. true keeps only resolved failures, false hides them" + "description": "filter on whether a failed or canceled run has been marked as handled. true keeps only resolved runs, false hides them" }, "is_flow_step": { "type": "boolean", diff --git a/frontend/src/lib/components/JobStatus.svelte b/frontend/src/lib/components/JobStatus.svelte index 7945081482..5e79303371 100644 --- a/frontend/src/lib/components/JobStatus.svelte +++ b/frontend/src/lib/components/JobStatus.svelte @@ -34,7 +34,7 @@ - Failed after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically + {job.canceled ? 'Canceled' : 'Failed'} after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically ? ' automatically' : job.resolved_by ? ` by ${job.resolved_by}` @@ -49,7 +49,7 @@ {:else if job && 'success' in job} - Failed after {msToReadableTime(job.duration_ms)} + {job.canceled ? 'Canceled' : 'Failed'} after {msToReadableTime(job.duration_ms)} {#if job.self_wait_time_ms || job.aggregate_wait_time_ms} void type?: ConfirmationModal['$$prop_def']['type'] @@ -437,6 +438,7 @@ async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) { if (!$operatingWorkspace) return + const batching = filterMatchedIds !== undefined if (askingForConfirmation) { askingForConfirmation.loading = true @@ -486,7 +488,11 @@ } if (reRanUuids.length !== jobIdsToReRun.length) { sendUserToast( - `Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details`, + `Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details` + + // Only a re-run resolves its source, so these stay first in line. + (batching + ? '. They stay unresolved and the next batch will try them again: resolve them to skip them' + : ''), true ) // We do not get explicit error from backend if the job script don't exist @@ -509,13 +515,31 @@ jobsLoader?.loadJobs(true, true) } + // Every failed or canceled job "Re-run all jobs matching filters" found, oldest first, so a + // batch limit takes the oldest ones. Re-running resolves them, so under "Hide resolved runs" + // the next click picks up where the previous batch stopped. + let filterMatchedIds: string[] | undefined = $state() + let rerunLimit: number | undefined = $state() + // The pane closes from several places, the table included; a stale match list would then + // offer its limit over a selection it no longer describes. + $effect(() => { + if (!batchRerunOptionsIsOpen) untrack(() => (filterMatchedIds = undefined)) + }) + + function setRerunLimit(limit: number | undefined) { + if (!filterMatchedIds) return + rerunLimit = limit && limit > 0 ? limit : undefined + selectedIds = filterMatchedIds.slice(0, rerunLimit ?? filterMatchedIds.length) + } + async function onRerunAllJobsMatchingFilters() { const selectedFilters = getSelectedFilters() selectedIds = [] const loadingToast = sendUserToast('Loading job ids', 'info') - if (filters.val.job_kinds !== 'runs') { + // Selecting "runs" deletes the key, since it is the default. + if ((filters.val.job_kinds ?? 'runs') !== 'runs') { sendUserToast('Batch re-run is only supported for scripts and flows', true) loadingToast.destroy() return @@ -524,11 +548,21 @@ // workspace. started_at >= minTs implies completed_at >= minTs, so completedAfter adds a // lossless indexed lower bound ((workspace_id, completed_at DESC)); started_at stays the // exact recheck. (completedBefore is omitted: it would drop jobs that finish after maxTs.) - selectedIds = await JobService.listFilteredJobsUuids({ + const ids = await JobService.listFilteredJobsUuids({ ...selectedFilters, completedAfter: selectedFilters.startedAfter, - jobKinds: 'script,flow' + orderDesc: false }) + // Batching needs both halves of its promise: only failed or canceled sources are resolved + // by a re-run, and only a status filter keeps the queue out of the lookup, whose union + // drops the ordering. Anything else re-runs every match, as one batch. + if (selectedFilters.status === 'failure' || selectedFilters.status === 'canceled') { + filterMatchedIds = ids + setRerunLimit(rerunLimit) + } else { + filterMatchedIds = undefined + selectedIds = ids + } loadingToast.destroy() batchRerunOptionsIsOpen = true } @@ -538,6 +572,10 @@ askingForConfirmation = { title: `Confirm re-running the selected jobs`, confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`, + // The server skips resolution for operators, who cannot resolve. + description: operatingUser.current?.operator + ? undefined + : "Failed or canceled jobs among them will be marked resolved, with the new run's id as the note.", type: 'reload', onConfirm: async () => { await reRunJobs(jobIdsToReRun, batchReRunOptions) @@ -867,6 +905,9 @@ askingForConfirmation = undefined }} > + {#if askingForConfirmation?.description} +

{askingForConfirmation.description}

+ {/if} {#if askingForConfirmation?.preContent}
{askingForConfirmation.preContent}
{ manualSelectionMode = 'rerun' selectedIds = [] + filterMatchedIds = undefined batchRerunOptionsIsOpen = true } }, @@ -1128,7 +1170,7 @@ ? [ { // Operators are rejected by the endpoint, so offering it would only 403. - displayName: 'Resolve failed jobs', + displayName: 'Resolve failed or canceled jobs', action: () => ( (manualSelectionMode = 'resolve'), (selectedIds = []), @@ -1210,7 +1252,7 @@ class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center gap-3 p-4" >

- Resolving keeps the run a failure but stops it showing as one in the runs list. + Resolving keeps the run's status but marks it as handled in the runs list.

((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))} - onConfirm={async (options) => { - await onReRunSelectedJobs(options) - }} - /> - {/await} +
+ {#if filterMatchedIds} +
+
+ {filterMatchedIds.length} jobs match the filters, re-run at most + rerunLimit?.toString() ?? '', + (v) => setRerunLimit(v ? parseInt(v.toString()) : undefined) + } + /> +
+ {#if filters.val.resolved === 'unresolved'} + + Oldest first. Re-run jobs are marked resolved, so the next batch starts where this one + stops. + + {:else} +
+ + + Resolved runs are included, so the next batch will pick these same jobs again. + + +
+ {/if} +
+ {/if} +
+ {#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane} + ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))} + onConfirm={async (options) => { + await onReRunSelectedJobs(options) + }} + /> + {/await} +
+
{/snippet} {#snippet jobPreview(pending?: Snippet)} diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 9a1aed6145..5783deaf53 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -514,7 +514,7 @@ variant="subtle" disabled={togglingResolution} onClick={() => setResolution(false)} - title="Show this run as a failure again" + title="Show this run as unhandled again" > Unresolve @@ -525,7 +525,7 @@ size="xs2" variant="subtle" nonCaptureEvent - title="Mark this failure as handled" + title="Mark this run as handled" > Mark resolved @@ -533,8 +533,7 @@ {#snippet content({ close })}

- Resolving keeps the run a failure but stops it showing as one in the runs - list. + Resolving keeps the run's status but marks it as handled in the runs list.

{:else if job.canceled && 'success' in job} - + {:else if 'success' in job && job.success} diff --git a/frontend/src/lib/components/runs/rerunResolution.svelte.ts b/frontend/src/lib/components/runs/rerunResolution.svelte.ts index 87911d6c8d..f57d7400c8 100644 --- a/frontend/src/lib/components/runs/rerunResolution.svelte.ts +++ b/frontend/src/lib/components/runs/rerunResolution.svelte.ts @@ -37,7 +37,7 @@ export function offerToResolveOriginal(origin: PendingRerun, onResolved?: () => 'success', [ { - label: 'Resolve the original failure', + label: 'Resolve the original run', callback: async () => { const affected = await JobService.resolveCompletedJobs({ workspace: origin.workspace, @@ -50,10 +50,10 @@ export function offerToResolveOriginal(origin: PendingRerun, onResolved?: () => } }) if (affected.length === 0) { - sendUserToast('Could not resolve the original failure', true) + sendUserToast('Could not resolve the original run', true) return } - sendUserToast('Original failure resolved') + sendUserToast('Original run resolved') onResolved?.() } } diff --git a/frontend/src/lib/components/runs/runsFilter.ts b/frontend/src/lib/components/runs/runsFilter.ts index 3ff8a5e54b..cef8cca6e9 100644 --- a/frontend/src/lib/components/runs/runsFilter.ts +++ b/frontend/src/lib/components/runs/runsFilter.ts @@ -186,7 +186,7 @@ export function buildRunsFilterSearchbarSchema({ ], label: 'Resolution', icon: Wrench, - description: 'Filter by whether a failure has been marked as handled' + description: 'Filter by whether a failed or canceled run has been marked as handled' }, job_trigger_kind: { type: 'oneof' as const, @@ -254,7 +254,7 @@ export const buildRunsFilterPresets = ({ { name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' }, { name: 'Hide future jobs', value: 'show_future_jobs:\\ false' }, { name: 'Show skipped', value: 'show_skipped:\\ true' }, - { name: 'Hide resolved failures', value: 'resolved:\\ unresolved' }, + { name: 'Hide resolved runs', value: 'resolved:\\ unresolved' }, ...(isSuperAdminOrDevops && isAdminsWorkspace ? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }] : []) diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index b3b70cfaa2..e53beeb53b 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -1853,7 +1853,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "resolved": { "type": "boolean", - "description": "filter on whether a failure has been marked as handled. true keeps only resolved failures, false hides them" + "description": "filter on whether a failed or canceled run has been marked as handled. true keeps only resolved runs, false hides them" }, "is_flow_step": { "type": "boolean", diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index a37238f91c..07216d341f 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -13,17 +13,16 @@ import { apiErrorMessage } from './utils' -// Mirrors the backend invariant that only `status = 'failure'` rows can carry a -// resolution; loosening this to `!success` would offer the action on queued and -// canceled runs the API then silently skips. +// Mirrors the backend invariant that only failed or canceled rows can carry a resolution; +// offering the action anywhere else would send ids the API then silently skips. describe('isJobResolvable', () => { const completed = (o: object) => ({ type: 'CompletedJob', ...o }) as any - it('accepts a plain failure', () => { + it('accepts a plain failure and a cancellation', () => { expect(isJobResolvable(completed({ success: false, canceled: false }))).toBe(true) + expect(isJobResolvable(completed({ success: false, canceled: true }))).toBe(true) }) - it('rejects success, cancellation, flow steps and jobs still queued', () => { + it('rejects success, flow steps and jobs still queued', () => { expect(isJobResolvable(completed({ success: true, canceled: false }))).toBe(false) - expect(isJobResolvable(completed({ success: false, canceled: true }))).toBe(false) // a step resolved alone would show orange inside a flow that is still red expect( isJobResolvable(completed({ success: false, canceled: false, is_flow_step: true })) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8540b964e8..13ae454c92 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -83,13 +83,13 @@ export function isJobReRunnable(j: Job): boolean { return (j.job_kind === 'script' || j.job_kind === 'flow') && j.parent_job === undefined } -// Only a top-level failure can carry a resolution: cancellations and skipped steps are not -// failures, and a flow step resolved on its own would render orange inside a flow whose status -// is still red. The resolve endpoint enforces the same three conditions. +// Only a top-level failed or canceled run can carry a resolution: successes and skipped steps +// have nothing to handle, and a flow step resolved on its own would render orange inside a flow +// whose status is still red. The resolve endpoint enforces the same conditions. // Runs obscured from another workspace stand in with `id: '-'`; they look like failures but // address nothing, and one reaching the endpoint fails the whole batch on UUID parsing. export function isJobResolvable(j: Job): j is CompletedJob { - return j.type === 'CompletedJob' && !j.success && !j.canceled && !j.is_flow_step && j.id !== '-' + return j.type === 'CompletedJob' && !j.success && !j.is_flow_step && j.id !== '-' } // Mirror the caps enforced by /jobs/completed/resolve, so the UI can chunk and validate