Files
Karthik Kalyan 371f06e5ac feat(web): bulk-cancel selected runs from the runs table (#3349)
* feat(cli): bulk-cancel runs in a single operation

Replace the per-run cancel loop in `workflow cancel` with one `cancelRuns`
call, validate `--limit` (1-500), print a compact outcome summary with
per-run lines for surfaced failures, and exit nonzero only when a run fails.
The bulk logic lives in a dependency-injected `performBulkCancel` helper so it
is unit-testable without an oclif harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): address bulk cancel review feedback

* feat(web): bulk-cancel selected runs in a single request

Thread a bulkCancelRuns action through the server action, RPC route,
rpc-client, and client wrappers, backed by core's cancelRuns. The runs table
now cancels the selected pending/running runs in one call, caps a batch at
BULK_CANCEL_MAX_RUN_IDS (disabling the button with guidance above the cap),
and reports a single outcome-summary toast covering only the categories that
occurred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:54:33 -07:00

102 lines
2.7 KiB
TypeScript

import type { BulkCancelWorkflowRunsResult } from '@workflow/world';
import { describe, expect, it } from 'vitest';
import {
bulkCancelToastSeverity,
shouldRetainSelectionAfterBulkCancel,
} from './bulk-cancel-ui';
type Summary = BulkCancelWorkflowRunsResult['summary'];
function summary(overrides: Partial<Summary>): Summary {
return {
requested: 0,
cancelled: 0,
alreadyCancelled: 0,
notCancellable: 0,
notFound: 0,
failed: 0,
...overrides,
};
}
describe('bulkCancelToastSeverity', () => {
it('is success when every run cancelled', () => {
expect(
bulkCancelToastSeverity(summary({ requested: 2, cancelled: 2 }))
).toBe('success');
});
it('treats already-cancelled as an idempotent success, not an error', () => {
// A concurrent caller cancelled everything first: only alreadyCancelled,
// zero cancelled. This must not be an error.
expect(
bulkCancelToastSeverity(summary({ requested: 2, alreadyCancelled: 2 }))
).toBe('success');
});
it('warns when a genuine problem accompanies some success', () => {
expect(
bulkCancelToastSeverity(
summary({ requested: 2, cancelled: 1, failed: 1 })
)
).toBe('warning');
expect(
bulkCancelToastSeverity(
summary({ requested: 2, alreadyCancelled: 1, notFound: 1 })
)
).toBe('warning');
});
it('is an error only when nothing succeeded', () => {
expect(bulkCancelToastSeverity(summary({ requested: 1, failed: 1 }))).toBe(
'error'
);
expect(
bulkCancelToastSeverity(summary({ requested: 1, notCancellable: 1 }))
).toBe('error');
});
});
describe('shouldRetainSelectionAfterBulkCancel', () => {
it('retains retryable failures', () => {
expect(
shouldRetainSelectionAfterBulkCancel({
runId: 'r',
outcome: 'failed',
code: 'internal_error',
retryable: true,
})
).toBe(true);
});
it('clears non-retryable failures and every terminal outcome', () => {
expect(
shouldRetainSelectionAfterBulkCancel({
runId: 'r',
outcome: 'failed',
code: 'internal_error',
retryable: false,
})
).toBe(false);
expect(
shouldRetainSelectionAfterBulkCancel({ runId: 'r', outcome: 'cancelled' })
).toBe(false);
expect(
shouldRetainSelectionAfterBulkCancel({
runId: 'r',
outcome: 'already_cancelled',
})
).toBe(false);
expect(
shouldRetainSelectionAfterBulkCancel({ runId: 'r', outcome: 'not_found' })
).toBe(false);
expect(
shouldRetainSelectionAfterBulkCancel({
runId: 'r',
outcome: 'not_cancellable',
status: 'completed',
})
).toBe(false);
});
});