workspace: disable archived worker console links
This commit is contained in:
parent
d8412d022b
commit
30d15daddf
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Disable Console links for archived Workers'
|
||||
state: 'inprogress'
|
||||
state: 'done'
|
||||
created_at: '2026-07-11T06:51:23Z'
|
||||
updated_at: '2026-07-11T06:52:17Z'
|
||||
updated_at: '2026-07-11T07:01:17Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-11T06:52:17Z'
|
||||
|
|
|
|||
|
|
@ -39,4 +39,33 @@ Ticket を `yoi ticket` が queued にしました。
|
|||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-11T07:01:17Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Archived registry-only Workers no longer render as Console links.
|
||||
|
||||
- Added shared `canOpenWorkerConsole` helper that rejects `state: archived` and `implementation.kind: backend_worker_registry`.
|
||||
- Sidebar renders archived Workers as disabled readonly rows instead of anchors.
|
||||
- Workers page shows `Archived` instead of `Open Console` for archived Workers.
|
||||
- Added unit coverage for live vs archived Console availability.
|
||||
|
||||
Validation:
|
||||
- cd web/workspace && deno task check
|
||||
- cd web/workspace && deno task test
|
||||
- git diff --check
|
||||
- nix build .#yoi --no-link
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-11T07:01:17Z from: inprogress to: done reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `done`.
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --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 --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/workers.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -436,6 +436,15 @@
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.worker-nav-item.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.worker-nav-item.disabled .item-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.worker-title-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
||||
);
|
||||
const workersPage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/workers/+page.svelte", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/workers/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const workersNav = await Deno.readTextFile(
|
||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||
|
|
@ -24,8 +27,8 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||
);
|
||||
|
||||
assert(
|
||||
workspacePage.includes('href={`/w/${workspaceId}/runtimes`}') &&
|
||||
workspacePage.includes('href={`/w/${workspaceId}/workers`}'),
|
||||
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
|
||||
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
|
||||
"top workspace page should link to Runtimes and Workers pages",
|
||||
);
|
||||
assert(
|
||||
|
|
@ -35,15 +38,15 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||
);
|
||||
assert(
|
||||
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
||||
workersPage.includes("<table class=\"workers-table\">") &&
|
||||
workersPage.includes('<table class="workers-table">') &&
|
||||
workersPage.includes("Open Console"),
|
||||
"dedicated Workers page should expose a table and attach action per Worker",
|
||||
);
|
||||
assert(
|
||||
workersNav.includes('href={`/w/${workspaceId}/workers`}') &&
|
||||
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
|
||||
workersNav.includes("aria-current"),
|
||||
"Workers sidebar should link to the Worker list page and target Console routes",
|
||||
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||
workersNav.includes("canOpenWorkerConsole(worker)") &&
|
||||
workersNav.includes('aria-disabled="true"'),
|
||||
"Workers sidebar should link to the Worker list page and render archived Workers as disabled rows",
|
||||
);
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
|
|
@ -54,12 +57,17 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||
|
||||
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
consolePage.includes("seenObservationEventIds") &&
|
||||
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
|
||||
consolePage.includes(
|
||||
"rememberObservationEvent(frame.envelope.event_id)",
|
||||
) &&
|
||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
||||
!consolePage.includes("/transcript") &&
|
||||
!consolePage.includes("WorkerTranscriptProjection"),
|
||||
|
|
@ -69,7 +77,10 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
|||
|
||||
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||
const editor = await Deno.readTextFile(
|
||||
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
|
||||
new URL(
|
||||
"../workspace-settings/DecodalSourceEditor.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
|
|
@ -89,18 +100,27 @@ Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned
|
|||
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const runtimesPage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/runtimes/+page.svelte", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const workdirsPage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const workdirsLoad = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
sidebar.includes("RuntimesNavSection") &&
|
||||
runtimesNav.includes('href={runtimesHref}') &&
|
||||
runtimesNav.includes("href={runtimesHref}") &&
|
||||
runtimesNav.includes("/runtimes"),
|
||||
"sidebar should expose Runtime management navigation",
|
||||
);
|
||||
|
|
@ -123,18 +143,21 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
|
|||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const newWorkerPage = await Deno.readTextFile(
|
||||
new URL("./../../routes/w/[workspaceId]/workers/new/+page.svelte", import.meta.url),
|
||||
new URL(
|
||||
"./../../routes/w/[workspaceId]/workers/new/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
workersNav.includes('href={`/w/${workspaceId}/workers/new`}') &&
|
||||
!workersNav.includes('worker-launch-form') &&
|
||||
!workersNav.includes('createWorker()'),
|
||||
workersNav.includes("href={`/w/${workspaceId}/workers/new`}") &&
|
||||
!workersNav.includes("worker-launch-form") &&
|
||||
!workersNav.includes("createWorker()"),
|
||||
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
||||
);
|
||||
assert(
|
||||
newWorkerPage.includes('worker-launch-form') &&
|
||||
newWorkerPage.includes('buildBrowserCreateWorkerRequest') &&
|
||||
newWorkerPage.includes("worker-launch-form") &&
|
||||
newWorkerPage.includes("buildBrowserCreateWorkerRequest") &&
|
||||
newWorkerPage.includes("/workers/launch-options"),
|
||||
"New Worker page should own launch options and creation form behavior",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||
import { canOpenWorkerConsole } from './workers';
|
||||
import type { ListResponse, Worker } from './types';
|
||||
|
||||
const MAX_VISIBLE_WORKERS = 6;
|
||||
|
|
@ -99,7 +100,9 @@
|
|||
<ul class="nav-list" aria-label="Workers">
|
||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||
{@const consoleAvailable = canOpenWorkerConsole(worker)}
|
||||
<li>
|
||||
{#if consoleAvailable}
|
||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||
<span class="worker-title-row">
|
||||
<span class="item-title">{worker.label}</span>
|
||||
|
|
@ -110,6 +113,18 @@
|
|||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||
</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-item worker-nav-item disabled" aria-disabled="true">
|
||||
<span class="worker-title-row">
|
||||
<span class="item-title">{worker.label}</span>
|
||||
<span class="worker-task-title">archived</span>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
|
|
|||
62
web/workspace/src/lib/workspace-sidebar/workers.test.ts
Normal file
62
web/workspace/src/lib/workspace-sidebar/workers.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { canOpenWorkerConsole } from "./workers.ts";
|
||||
import type { Worker } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function worker(overrides: Partial<Worker>): Worker {
|
||||
return {
|
||||
runtime_id: "arc",
|
||||
worker_id: "worker-1",
|
||||
host_id: "host",
|
||||
label: "worker-1",
|
||||
role: null,
|
||||
profile: null,
|
||||
workspace: { visibility: "workspace", identity: "workspace" },
|
||||
state: "running",
|
||||
pinned: false,
|
||||
retention_state: "normal",
|
||||
last_seen_at: null,
|
||||
implementation: {
|
||||
kind: "runtime_worker",
|
||||
display_hint: "Runtime Worker",
|
||||
},
|
||||
capabilities: {
|
||||
can_accept_input: true,
|
||||
can_stop: true,
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
working_directory: null,
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("canOpenWorkerConsole rejects archived registry-only workers", () => {
|
||||
assertEquals(
|
||||
canOpenWorkerConsole(worker({
|
||||
state: "archived",
|
||||
implementation: {
|
||||
kind: "backend_worker_registry",
|
||||
display_hint: "Archived Worker",
|
||||
},
|
||||
capabilities: {
|
||||
can_accept_input: false,
|
||||
can_stop: false,
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
})),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("canOpenWorkerConsole accepts live runtime workers", () => {
|
||||
assertEquals(canOpenWorkerConsole(worker({ state: "running" })), true);
|
||||
});
|
||||
6
web/workspace/src/lib/workspace-sidebar/workers.ts
Normal file
6
web/workspace/src/lib/workspace-sidebar/workers.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Worker } from "./types";
|
||||
|
||||
export function canOpenWorkerConsole(worker: Worker): boolean {
|
||||
return worker.state !== "archived" &&
|
||||
worker.implementation.kind !== "backend_worker_registry";
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
|
||||
import type { Worker } from '$lib/workspace-sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
|
|
@ -91,7 +92,11 @@
|
|||
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
||||
<td>{workerDirectory(worker)}</td>
|
||||
<td>
|
||||
{#if canOpenWorkerConsole(worker)}
|
||||
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
||||
{:else}
|
||||
<span class="muted" aria-disabled="true">Archived</span>
|
||||
{/if}
|
||||
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
||||
{worker.pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user