Add Cloud Console cancel action and cancelled status

- Widen TaskStatus to include "cancelled"; add it to TasksView's
  STATUSES filter dropdown.
- Add TaskCancellationResponse type and cancelTask(taskId) to api.ts.
- Add a Cancel button to TasksView's task detail panel, gated on
  tasks:submit and a non-terminal task status; updates the displayed
  status on success and surfaces errors via the existing error path.
- Extract the cancellability rule into a pure taskCancellation.ts
  module (mirroring taskProgress.ts/plannerHistory.ts) with unit
  tests, since the project has no Vue component-mounting test setup.

Task 6/9 of task-cancellation change.
This commit is contained in:
2026-07-15 18:43:55 +08:00
parent 6776ac2f2d
commit 4d04d7ac83
6 changed files with 94 additions and 5 deletions
+8
View File
@@ -13,6 +13,7 @@ import type {
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskCancellationResponse,
TaskListResponse,
TaskSubmissionPayload,
TaskStatus,
@@ -239,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
}
export function cancelTask(taskId: string): Promise<TaskCancellationResponse> {
return request<TaskCancellationResponse>(
`/v1/tasks/${encodeURIComponent(taskId)}/cancel`,
{ method: "POST" },
);
}
export function getTaskPlannerDecisions(
taskId: string,
attempt: number,
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { canCancelTask } from "./taskCancellation";
import type { TaskStatus } from "./types";
describe("canCancelTask", () => {
it.each<TaskStatus>(["queued", "assigned", "dispatched"])(
"allows cancelling a %s task when the caller can submit",
(status) => {
expect(canCancelTask(status, true)).toBe(true);
},
);
it.each<TaskStatus>(["done", "failed", "cancelled"])(
"refuses to cancel a terminal %s task even when the caller can submit",
(status) => {
expect(canCancelTask(status, true)).toBe(false);
},
);
it("refuses to cancel a cancellable task when the caller lacks submit permission", () => {
expect(canCancelTask("assigned", false)).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { TaskStatus } from "./types";
/**
* Statuses for which cancellation is still meaningful: the task has not yet
* reached a terminal state. `cancelled` itself is excluded so a task can't be
* cancelled twice through the UI.
*/
const CANCELLABLE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
"queued",
"assigned",
"dispatched",
]);
/**
* Whether the Cancel action should be shown/enabled for a task, given the
* caller's submit permission and the task's current status.
*/
export function canCancelTask(status: TaskStatus, canSubmit: boolean): boolean {
return canSubmit && CANCELLABLE_STATUSES.has(status);
}
+7 -1
View File
@@ -3,7 +3,8 @@ export type TaskStatus =
| "assigned"
| "dispatched"
| "done"
| "failed";
| "failed"
| "cancelled";
export interface TaskListItem {
id: string;
@@ -41,6 +42,11 @@ export interface TaskListResponse {
offset: number;
}
export interface TaskCancellationResponse {
task_id: string;
status: TaskStatus;
}
export interface TaskAttempt {
task_id: string;
attempt: number;
+31
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
cancelTask,
getTaskAttempts,
getTaskPlannerDecisions,
listTasks,
@@ -20,6 +21,7 @@ import type {
PlannerDecisionItem,
} from "../types";
import { formatTaskProgress } from "../taskProgress";
import { canCancelTask } from "../taskCancellation";
import { computePlannerHistoryState } from "../plannerHistory";
const props = defineProps<{ canSubmit: boolean }>();
@@ -30,6 +32,7 @@ const STATUSES: TaskStatus[] = [
"dispatched",
"done",
"failed",
"cancelled",
];
const statusFilter = ref<TaskStatus | "">("");
@@ -55,6 +58,7 @@ const devices = ref<DeviceRecord[]>([]);
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
const plannerLoading = ref(false);
const plannerError = ref("");
const cancelling = ref(false);
const availableDevices = computed(() =>
devices.value.filter((device) => device.host_id === submitHostId.value),
);
@@ -185,6 +189,21 @@ async function selectTask(task: TaskListItem) {
}
}
async function cancelSelectedTask() {
if (!selectedTask.value) return;
cancelling.value = true;
errorMessage.value = "";
try {
const response = await cancelTask(selectedTask.value.id);
selectedTask.value = { ...selectedTask.value, status: response.status };
await refresh();
} catch (err) {
handleError(err, "failed to cancel task");
} finally {
cancelling.value = false;
}
}
function handleError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
@@ -257,6 +276,11 @@ const selectedTaskProgress = computed(() =>
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
);
const canCancelSelectedTask = computed(
() =>
!!selectedTask.value && canCancelTask(selectedTask.value.status, props.canSubmit),
);
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
if (!selectedTask.value?.assigned_host_id) return null;
const host = hosts.value.find(
@@ -417,6 +441,13 @@ function formatArguments(args: Record<string, unknown>): string {
<h2>
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
</h2>
<button
v-if="canCancelSelectedTask"
:disabled="cancelling"
@click="cancelSelectedTask"
>
{{ cancelling ? "Cancelling…" : "Cancel" }}
</button>
<button @click="clearSelection">Back to list</button>
</div>
<p class="muted">