fix: route worker consoles through logical resources

This commit is contained in:
2026-09-07 03:53:34 +09:00
parent f1baea1705
commit f5c5ea5a0b
16 changed files with 114 additions and 98 deletions
+26 -7
View File
@@ -13439,6 +13439,14 @@ fn browser_worker_spawn_policy(
} }
} }
fn browser_worker_console_href(workspace_id: &str, resource_key: &str) -> String {
format!(
"/w/{}/workers/{}/console",
encode_path_segment(workspace_id),
encode_path_segment(resource_key)
)
}
async fn create_workspace_worker( async fn create_workspace_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
headers: HeaderMap, headers: HeaderMap,
@@ -13750,18 +13758,22 @@ fn browser_worker_response_from_summary(
let runtime_id = worker.worker.runtime_id.clone(); let runtime_id = worker.worker.runtime_id.clone();
let worker_id = worker.worker.worker_id.clone(); let worker_id = worker.worker.worker_id.clone();
let workspace_id = api.workspace_id().to_string(); let workspace_id = api.workspace_id().to_string();
let console_href = format!( let resource_key = api
"/w/{}/runtimes/{}/workers/{}/console", .store
encode_path_segment(&workspace_id), .resource_key(&workspace_id, WorkspaceResourceKind::Worker, &worker_id)?
encode_path_segment(&runtime_id), .ok_or_else(|| {
encode_path_segment(&worker_id) Error::Store(format!(
); "Workspace Worker `{worker_id}` has no resource key after registration"
))
})?;
let worker = worker_launch_worker_summary(worker);
let console_href = browser_worker_console_href(&workspace_id, &resource_key);
Ok(BrowserCreateWorkerResponse { Ok(BrowserCreateWorkerResponse {
workspace_id, workspace_id,
runtime_id, runtime_id,
worker_id, worker_id,
console_href, console_href,
worker: worker_launch_worker_summary(worker), worker,
diagnostics: diagnostics diagnostics: diagnostics
.into_iter() .into_iter()
.map(workspace_api::Diagnostic::from) .map(workspace_api::Diagnostic::from)
@@ -17193,6 +17205,13 @@ mod tests {
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
}; };
#[test]
fn browser_worker_console_href_uses_logical_worker_route() {
let href = browser_worker_console_href("workspace/one", "W-7");
assert_eq!(href, "/w/workspace%2Fone/workers/W-7/console");
assert!(!href.contains("/runtimes/"));
}
#[tokio::test] #[tokio::test]
async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() { async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() {
let locks = Arc::new(AsyncMutex::new(HashMap::new())); let locks = Arc::new(AsyncMutex::new(HashMap::new()));
@@ -17,7 +17,7 @@ context ではない。`crypto.randomUUID()` は secure context 限定なので
Worker Console の `connectProtocolTransport` は、最初に `protocolState` Worker Console の `connectProtocolTransport` は、最初に `protocolState`
`"connecting"` に設定してから `WorkspaceMultiplexer.subscribe()` を呼ぶ。 `"connecting"` に設定してから `WorkspaceMultiplexer.subscribe()` を呼ぶ。
- `web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte` - `web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte`
- `protocolState = "connecting"` - `protocolState = "connecting"`
- 直後に `workspaceMultiplexer(...).subscribe(...)` - 直後に `workspaceMultiplexer(...).subscribe(...)`
- `web/workspace/src/lib/workspace/multiplexer.ts` - `web/workspace/src/lib/workspace/multiplexer.ts`
@@ -12,8 +12,8 @@ import {
resolveConsoleWorkerView, resolveConsoleWorkerView,
segmentsToText, segmentsToText,
selectConsoleTimelineLines, selectConsoleTimelineLines,
workerConsoleHref,
} from "./model.ts"; } from "./model.ts";
import { workerConsoleHref } from "../resource-links.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => void): void; test(name: string, fn: () => void): void;
@@ -461,14 +461,13 @@ Deno.test("reload snapshot projects provenance-annotated history entries", () =>
); );
}); });
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { Deno.test("workerConsoleHref uses logical Worker resource authority", () => {
assert( assert(
workerConsoleHref({ workerConsoleHref("workspace-1", {
runtime_id: "local runtime", resource_key: "W-123",
worker_id: "worker/one", display_name: "Worker One",
}, "workspace-1") === }) === "/w/workspace-1/workers/W-123-worker-one/console",
"/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console", "href should contain the canonical logical Worker reference",
"href should contain encoded runtime_id and worker_id segments",
); );
}); });
@@ -15,7 +15,6 @@ import type {
WorkerStatus, WorkerStatus,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
import { import {
applyRunActivityEvent, applyRunActivityEvent,
emptyRunActivityStats, emptyRunActivityStats,
@@ -220,31 +219,6 @@ export type WorkerTarget = {
worker_id: string; worker_id: string;
}; };
export function workerConsoleHref(
target: WorkerTarget,
workspaceId: string,
): string {
return workspaceRoute(
workspaceId,
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
encodeURIComponent(
target.worker_id,
)
}/console`,
);
}
export function workerConsolePath(
workspaceId: string,
runtimeId: string,
workerId: string,
): string {
return workerConsoleHref(
{ runtime_id: runtimeId, worker_id: workerId },
workspaceId,
);
}
export type ConsoleEventInput = { export type ConsoleEventInput = {
eventId: string; eventId: string;
event: ProtocolEvent; event: ProtocolEvent;
@@ -371,7 +371,7 @@ Deno.test("root layout keeps Workspace selection explicit", 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( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -395,7 +395,7 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
Deno.test("Worker Console owns its narrower centered shell width", async () => { Deno.test("Worker Console owns its narrower centered shell width", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -494,7 +494,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => { Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -529,7 +529,7 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => { Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -558,7 +558,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => { Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -592,7 +592,7 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async (
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => { Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -724,24 +724,28 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
); );
}); });
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => { Deno.test("Worker Console route resolves logical Worker authority before Runtime APIs", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
const routeLoad = await Deno.readTextFile( const routeLoad = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.ts",
import.meta.url, import.meta.url,
), ),
); );
assert( assert(
routeLoad.includes("workspaceId") && routeLoad.includes("params.workerRef") &&
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"), routeLoad.includes("resourceKey(params.workerRef)") &&
"route load should expose workspace and target ids", routeLoad.includes("/workers/${encodeURIComponent(reference)}") &&
routeLoad.includes("canonicalResourceReference") &&
routeLoad.includes("runtimeId: result.data?.runtime_id") &&
routeLoad.includes("workerId: result.data?.worker_id"),
"route load should resolve the logical Worker reference before exposing its execution target",
); );
assert( assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") && consolePage.includes("workspaceApiPath(workspaceId, path)") &&
@@ -951,7 +955,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
Deno.test("Workspace Worker list and Console share the multiplexed connection", async () => { Deno.test("Workspace Worker list and Console share the multiplexed connection", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -988,7 +992,7 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
Deno.test("Web Console renders the client-projected Worker task store", async () => { Deno.test("Web Console renders the client-projected Worker task store", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -1027,7 +1031,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => { Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -1071,7 +1075,7 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
Deno.test("Web Console uses Notify while running and exposes durable pending controls", async () => { Deno.test("Web Console uses Notify while running and exposes durable pending controls", async () => {
const consolePage = await Deno.readTextFile( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -25,7 +25,7 @@ Deno.test("Ticket detail breadcrumbs expose the Ticket list and current id", ()
Deno.test("Worker console breadcrumbs use the logical Workers route and display name", () => { Deno.test("Worker console breadcrumbs use the logical Workers route and display name", () => {
assertEquals( assertEquals(
buildWorkspaceBreadcrumbs( buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console", "/w/workspace/workers/W-7-review-worker/console",
"workspace", "workspace",
{ workerName: "Review Worker" }, { workerName: "Review Worker" },
), ),
@@ -39,12 +39,12 @@ Deno.test("Worker console breadcrumbs use the logical Workers route and display
Deno.test("Worker console breadcrumbs fall back to Worker id", () => { Deno.test("Worker console breadcrumbs fall back to Worker id", () => {
assertEquals( assertEquals(
buildWorkspaceBreadcrumbs( buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console", "/w/workspace/workers/W-7-review-worker/console",
"workspace", "workspace",
), ),
[ [
{ label: "workers", href: "/w/workspace/workers" }, { label: "workers", href: "/w/workspace/workers" },
{ label: "worker-7" }, { label: "W-7-review-worker" },
], ],
); );
}); });
@@ -24,13 +24,13 @@ export function buildWorkspaceBreadcrumbs(
.map(decodeURIComponent); .map(decodeURIComponent);
if ( if (
segments[0] === "runtimes" && segments[0] === "workers" &&
segments[2] === "workers" && segments[1] &&
segments[3] segments[2] === "console"
) { ) {
return [ return [
{ label: "workers", href: `${workspaceRoot}/workers` }, { label: "workers", href: `${workspaceRoot}/workers` },
{ label: context.workerName?.trim() || segments[3] }, { label: context.workerName?.trim() || segments[1] },
]; ];
} }
@@ -45,3 +45,10 @@ export function workerHref(
const reference = canonicalResourceReference(worker.resource_key, worker.display_name); const reference = canonicalResourceReference(worker.resource_key, worker.display_name);
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`; return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
} }
export function workerConsoleHref(
workspaceId: string,
worker: { resource_key: string; display_name: string },
): string {
return `${workerHref(workspaceId, worker)}/console`;
}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte'; import Spinner from '$lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerConsoleHref } from '$lib/workspace/resource-links';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store'; import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import { import {
canDeleteSidebarWorker, canDeleteSidebarWorker,
@@ -168,7 +168,7 @@
{#if error}<p class="section-state error">{error}</p>{/if} {#if error}<p class="section-state error">{error}</p>{/if}
<ul class="nav-list" aria-label="Workers"> <ul class="nav-list" aria-label="Workers">
{#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)} {#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker, workspaceId)} {@const href = workerConsoleHref(workspaceId, worker)}
{@const activity = sidebarWorkerActivity(worker)} {@const activity = sidebarWorkerActivity(worker)}
{@const key = workerKey(worker)} {@const key = workerKey(worker)}
{@const label = worker.display_name || worker.label} {@const label = worker.display_name || worker.label}
@@ -1,23 +0,0 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { Worker } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const worker = await loadJson<Worker>(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(params.runtimeId)}/workers/${
encodeURIComponent(params.workerId)
}`,
),
);
return {
workspaceId: params.workspaceId,
runtimeId: params.runtimeId,
workerId: params.workerId,
worker: worker.data,
workerError: worker.error,
};
};
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workerConsoleHref } from '$lib/workspace/resource-links';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state'; import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -18,10 +18,7 @@
</div> </div>
<a <a
class="button-primary" class="button-primary"
href={workspaceRoute( href={workerConsoleHref(data.workspaceId, data.worker)}
data.workspaceId,
`/runtimes/${data.worker.runtime_id}/workers/${data.worker.worker_id}/console`,
)}
>Open console</a> >Open console</a>
</header> </header>
<dl class="resource-meta"> <dl class="resource-meta">
@@ -0,0 +1,39 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { Worker } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const reference = resourceKey(params.workerRef);
const result = await loadJson<Worker>(
fetch,
workspaceApiPath(
params.workspaceId,
`/workers/${encodeURIComponent(reference)}`,
),
);
if (result.data?.resource_key) {
const canonical = canonicalResourceReference(
result.data.resource_key,
result.data.display_name,
);
if (params.workerRef !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}/console`,
);
}
}
return {
workspaceId: params.workspaceId,
runtimeId: result.data?.runtime_id ?? "",
workerId: result.data?.worker_id ?? "",
worker: result.data,
workerError: result.error,
};
}) satisfies PageLoad;
+1 -1
View File
@@ -186,7 +186,7 @@ Deno.test("Composer input uses boundary-aware Up and Down history navigation", a
); );
const consoleSource = await Deno.readTextFile( const consoleSource = await Deno.readTextFile(
new URL( new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "../src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -87,7 +87,7 @@ Deno.test("sidebar running status reuses the green symbol spinner", async () =>
Deno.test("running status is Composer-side above mini Tasks", async () => { Deno.test("running status is Composer-side above mini Tasks", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "../src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
@@ -6,7 +6,7 @@ function assert(condition: unknown, message: string): asserts condition {
Deno.test("Worker Console exposes Overview and Normal display modes", async () => { Deno.test("Worker Console exposes Overview and Normal display modes", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "../src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );