workspace: disable archived worker console links

This commit is contained in:
Keisuke Hirata 2026-07-11 16:01:29 +09:00
parent d8412d022b
commit 30d15daddf
No known key found for this signature in database
9 changed files with 184 additions and 35 deletions

View File

@ -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'

View File

@ -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`.
---

View File

@ -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"
},

View File

@ -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);

View File

@ -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",
);

View File

@ -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>

View 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);
});

View 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";
}

View File

@ -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>