feat: add Worker actions to workspace sidebar
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/workspace/console/Spinner.svelte';
|
||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
|
||||
import {
|
||||
canDeleteSidebarWorker,
|
||||
deleteSidebarWorker,
|
||||
stopSidebarWorker,
|
||||
} from './worker-actions';
|
||||
import {
|
||||
workspaceWorkersStore,
|
||||
type SidebarWorker,
|
||||
@@ -8,6 +14,7 @@
|
||||
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
|
||||
|
||||
const COLLAPSED_WORKER_COUNT = 6;
|
||||
type WorkerActionKind = 'stop' | 'delete';
|
||||
|
||||
type Props = {
|
||||
currentPath?: string;
|
||||
@@ -19,6 +26,10 @@
|
||||
let error = $state<string | null>(null);
|
||||
let workers = $state<SidebarWorker[]>([]);
|
||||
let expanded = $state(false);
|
||||
let openWorkerKey = $state<string | null>(null);
|
||||
let menuElement = $state<HTMLElement | null>(null);
|
||||
let menuTrigger = $state<HTMLButtonElement | null>(null);
|
||||
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
|
||||
let visibleWorkers = $derived(
|
||||
expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT),
|
||||
);
|
||||
@@ -26,8 +37,95 @@
|
||||
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
|
||||
);
|
||||
|
||||
function workerKey(worker: SidebarWorker): string {
|
||||
return `${worker.runtime_id}:${worker.worker_id}`;
|
||||
}
|
||||
|
||||
function isBusy(worker: SidebarWorker, kind: WorkerActionKind): boolean {
|
||||
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
|
||||
}
|
||||
|
||||
function closeWorkerMenu(restoreFocus = false) {
|
||||
const trigger = menuTrigger;
|
||||
openWorkerKey = null;
|
||||
menuElement = null;
|
||||
menuTrigger = null;
|
||||
if (restoreFocus) queueMicrotask(() => trigger?.focus());
|
||||
}
|
||||
|
||||
function toggleWorkerMenu(worker: SidebarWorker, trigger: HTMLButtonElement) {
|
||||
const key = workerKey(worker);
|
||||
if (openWorkerKey === key) {
|
||||
closeWorkerMenu();
|
||||
return;
|
||||
}
|
||||
openWorkerKey = key;
|
||||
menuTrigger = trigger;
|
||||
queueMicrotask(() => {
|
||||
menuElement?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowClick(event: MouseEvent) {
|
||||
if (!openWorkerKey) return;
|
||||
const target = event.target;
|
||||
const owner = target instanceof Element ? target.closest('[data-worker-actions]') : null;
|
||||
if (owner?.getAttribute('data-worker-actions') !== openWorkerKey) closeWorkerMenu();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !openWorkerKey) return;
|
||||
event.preventDefault();
|
||||
closeWorkerMenu(true);
|
||||
}
|
||||
|
||||
async function stopWorker(worker: SidebarWorker) {
|
||||
if (busyAction || !worker.capabilities.can_stop) return;
|
||||
closeWorkerMenu();
|
||||
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
|
||||
try {
|
||||
await stopSidebarWorker(workspaceId, worker);
|
||||
workers = workers.map((item) =>
|
||||
workerKey(item) === workerKey(worker)
|
||||
? { ...item, state: 'stopped', capabilities: { ...item.capabilities, can_stop: false } }
|
||||
: item
|
||||
);
|
||||
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
|
||||
title: 'Worker stopped',
|
||||
});
|
||||
} catch (cause) {
|
||||
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
|
||||
title: 'Worker stop failed',
|
||||
});
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorker(worker: SidebarWorker) {
|
||||
if (busyAction || !canDeleteSidebarWorker(worker)) return;
|
||||
closeWorkerMenu();
|
||||
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
|
||||
try {
|
||||
await deleteSidebarWorker(workspaceId, worker);
|
||||
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
|
||||
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
|
||||
title: 'Worker deleted',
|
||||
});
|
||||
} catch (cause) {
|
||||
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
|
||||
title: 'Worker deletion failed',
|
||||
});
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
expanded = false;
|
||||
openWorkerKey = null;
|
||||
menuElement = null;
|
||||
menuTrigger = null;
|
||||
const subscription = workspaceWorkersStore(workspaceId);
|
||||
return subscription.subscribe((state) => {
|
||||
loading = state.loading;
|
||||
@@ -37,6 +135,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
|
||||
|
||||
<section class="sidebar-nav-section" aria-labelledby="workers-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="workers-heading">
|
||||
@@ -70,7 +170,9 @@
|
||||
{#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||
{@const activity = sidebarWorkerActivity(worker)}
|
||||
<li>
|
||||
{@const key = workerKey(worker)}
|
||||
{@const label = worker.display_name || worker.label}
|
||||
<li class="worker-nav-item" data-worker-actions={key}>
|
||||
<a
|
||||
href={href}
|
||||
class="worker-nav-link"
|
||||
@@ -86,11 +188,47 @@
|
||||
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
||||
<span class="worker-nav-label">{label}</span>
|
||||
<small class="worker-nav-meta">
|
||||
{worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'}
|
||||
</small>
|
||||
</a>
|
||||
<button
|
||||
class="worker-actions-trigger"
|
||||
class:open={openWorkerKey === key}
|
||||
type="button"
|
||||
aria-label={`Actions for ${label}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openWorkerKey === key}
|
||||
onclick={(event) => toggleWorkerMenu(worker, event.currentTarget)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="1.5"></circle>
|
||||
<circle cx="12" cy="12" r="1.5"></circle>
|
||||
<circle cx="19" cy="12" r="1.5"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
{#if openWorkerKey === key}
|
||||
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busyAction !== null || !worker.capabilities.can_stop}
|
||||
onclick={() => stopWorker(worker)}
|
||||
>
|
||||
{isBusy(worker, 'stop') ? 'Stopping…' : 'Stop'}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busyAction !== null || !canDeleteSidebarWorker(worker)}
|
||||
onclick={() => deleteWorker(worker)}
|
||||
>
|
||||
{isBusy(worker, 'delete') ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -410,6 +410,91 @@
|
||||
a.sidebar-link.active {
|
||||
background: var(--sidebar-item-active);
|
||||
}
|
||||
.worker-nav-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.worker-actions-trigger {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
display: grid;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-soft);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.worker-actions-trigger svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
.worker-nav-item:hover .worker-actions-trigger,
|
||||
.worker-nav-item:focus-within .worker-actions-trigger,
|
||||
.worker-actions-trigger.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.worker-actions-trigger:hover,
|
||||
.worker-actions-trigger:focus-visible,
|
||||
.worker-actions-trigger.open {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.worker-actions-trigger:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.worker-actions-menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 2rem;
|
||||
right: 0.35rem;
|
||||
display: grid;
|
||||
min-width: 7rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-1);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.worker-actions-menu button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.worker-actions-menu button:hover:not(:disabled),
|
||||
.worker-actions-menu button:focus-visible {
|
||||
background: var(--sidebar-item-hover);
|
||||
}
|
||||
.worker-actions-menu button:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.worker-actions-menu button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.worker-actions-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.worker-nav-link {
|
||||
display: grid;
|
||||
grid-template-columns: 0.75rem minmax(0, 1fr);
|
||||
@@ -418,6 +503,7 @@
|
||||
row-gap: 0.1rem;
|
||||
margin: 0.0625rem 0;
|
||||
padding: var(--space-2);
|
||||
padding-right: 2.35rem;
|
||||
border-radius: var(--radius-soft);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
Diagnostic,
|
||||
RuntimeCleanupExecutionResponse,
|
||||
RuntimeCleanupPlanResponse,
|
||||
Worker,
|
||||
} from "./types";
|
||||
|
||||
type FetchFn = typeof fetch;
|
||||
|
||||
type WorkerLifecycleResponse = {
|
||||
state: string;
|
||||
diagnostics?: Diagnostic[];
|
||||
};
|
||||
|
||||
function workerPath(workspaceId: string, worker: Worker): string {
|
||||
return workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${
|
||||
encodeURIComponent(worker.worker_id)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<string> {
|
||||
const fallback = `${response.status} ${response.statusText}`.trim();
|
||||
try {
|
||||
const payload = await response.json() as {
|
||||
message?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
return payload.error?.message ?? payload.message ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticMessage(
|
||||
diagnostics: Diagnostic[] | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
return diagnostics?.find((diagnostic) => diagnostic.severity === "error")
|
||||
?.message ??
|
||||
diagnostics?.[0]?.message ??
|
||||
fallback;
|
||||
}
|
||||
|
||||
export function canDeleteSidebarWorker(worker: Worker): boolean {
|
||||
return worker.state === "stopped" || worker.state === "cancelled";
|
||||
}
|
||||
|
||||
export async function stopSidebarWorker(
|
||||
workspaceId: string,
|
||||
worker: Worker,
|
||||
fetchFn: FetchFn = fetch,
|
||||
): Promise<void> {
|
||||
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ reason: "stopped from Workspace sidebar" }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
|
||||
const result = await response.json() as WorkerLifecycleResponse;
|
||||
if (result.state !== "accepted") {
|
||||
throw new Error(
|
||||
diagnosticMessage(result.diagnostics, `Worker stop was ${result.state}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSidebarWorker(
|
||||
workspaceId: string,
|
||||
worker: Worker,
|
||||
fetchFn: FetchFn = fetch,
|
||||
): Promise<void> {
|
||||
const runtimePath = `/runtimes/${encodeURIComponent(worker.runtime_id)}`;
|
||||
const planResponse = await fetchFn(
|
||||
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-plan`),
|
||||
);
|
||||
if (!planResponse.ok) throw new Error(await responseError(planResponse));
|
||||
|
||||
const plan = await planResponse.json() as RuntimeCleanupPlanResponse;
|
||||
const candidate = plan.workers.find((item) =>
|
||||
item.runtime_id === worker.runtime_id &&
|
||||
item.runtime_worker_id === worker.worker_id
|
||||
);
|
||||
if (!candidate) throw new Error("Worker is not available for deletion");
|
||||
if (candidate.blocking_reason) throw new Error(candidate.blocking_reason);
|
||||
|
||||
const executionResponse = await fetchFn(
|
||||
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-executions`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_plan_revision: plan.revision,
|
||||
expected_plan_digest: plan.digest,
|
||||
worker_target_ids: [candidate.target_id],
|
||||
workdir_target_ids: [],
|
||||
confirm_dirty_discard_target_ids: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!executionResponse.ok) {
|
||||
throw new Error(await responseError(executionResponse));
|
||||
}
|
||||
|
||||
const execution = await executionResponse
|
||||
.json() as RuntimeCleanupExecutionResponse;
|
||||
const outcome = execution.results.find((result) =>
|
||||
result.target_id === candidate.target_id
|
||||
);
|
||||
if (!outcome || outcome.status !== "deleted") {
|
||||
throw new Error(
|
||||
outcome?.message ??
|
||||
diagnosticMessage(
|
||||
execution.diagnostics,
|
||||
"Worker deletion was not completed",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
canDeleteSidebarWorker,
|
||||
deleteSidebarWorker,
|
||||
stopSidebarWorker,
|
||||
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
|
||||
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
|
||||
|
||||
function assert(
|
||||
condition: unknown,
|
||||
message = "assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRejects(
|
||||
operation: () => Promise<unknown>,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (cause) {
|
||||
assert(cause instanceof Error, "expected an Error");
|
||||
assert(
|
||||
cause.message.includes(message),
|
||||
`expected error containing ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected operation to reject");
|
||||
}
|
||||
|
||||
const worker = {
|
||||
runtime_id: "runtime /",
|
||||
worker_id: "worker /",
|
||||
state: "running",
|
||||
capabilities: { can_stop: true },
|
||||
} as Worker;
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("sidebar Stop uses the workspace-scoped Worker lifecycle endpoint", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: input.toString(), init });
|
||||
return jsonResponse({ state: "accepted", diagnostics: [] });
|
||||
}) as typeof fetch;
|
||||
|
||||
await stopSidebarWorker("team space", worker, fetchFn);
|
||||
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(
|
||||
requests[0]?.url,
|
||||
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/stop",
|
||||
);
|
||||
assertEquals(requests[0]?.init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(requests[0]?.init?.body)), {
|
||||
reason: "stopped from Workspace sidebar",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("sidebar Stop rejects non-accepted lifecycle responses", async () => {
|
||||
const fetchFn = (() =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
state: "rejected",
|
||||
diagnostics: [{ severity: "error", message: "Worker cannot stop" }],
|
||||
}),
|
||||
)) as typeof fetch;
|
||||
|
||||
await assertRejects(
|
||||
() => stopSidebarWorker("workspace", worker, fetchFn),
|
||||
"Worker cannot stop",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: input.toString(), init });
|
||||
if (requests.length === 1) {
|
||||
return jsonResponse({
|
||||
revision: 7,
|
||||
digest: "digest-7",
|
||||
candidates: [],
|
||||
workers: [{
|
||||
target_id: "worker-target",
|
||||
runtime_id: worker.runtime_id,
|
||||
runtime_worker_id: worker.worker_id,
|
||||
blocking_reason: null,
|
||||
}],
|
||||
workdirs: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
results: [{
|
||||
target_id: "worker-target",
|
||||
status: "deleted",
|
||||
message: null,
|
||||
}],
|
||||
diagnostics: [],
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
await deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn);
|
||||
|
||||
assertEquals(requests.map((request) => request.url), [
|
||||
"/api/w/team/runtimes/runtime%20%2F/cleanup-plan",
|
||||
"/api/w/team/runtimes/runtime%20%2F/cleanup-executions",
|
||||
]);
|
||||
assertEquals(requests[1]?.init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
|
||||
expected_plan_revision: 7,
|
||||
expected_plan_digest: "digest-7",
|
||||
worker_target_ids: ["worker-target"],
|
||||
workdir_target_ids: [],
|
||||
confirm_dirty_discard_target_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
|
||||
const fetchFn = (() =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
revision: 8,
|
||||
digest: "digest-8",
|
||||
candidates: [],
|
||||
workers: [{
|
||||
target_id: "worker-target",
|
||||
runtime_id: worker.runtime_id,
|
||||
runtime_worker_id: worker.worker_id,
|
||||
blocking_reason: "Worker is pinned",
|
||||
}],
|
||||
workdirs: [],
|
||||
diagnostics: [],
|
||||
}),
|
||||
)) as typeof fetch;
|
||||
|
||||
await assertRejects(
|
||||
() => deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn),
|
||||
"Worker is pinned",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
|
||||
assert(!canDeleteSidebarWorker(worker));
|
||||
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
|
||||
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
|
||||
});
|
||||
|
||||
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/lib/workspace/sidebar/WorkersNavSection.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const styles = await Deno.readTextFile(
|
||||
new URL("../../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
|
||||
);
|
||||
|
||||
assert(source.includes('aria-haspopup="menu"'));
|
||||
assert(source.includes('role="menuitem"'));
|
||||
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
|
||||
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
|
||||
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
|
||||
});
|
||||
Reference in New Issue
Block a user