feat: add workspace runtime and worker controls
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user