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'
|
title: 'Disable Console links for archived Workers'
|
||||||
state: 'inprogress'
|
state: 'done'
|
||||||
created_at: '2026-07-11T06:51:23Z'
|
created_at: '2026-07-11T06:51:23Z'
|
||||||
updated_at: '2026-07-11T06:52:17Z'
|
updated_at: '2026-07-11T07:01:17Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'yoi ticket'
|
queued_by: 'yoi ticket'
|
||||||
queued_at: '2026-07-11T06:52:17Z'
|
queued_at: '2026-07-11T06:52:17Z'
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,33 @@ Ticket を `yoi ticket` が queued にしました。
|
||||||
State changed to `inprogress`.
|
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": "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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,15 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-nav-item.disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-nav-item.disabled .item-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.worker-title-row {
|
.worker-title-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
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),
|
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const workersPage = await Deno.readTextFile(
|
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(
|
const workersNav = await Deno.readTextFile(
|
||||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
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(
|
assert(
|
||||||
workspacePage.includes('href={`/w/${workspaceId}/runtimes`}') &&
|
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
|
||||||
workspacePage.includes('href={`/w/${workspaceId}/workers`}'),
|
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
|
||||||
"top workspace page should link to Runtimes and Workers pages",
|
"top workspace page should link to Runtimes and Workers pages",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -35,15 +38,15 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
||||||
workersPage.includes("<table class=\"workers-table\">") &&
|
workersPage.includes('<table class="workers-table">') &&
|
||||||
workersPage.includes("Open Console"),
|
workersPage.includes("Open Console"),
|
||||||
"dedicated Workers page should expose a table and attach action per Worker",
|
"dedicated Workers page should expose a table and attach action per Worker",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersNav.includes('href={`/w/${workspaceId}/workers`}') &&
|
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||||
workersNav.includes("workerConsoleHref(worker, workspaceId)") &&
|
workersNav.includes("canOpenWorkerConsole(worker)") &&
|
||||||
workersNav.includes("aria-current"),
|
workersNav.includes('aria-disabled="true"'),
|
||||||
"Workers sidebar should link to the Worker list page and target Console routes",
|
"Workers sidebar should link to the Worker list page and render archived Workers as disabled rows",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!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 () => {
|
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
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(
|
assert(
|
||||||
consolePage.includes("seenObservationEventIds") &&
|
consolePage.includes("seenObservationEventIds") &&
|
||||||
consolePage.includes("rememberObservationEvent(frame.envelope.event_id)") &&
|
consolePage.includes(
|
||||||
|
"rememberObservationEvent(frame.envelope.event_id)",
|
||||||
|
) &&
|
||||||
consolePage.includes("projectConsole(observedEvents.map") &&
|
consolePage.includes("projectConsole(observedEvents.map") &&
|
||||||
!consolePage.includes("/transcript") &&
|
!consolePage.includes("/transcript") &&
|
||||||
!consolePage.includes("WorkerTranscriptProjection"),
|
!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 () => {
|
Deno.test("Decodal source editor keeps imperative EditorView out of reactive state", async () => {
|
||||||
const editor = await Deno.readTextFile(
|
const editor = await Deno.readTextFile(
|
||||||
new URL("../workspace-settings/DecodalSourceEditor.svelte", import.meta.url),
|
new URL(
|
||||||
|
"../workspace-settings/DecodalSourceEditor.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
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),
|
new URL("../workspace-sidebar/RuntimesNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const runtimesPage = await Deno.readTextFile(
|
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(
|
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(
|
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(
|
assert(
|
||||||
sidebar.includes("RuntimesNavSection") &&
|
sidebar.includes("RuntimesNavSection") &&
|
||||||
runtimesNav.includes('href={runtimesHref}') &&
|
runtimesNav.includes("href={runtimesHref}") &&
|
||||||
runtimesNav.includes("/runtimes"),
|
runtimesNav.includes("/runtimes"),
|
||||||
"sidebar should expose Runtime management navigation",
|
"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),
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const newWorkerPage = await Deno.readTextFile(
|
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(
|
assert(
|
||||||
workersNav.includes('href={`/w/${workspaceId}/workers/new`}') &&
|
workersNav.includes("href={`/w/${workspaceId}/workers/new`}") &&
|
||||||
!workersNav.includes('worker-launch-form') &&
|
!workersNav.includes("worker-launch-form") &&
|
||||||
!workersNav.includes('createWorker()'),
|
!workersNav.includes("createWorker()"),
|
||||||
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
newWorkerPage.includes('worker-launch-form') &&
|
newWorkerPage.includes("worker-launch-form") &&
|
||||||
newWorkerPage.includes('buildBrowserCreateWorkerRequest') &&
|
newWorkerPage.includes("buildBrowserCreateWorkerRequest") &&
|
||||||
newWorkerPage.includes("/workers/launch-options"),
|
newWorkerPage.includes("/workers/launch-options"),
|
||||||
"New Worker page should own launch options and creation form behavior",
|
"New Worker page should own launch options and creation form behavior",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
|
import { canOpenWorkerConsole } from './workers';
|
||||||
import type { ListResponse, Worker } from './types';
|
import type { ListResponse, Worker } from './types';
|
||||||
|
|
||||||
const MAX_VISIBLE_WORKERS = 6;
|
const MAX_VISIBLE_WORKERS = 6;
|
||||||
|
|
@ -99,17 +100,31 @@
|
||||||
<ul class="nav-list" aria-label="Workers">
|
<ul class="nav-list" aria-label="Workers">
|
||||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||||
|
{@const consoleAvailable = canOpenWorkerConsole(worker)}
|
||||||
<li>
|
<li>
|
||||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
{#if consoleAvailable}
|
||||||
<span class="worker-title-row">
|
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||||
<span class="item-title">{worker.label}</span>
|
<span class="worker-title-row">
|
||||||
<span class="worker-task-title">-</span>
|
<span class="item-title">{worker.label}</span>
|
||||||
</span>
|
<span class="worker-task-title">-</span>
|
||||||
<span class="item-meta">
|
</span>
|
||||||
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
<span class="item-meta">
|
||||||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
{worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||||
</span>
|
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||||
</a>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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">
|
<script lang="ts">
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
|
import { canOpenWorkerConsole } from '$lib/workspace-sidebar/workers';
|
||||||
import type { Worker } from '$lib/workspace-sidebar/types';
|
import type { Worker } from '$lib/workspace-sidebar/types';
|
||||||
import type { PageProps } from './$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><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
|
||||||
<td>{workerDirectory(worker)}</td>
|
<td>{workerDirectory(worker)}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={workerConsoleHref(worker, data.workspaceId)}>Open Console</a>
|
{#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)}>
|
<button type="button" onclick={() => setPinned(worker, !worker.pinned)}>
|
||||||
{worker.pinned ? 'Unpin' : 'Pin'}
|
{worker.pinned ? 'Unpin' : 'Pin'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user