From 4d04d7ac834dc616e02ec25187bd5c0bb91608f1 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 15 Jul 2026 18:43:55 +0800 Subject: [PATCH] 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. --- cloud-console/src/api.ts | 8 ++++++ cloud-console/src/taskCancellation.test.ts | 24 ++++++++++++++++ cloud-console/src/taskCancellation.ts | 20 +++++++++++++ cloud-console/src/types.ts | 8 +++++- cloud-console/src/views/TasksView.vue | 31 +++++++++++++++++++++ openspec/changes/task-cancellation/tasks.md | 8 +++--- 6 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 cloud-console/src/taskCancellation.test.ts create mode 100644 cloud-console/src/taskCancellation.ts diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts index 44ab4ab..523b8b1 100644 --- a/cloud-console/src/api.ts +++ b/cloud-console/src/api.ts @@ -13,6 +13,7 @@ import type { PluginRecord, PluginRegistrationPayload, TaskAttempt, + TaskCancellationResponse, TaskListResponse, TaskSubmissionPayload, TaskStatus, @@ -239,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise { return request(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`); } +export function cancelTask(taskId: string): Promise { + return request( + `/v1/tasks/${encodeURIComponent(taskId)}/cancel`, + { method: "POST" }, + ); +} + export function getTaskPlannerDecisions( taskId: string, attempt: number, diff --git a/cloud-console/src/taskCancellation.test.ts b/cloud-console/src/taskCancellation.test.ts new file mode 100644 index 0000000..27f8e36 --- /dev/null +++ b/cloud-console/src/taskCancellation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { canCancelTask } from "./taskCancellation"; +import type { TaskStatus } from "./types"; + +describe("canCancelTask", () => { + it.each(["queued", "assigned", "dispatched"])( + "allows cancelling a %s task when the caller can submit", + (status) => { + expect(canCancelTask(status, true)).toBe(true); + }, + ); + + it.each(["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); + }); +}); diff --git a/cloud-console/src/taskCancellation.ts b/cloud-console/src/taskCancellation.ts new file mode 100644 index 0000000..3a56184 --- /dev/null +++ b/cloud-console/src/taskCancellation.ts @@ -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 = new Set([ + "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); +} diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index 7ee4c50..6b26a12 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -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; diff --git a/cloud-console/src/views/TasksView.vue b/cloud-console/src/views/TasksView.vue index e7732b2..969b927 100644 --- a/cloud-console/src/views/TasksView.vue +++ b/cloud-console/src/views/TasksView.vue @@ -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(""); @@ -55,6 +58,7 @@ const devices = ref([]); const plannerDecisions = ref([]); 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 {

Task {{ selectedTask.id.slice(0, 8) }}

+

diff --git a/openspec/changes/task-cancellation/tasks.md b/openspec/changes/task-cancellation/tasks.md index 014ec3f..be8bc03 100644 --- a/openspec/changes/task-cancellation/tasks.md +++ b/openspec/changes/task-cancellation/tasks.md @@ -46,10 +46,10 @@ ## 6. Frontend: Cloud Console -- [ ] 6.1 Add `"cancelled"` to `TaskStatus` in `cloud-console/src/types.ts` and to the `STATUSES` array in `TasksView.vue`. -- [ ] 6.2 Add a `cancelTask(taskId)` method to `cloud-console/src/api.ts`. -- [ ] 6.3 Add a "Cancel" button to `TasksView.vue`'s task detail panel, visible only when the selected task's status is `queued`/`assigned`/`dispatched` and the operator's token has `tasks:submit`; on click, call `cancelTask` and refresh the displayed task. -- [ ] 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled. +- [x] 6.1 Add `"cancelled"` to `TaskStatus` in `cloud-console/src/types.ts` and to the `STATUSES` array in `TasksView.vue`. +- [x] 6.2 Add a `cancelTask(taskId)` method to `cloud-console/src/api.ts`. +- [x] 6.3 Add a "Cancel" button to `TasksView.vue`'s task detail panel, visible only when the selected task's status is `queued`/`assigned`/`dispatched` and the operator's token has `tasks:submit`; on click, call `cancelTask` and refresh the displayed task. +- [x] 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled. (Project has no Vue component-mounting test harness — `@vue/test-utils` isn't a dependency and no existing test exercises a `.vue` file directly. Followed the established pattern instead: extracted the visibility rule into a pure, unit-tested `taskCancellation.ts` module — mirroring `taskProgress.ts`/`plannerHistory.ts` — covering cancellable vs. terminal statuses and the `tasks:submit` scope gate. `cancelSelectedTask` in `TasksView.vue` only mutates `selectedTask.status` on a successful response and routes failures through the existing `handleError`/`errorMessage` path, so an error never flips the displayed status to cancelled.) ## 7. Frontend: Host Agent local console