feat: add workspace runtime and worker controls

This commit is contained in:
2026-07-03 02:27:42 +09:00
parent 2eb90470bb
commit f2fead7ebd
10 changed files with 1550 additions and 42 deletions
+153
View File
@@ -1140,3 +1140,156 @@
display: grid;
}
}
.section-heading-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-action {
margin-left: auto;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-subtle);
color: var(--text-strong);
font-size: 0.72rem;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
.worker-new-form {
display: grid;
gap: 0.65rem;
margin: 0.6rem 0 0.75rem;
padding: 0.65rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.03);
}
.worker-new-form label,
.settings-runtime-form label {
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
}
.worker-new-form input,
.worker-new-form select,
.worker-new-form textarea,
.settings-runtime-form input {
width: 100%;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--bg-raised);
color: var(--text-strong);
padding: 0.45rem 0.55rem;
font: inherit;
}
.worker-new-form textarea {
resize: vertical;
}
.worker-new-form button,
.settings-runtime-form button,
.settings-action-row button {
border: 0;
border-radius: 0.6rem;
background: var(--accent);
color: var(--bg);
font-weight: 700;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
.worker-new-form button:disabled,
.settings-runtime-form button:disabled,
.settings-action-row button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-runtime-form,
.settings-runtime-list {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.settings-runtime-form {
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
}
.settings-runtime-card {
display: grid;
gap: 0.75rem;
border: 1px solid var(--line);
border-radius: 1rem;
padding: 1rem;
background: var(--bg-raised);
}
.settings-runtime-card header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.settings-runtime-card.inactive {
opacity: 0.86;
}
.settings-identity-list.compact {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.settings-action-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.settings-action-row .danger {
background: var(--danger);
color: var(--bg);
}
.settings-diagnostics-list {
display: grid;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.settings-diagnostics-list li {
display: grid;
gap: 0.15rem;
border-radius: 0.6rem;
border: 1px solid var(--line);
padding: 0.55rem 0.65rem;
color: var(--text-muted);
}
.settings-diagnostics-list li.error {
border-color: rgba(255, 99, 99, 0.55);
}
.settings-diagnostics-list li.warning {
border-color: rgba(255, 205, 86, 0.55);
}
.settings-test-result {
display: grid;
gap: 0.3rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.04);
padding: 0.75rem;
}
@@ -5,12 +5,39 @@
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref,
type Diagnostic,
type RemoteRuntimeConnectionSummary,
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
type RuntimeConnectionSummary,
} from "./model";
type RemoteAddForm = {
runtime_id: string;
display_name: string;
endpoint: string;
};
let workspace = $state<WorkspaceResponse | null>(null);
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
let loading = $state(true);
let runtimeLoading = $state(true);
let error = $state<string | null>(null);
let runtimeError = $state<string | null>(null);
let mutationMessage = $state<string | null>(null);
let mutationDiagnostics = $state<Diagnostic[]>([]);
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
let deleting = $state<string | null>(null);
let testing = $state<string | null>(null);
let submitting = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: "",
display_name: "",
endpoint: "",
});
$effect(() => {
let cancelled = false;
@@ -39,12 +66,165 @@
}
}
async function loadRuntimeSettings() {
runtimeLoading = true;
runtimeError = null;
try {
const response = await fetch("/api/settings/runtime-connections");
if (!response.ok) {
throw new Error(`runtime settings request failed (${response.status})`);
}
const data = (await response.json()) as RuntimeConnectionSettingsResponse;
if (!cancelled) {
runtimeSettings = data;
}
} catch (err) {
if (!cancelled) {
runtimeError = err instanceof Error ? err.message : "runtime settings request failed";
}
} finally {
if (!cancelled) {
runtimeLoading = false;
}
}
}
loadWorkspace();
loadRuntimeSettings();
return () => {
cancelled = true;
};
});
async function submitRemoteRuntime() {
submitting = true;
mutationMessage = null;
mutationDiagnostics = [];
try {
const response = await fetch("/api/settings/runtime-connections/remotes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
runtime_id: remoteForm.runtime_id,
display_name: remoteForm.display_name || null,
endpoint: remoteForm.endpoint,
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, "add remote Runtime failed"));
}
const data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
remoteForm = { runtime_id: "", display_name: "", endpoint: "" };
} catch (err) {
mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed";
} finally {
submitting = false;
}
}
async function deleteRemoteRuntime(runtimeId: string) {
deleting = runtimeId;
mutationMessage = null;
mutationDiagnostics = [];
try {
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, "delete remote Runtime failed"));
}
const data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
const nextTests = { ...tests };
delete nextTests[runtimeId];
tests = nextTests;
} catch (err) {
mutationMessage = err instanceof Error ? err.message : "delete remote Runtime failed";
} finally {
deleting = null;
}
}
async function testRemoteRuntime(runtimeId: string) {
testing = runtimeId;
try {
const response = await fetch(`/api/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`, {
method: "POST",
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, "test remote Runtime failed"));
}
const data = (await response.json()) as RemoteRuntimeTestResponse;
tests = { ...tests, [runtimeId]: data };
} catch (err) {
tests = {
...tests,
[runtimeId]: {
workspace_id: runtimeSettings?.workspace_id ?? "unknown",
runtime_id: runtimeId,
checked_at: new Date().toISOString(),
state: "failed",
protocol_version: null,
compatibility_basis: "browser request failed",
capabilities: [],
health_result: "failed",
diagnostics: [
{
code: "browser_runtime_test_failed",
severity: "error",
message: err instanceof Error ? err.message : "test remote Runtime failed",
},
],
},
};
} finally {
testing = null;
}
}
function applyRuntimeMutation(data: RuntimeConnectionMutationResponse) {
runtimeSettings = runtimeSettings
? { ...runtimeSettings, remotes: data.remotes, diagnostics: data.diagnostics }
: {
workspace_id: data.workspace_id,
embedded: {
runtime_id: "embedded-worker-runtime",
display_name: "Embedded Runtime",
kind: "embedded_worker_runtime",
built_in: true,
config_managed: false,
active: false,
can_spawn_worker: false,
restart_required: false,
status: "unknown",
diagnostics: [],
},
remotes: data.remotes,
diagnostics: data.diagnostics,
};
mutationDiagnostics = data.diagnostics;
mutationMessage = data.restart_required
? "Runtime config saved. Restart the Workspace backend to apply live registry changes."
: "Runtime config saved.";
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
if (typeof payload.error === "object" && payload.error?.message) {
return `${payload.error.code ?? "request_failed"}: ${payload.error.message}`;
}
if (payload.message) {
const code = typeof payload.error === "string" ? payload.error : "request_failed";
return `${code}: ${payload.message}`;
}
} catch {
// fall through
}
return `${fallback} (${response.status})`;
}
</script>
<svelte:head>
@@ -60,11 +240,10 @@
<p class="eyebrow">Workspace Browser</p>
<h1 id="settings-title">Settings / Admin</h1>
<p class="hero-copy">
Read-only shell for future local administration surfaces. This page creates
navigation and operator context without adding mutation authority.
Local administration surfaces for the Workspace backend. Runtime Connections v0 is editable through typed APIs; broader admin controls remain bounded placeholders.
</p>
</div>
<span class="badge warning">shell only</span>
<span class="badge warning">local only</span>
</section>
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
@@ -74,8 +253,8 @@
<p>{SETTINGS_PERMISSION_NOTICE}</p>
</div>
<div class="settings-diagnostic" role="note">
<strong>Diagnostic pattern</strong>
<span>Future controls must use typed Backend diagnostics and restart-required states.</span>
<strong>Restart-required</strong>
<span>Runtime config changes are persisted, then applied after backend restart.</span>
</div>
</section>
@@ -83,13 +262,95 @@
{#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={settingsSectionHref(section.id)}>
<span>{section.label}</span>
<small>{section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
<small>{section.status === "editable" ? "Editable" : section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
</a>
{/each}
</section>
<section class="card settings-section" id="runtime-connections" aria-labelledby="runtime-connections-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="runtime-connections-title">Runtime Connections</h2>
</div>
<span class="badge success">typed API</span>
</header>
<p>{SETTINGS_SECTIONS.find((section) => section.id === "runtime-connections")?.summary}</p>
{#if runtimeLoading}
<p class="status-message">Loading Runtime connections…</p>
{:else if runtimeError}
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
{:else if runtimeSettings}
{@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
<h3>Add remote Runtime</h3>
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
<label>
<span>Runtime id</span>
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
</label>
<label>
<span>Display name</span>
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
</label>
<label>
<span>Endpoint</span>
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{#if mutationMessage}
<p class="status-message" class:error={mutationMessage.includes("failed")}>{mutationMessage}</p>
{/if}
{@render DiagnosticsList({ diagnostics: mutationDiagnostics })}
<div class="settings-runtime-list" aria-label="Remote Runtime connections">
<h3>Remote Runtimes</h3>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{:else}
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
<article class="settings-runtime-card">
{@render RuntimeConnectionCard({ connection: remote })}
<dl class="settings-identity-list compact">
<div>
<dt>Endpoint</dt>
<dd>{remote.endpoint_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
<div>
<dt>Token ref</dt>
<dd>{remote.token_ref_configured ? "configured (hidden)" : "not configured"}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
{testing === remote.runtime_id ? "Testing…" : "Test"}
</button>
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
<p>{test.compatibility_basis}</p>
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
{/if}
</article>
{/each}
{/if}
</div>
{/if}
</section>
<div class="grid settings-grid">
{#each SETTINGS_SECTIONS as section}
{#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section}
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
<header class="settings-section-header">
<div>
@@ -132,7 +393,7 @@
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
<div>
<p class="eyebrow">Implementation patterns</p>
<h2 id="settings-patterns-title">How future settings should appear</h2>
<h2 id="settings-patterns-title">How settings should appear</h2>
</div>
<div class="grid settings-pattern-grid">
{#each SETTINGS_PATTERNS as pattern}
@@ -151,3 +412,51 @@
{/if}
</main>
</div>
{#snippet RuntimeConnectionCard({ connection }: { connection: RuntimeConnectionSummary | RemoteRuntimeConnectionSummary })}
<article class="settings-runtime-card embedded" class:inactive={!connection.active}>
<header>
<div>
<h3>{connection.display_name}</h3>
<p><code>{connection.runtime_id}</code></p>
</div>
<span class="badge" class:success={connection.active} class:warning={!connection.active}>{connection.status}</span>
</header>
<dl class="settings-identity-list compact">
<div>
<dt>Kind</dt>
<dd>{connection.kind}</dd>
</div>
<div>
<dt>Built in</dt>
<dd>{connection.built_in ? "yes" : "no"}</dd>
</div>
<div>
<dt>Config managed</dt>
<dd>{connection.config_managed ? "yes" : "no"}</dd>
</div>
<div>
<dt>Spawn</dt>
<dd>{connection.can_spawn_worker ? "available" : "unavailable"}</dd>
</div>
<div>
<dt>Restart required</dt>
<dd>{connection.restart_required ? "yes" : "no"}</dd>
</div>
</dl>
{@render DiagnosticsList({ diagnostics: connection.diagnostics })}
</article>
{/snippet}
{#snippet DiagnosticsList({ diagnostics }: { diagnostics: Diagnostic[] })}
{#if diagnostics.length > 0}
<ul class="settings-diagnostics-list">
{#each diagnostics as diagnostic}
<li class={diagnostic.severity}>
<strong>{diagnosticLabel(diagnostic)}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{/snippet}
@@ -3,6 +3,7 @@ import {
SETTINGS_PERMISSION_NOTICE,
SETTINGS_ROUTE,
SETTINGS_SECTIONS,
diagnosticLabel,
settingsSectionHref,
} from "./model.ts";
@@ -43,7 +44,12 @@ Deno.test("settings shell advertises no fake browser admin model", () => {
);
});
Deno.test("settings placeholders avoid mutation promises and raw authority leaks", () => {
Deno.test("runtime connections are editable without advertising raw authority leaks", () => {
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
section.id === "runtime-connections"
);
assert(runtimeSection?.status === "editable", "Runtime Connections should be editable");
const allText = [
SETTINGS_PERMISSION_NOTICE,
...SETTINGS_SECTIONS.flatMap((section) => [
@@ -55,14 +61,12 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
].join("\n");
assert(
allText.includes(
"does not add, remove, test, or persist Runtime endpoints",
),
"Runtime Connections should remain a placeholder",
allText.includes("restart_required=true") || allText.includes("Restart-required"),
"restart-required pattern should be visible",
);
assert(
allText.includes("Restart-required"),
"restart-required pattern should be visible",
allText.includes("not echoed back") || allText.includes("not echoed"),
"endpoint submission should not imply endpoint echoing",
);
for (
@@ -72,6 +76,7 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
"token:",
"secret:",
"store root:",
"config file path:",
]
) {
assert(
@@ -80,3 +85,15 @@ Deno.test("settings placeholders avoid mutation promises and raw authority leaks
);
}
});
Deno.test("diagnostic labels preserve severity and code", () => {
const diagnostic = {
severity: "warning",
code: "runtime_registry_restart_required",
message: "Restart required.",
} as const;
assert(
diagnosticLabel(diagnostic) === "warning: runtime_registry_restart_required",
"diagnostic label should be bounded and stable",
);
});
@@ -1,3 +1,9 @@
export type Diagnostic = {
severity: "info" | "warning" | "error";
code: string;
message: string;
};
export type SettingsSectionId =
| "runtime-connections"
| "backend-config"
@@ -6,7 +12,7 @@ export type SettingsSectionId =
export type SettingsSection = {
readonly id: SettingsSectionId;
readonly label: string;
readonly status: "placeholder" | "read-only";
readonly status: "editable" | "placeholder" | "read-only";
readonly summary: string;
readonly bullets: readonly string[];
};
@@ -16,22 +22,66 @@ export type SettingsPattern = {
readonly body: string;
};
export type RuntimeConnectionSummary = {
runtime_id: string;
display_name: string;
kind: string;
built_in: boolean;
config_managed: boolean;
active: boolean;
can_spawn_worker: boolean;
restart_required: boolean;
status: string;
diagnostics: Diagnostic[];
};
export type RemoteRuntimeConnectionSummary = RuntimeConnectionSummary & {
endpoint_configured: boolean;
token_ref_configured: boolean;
};
export type RuntimeConnectionSettingsResponse = {
workspace_id: string;
embedded: RuntimeConnectionSummary;
remotes: RemoteRuntimeConnectionSummary[];
diagnostics: Diagnostic[];
};
export type RuntimeConnectionMutationResponse = {
workspace_id: string;
restart_required: boolean;
remotes: RemoteRuntimeConnectionSummary[];
diagnostics: Diagnostic[];
};
export type RemoteRuntimeTestResponse = {
workspace_id: string;
runtime_id: string;
checked_at: string;
state: string;
protocol_version?: string | null;
compatibility_basis: string;
capabilities: string[];
health_result: string;
diagnostics: Diagnostic[];
};
export const SETTINGS_ROUTE = "/settings";
export const SETTINGS_PERMISSION_NOTICE =
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This shell is intentionally local and descriptive; it does not create an admin role or grant mutation authority.";
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority.";
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{
id: "runtime-connections",
label: "Runtime Connections",
status: "placeholder",
status: "editable",
summary:
"Future Runtime connection management will live here. The current view does not add, remove, test, or persist Runtime endpoints.",
"Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.",
bullets: [
"Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.",
"Connection changes require a later typed Backend API and are not performed by this shell.",
"Restart-required states should be shown as bounded diagnostics rather than live mutation controls.",
"Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
"The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
],
},
{
@@ -39,11 +89,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
label: "Backend Config",
status: "placeholder",
summary:
"Configuration inspection is planned, but editing Backend config or secrets is out of scope for this shell.",
"General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.",
bullets: [
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.",
"Missing-provider or invalid-config states should be displayed as typed diagnostics.",
"No fake permission model is created to make config editing appear available.",
"No fake permission model is created to make unrelated config editing appear available.",
],
},
{
@@ -64,20 +114,24 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
{
title: "Sanitized diagnostics",
body:
"Settings cards should show bounded codes and operator-facing messages, not raw socket paths, credentials, secret refs, token values, or Runtime store paths.",
"Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, or Runtime store paths.",
},
{
title: "Restart-required changes",
body:
"When a future setting cannot apply live, the browser should say restart required and leave the mutation to a typed Backend workflow.",
"Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.",
},
{
title: "Read-only until typed APIs exist",
title: "Typed Runtime surface only",
body:
"Placeholder sections describe planned surfaces without pretending that user, role, permission, or Runtime mutation APIs already exist.",
"Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.",
},
];
export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}#${id}`;
}
export function diagnosticLabel(diagnostic: Diagnostic): string {
return `${diagnostic.severity}: ${diagnostic.code}`;
}
@@ -1,6 +1,11 @@
<script lang="ts">
import { workerConsoleHref } from '$lib/workspace-console/model';
import type { ListResponse, Worker } from './types';
import type {
BrowserCreateWorkerResponse,
ListResponse,
Worker,
WorkerLaunchOptionsResponse,
} from './types';
const MAX_VISIBLE_WORKERS = 6;
@@ -14,14 +19,24 @@
let error = $state<string | null>(null);
let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null);
let options = $state<WorkerLaunchOptionsResponse | null>(null);
let optionsError = $state<string | null>(null);
let showNewWorker = $state(false);
let submitting = $state(false);
let submitError = $state<string | null>(null);
let displayName = $state('Coding Worker');
let runtimeId = $state('');
let profile = $state('builtin:coder');
let initialText = $state('');
$effect(() => {
const controller = new AbortController();
void loadWorkers(controller.signal);
void loadLaunchOptions(controller.signal);
return () => controller.abort();
});
async function loadWorkers(signal: AbortSignal) {
async function loadWorkers(signal?: AbortSignal) {
loading = true;
error = null;
placeholder = null;
@@ -47,21 +62,142 @@
error = err instanceof Error ? err.message : 'workers request failed';
workers = [];
} finally {
if (!signal.aborted) {
if (!signal?.aborted) {
loading = false;
}
}
}
async function loadLaunchOptions(signal?: AbortSignal) {
optionsError = null;
try {
const response = await fetch('/api/workers/launch-options', { signal });
if (!response.ok) {
throw new Error(`worker launch options failed (${response.status})`);
}
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
options = payload;
const preferredRuntime = payload.runtimes.find((runtime) => runtime.can_spawn_worker && runtime.status === 'active')
?? payload.runtimes.find((runtime) => runtime.can_spawn_worker)
?? payload.runtimes[0];
if (preferredRuntime && !runtimeId) {
runtimeId = preferredRuntime.runtime_id;
}
const preferredProfile = payload.profiles.find((candidate) => candidate.id === 'builtin:coder') ?? payload.profiles[0];
if (preferredProfile && !payload.profiles.some((candidate) => candidate.id === profile)) {
profile = preferredProfile.id;
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
}
}
async function createWorker() {
submitError = null;
submitting = true;
try {
const response = await fetch('/api/workers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName,
profile,
initial_text: initialText,
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response, 'worker create failed'));
}
const payload = (await response.json()) as BrowserCreateWorkerResponse;
await loadWorkers();
window.location.href = payload.console_href;
} catch (err) {
submitError = err instanceof Error ? err.message : 'worker create failed';
} finally {
submitting = false;
}
}
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
if (typeof payload.error === 'object' && payload.error?.message) {
return `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`;
}
if (payload.message) {
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
return `${code}: ${payload.message}`;
}
} catch {
// fall through
}
return `${fallback} (${response.status})`;
}
</script>
<section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">workers</h2>
<button type="button" class="section-action" onclick={() => (showNewWorker = !showNewWorker)}>
{showNewWorker ? 'Close' : 'New'}
</button>
{#if !loading && !error && workers.length > 0}
<span class="section-count">{workers.length}</span>
{/if}
</div>
{#if showNewWorker}
<form class="worker-new-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
<label>
<span>Display name</span>
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
</label>
<label>
<span>Runtime</span>
<select bind:value={runtimeId} required>
{#if options?.runtimes.length}
{#each options.runtimes as runtime}
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
{runtime.display_name} · {runtime.status}{runtime.built_in ? ' · embedded' : ''}
</option>
{/each}
{:else}
<option value="" disabled>No Runtime options</option>
{/if}
</select>
</label>
<label>
<span>Profile</span>
<select bind:value={profile} required>
{#if options?.profiles.length}
{#each options.profiles as candidate}
<option value={candidate.id}>{candidate.label}</option>
{/each}
{:else}
<option value="" disabled>No profile candidates</option>
{/if}
</select>
</label>
<label>
<span>Initial text</span>
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
</label>
{#if optionsError}
<p class="section-state error">{optionsError}</p>
{/if}
{#if submitError}
<p class="section-state error">{submitError}</p>
{/if}
<button type="submit" disabled={submitting || !runtimeId || !profile}>
{submitting ? 'Starting…' : 'Start Coding Worker'}
</button>
</form>
{/if}
{#if loading}
<p class="section-state">Checking workers…</p>
{:else if error}
@@ -93,6 +93,37 @@ export type Worker = {
export type WorkerOperationState = 'accepted' | 'unsupported' | 'rejected';
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
can_spawn_worker: boolean;
status: string;
diagnostics: Diagnostic[];
};
export type WorkerLaunchProfileCandidate = {
id: string;
label: string;
description: string;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[];
profiles: WorkerLaunchProfileCandidate[];
diagnostics: Diagnostic[];
};
export type BrowserCreateWorkerResponse = {
workspace_id: string;
runtime_id: string;
worker_id: string;
console_href: string;
worker: Worker;
diagnostics: Diagnostic[];
};
export type WorkerInputResult = {
state: WorkerOperationState;
runtime_id: string;