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(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
@@ -13750,18 +13758,22 @@ fn browser_worker_response_from_summary(
let runtime_id = worker.worker.runtime_id.clone();
let worker_id = worker.worker.worker_id.clone();
let workspace_id = api.workspace_id().to_string();
let console_href = format!(
"/w/{}/runtimes/{}/workers/{}/console",
encode_path_segment(&workspace_id),
encode_path_segment(&runtime_id),
encode_path_segment(&worker_id)
);
let resource_key = api
.store
.resource_key(&workspace_id, WorkspaceResourceKind::Worker, &worker_id)?
.ok_or_else(|| {
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 {
workspace_id,
runtime_id,
worker_id,
console_href,
worker: worker_launch_worker_summary(worker),
worker,
diagnostics: diagnostics
.into_iter()
.map(workspace_api::Diagnostic::from)
@@ -17193,6 +17205,13 @@ mod tests {
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]
async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() {
let locks = Arc::new(AsyncMutex::new(HashMap::new()));
@@ -17,7 +17,7 @@ context ではない。`crypto.randomUUID()` は secure context 限定なので
Worker Console の `connectProtocolTransport` は、最初に `protocolState`
`"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"`
- 直後に `workspaceMultiplexer(...).subscribe(...)`
- `web/workspace/src/lib/workspace/multiplexer.ts`
@@ -12,8 +12,8 @@ import {
resolveConsoleWorkerView,
segmentsToText,
selectConsoleTimelineLines,
workerConsoleHref,
} from "./model.ts";
import { workerConsoleHref } from "../resource-links.ts";
declare const Deno: {
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(
workerConsoleHref({
runtime_id: "local runtime",
worker_id: "worker/one",
}, "workspace-1") ===
"/w/workspace-1/runtimes/local%20runtime/workers/worker%2Fone/console",
"href should contain encoded runtime_id and worker_id segments",
workerConsoleHref("workspace-1", {
resource_key: "W-123",
display_name: "Worker One",
}) === "/w/workspace-1/workers/W-123-worker-one/console",
"href should contain the canonical logical Worker reference",
);
});
@@ -15,7 +15,6 @@ import type {
WorkerStatus,
} from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
emptyRunActivityStats,
@@ -220,31 +219,6 @@ export type WorkerTarget = {
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 = {
eventId: string;
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url,
),
);
const routeLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.ts",
import.meta.url,
),
);
assert(
routeLoad.includes("workspaceId") &&
routeLoad.includes("runtimeId") && routeLoad.includes("workerId"),
"route load should expose workspace and target ids",
routeLoad.includes("params.workerRef") &&
routeLoad.includes("resourceKey(params.workerRef)") &&
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(
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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 () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
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", () => {
assertEquals(
buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
"/w/workspace/workers/W-7-review-worker/console",
"workspace",
{ 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", () => {
assertEquals(
buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
"/w/workspace/workers/W-7-review-worker/console",
"workspace",
),
[
{ label: "workers", href: "/w/workspace/workers" },
{ label: "worker-7" },
{ label: "W-7-review-worker" },
],
);
});
@@ -24,13 +24,13 @@ export function buildWorkspaceBreadcrumbs(
.map(decodeURIComponent);
if (
segments[0] === "runtimes" &&
segments[2] === "workers" &&
segments[3]
segments[0] === "workers" &&
segments[1] &&
segments[2] === "console"
) {
return [
{ 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);
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">
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 {
canDeleteSidebarWorker,
@@ -168,7 +168,7 @@
{#if error}<p class="section-state error">{error}</p>{/if}
<ul class="nav-list" aria-label="Workers">
{#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 key = workerKey(worker)}
{@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">
import { workspaceRoute } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/resource-links';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -18,10 +18,7 @@
</div>
<a
class="button-primary"
href={workspaceRoute(
data.workspaceId,
`/runtimes/${data.worker.runtime_id}/workers/${data.worker.worker_id}/console`,
)}
href={workerConsoleHref(data.workspaceId, data.worker)}
>Open console</a>
</header>
<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(
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,
),
);
@@ -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 () => {
const page = await Deno.readTextFile(
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,
),
);
@@ -6,7 +6,7 @@ function assert(condition: unknown, message: string): asserts condition {
Deno.test("Worker Console exposes Overview and Normal display modes", async () => {
const page = await Deno.readTextFile(
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,
),
);