fix: refine workspace runtime UI

This commit is contained in:
Keisuke Hirata 2026-07-06 22:51:25 +09:00
parent 5279f8fc0d
commit b6029220ff
No known key found for this signature in database
6 changed files with 283 additions and 162 deletions

View File

@ -0,0 +1,33 @@
# worker-runtime
`worker-runtime` owns the Runtime authority surface for Worker management. A Runtime process bundles Worker lifecycle management, the HTTP/WebSocket control API, and the Worker execution backend.
## Run the local Runtime server
From the repository root:
```bash
cargo run -p worker-runtime \
--features ws-server,fs-store \
--bin worker-runtime-rest-server \
-- --workspace .
```
By default the server listens on:
```text
127.0.0.1:38800
```
To bind another address explicitly:
```bash
cargo run -p worker-runtime \
--features ws-server,fs-store \
--bin worker-runtime-rest-server \
-- --workspace . --bind 127.0.0.1:38800
```
`--workspace` is currently a legacy bootstrap input for the v0 local materializer / Worker profile resolution path. It is not intended to be the long-term Runtime identity or a single-workspace binding. Future Runtime launches should receive Workspace / Repository context through Worker launch requests and config bundles instead.
The REST server is intended for a trusted Backend/proxy, not direct browser access.

View File

@ -1885,11 +1885,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
true,
response.runtime.worker_creation_available,
),
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote Runtime is accessed only by backend-owned REST/WS clients".to_string(),
)],
diagnostics: Vec::new(),
},
Err(diagnostic) => RuntimeSummary {
runtime_id: self.runtime_id.clone(),
@ -1922,11 +1918,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false),
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote host endpoint and credentials are backend-private".to_string(),
)],
diagnostics: Vec::new(),
}],
Vec::new(),
)
@ -2010,11 +2002,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
kind: "remote_runtime_worker_created".to_string(),
detail: "worker-runtime REST create endpoint accepted the Worker".to_string(),
}],
diagnostics: vec![diagnostic(
"remote_runtime_backend_proxy",
DiagnosticSeverity::Info,
"Remote create used a backend-owned REST client; browser-facing payload exposes only runtime_id plus worker_id".to_string(),
)],
diagnostics: Vec::new(),
},
Err(diagnostic) => WorkerSpawnResult {
state: WorkerOperationState::Rejected,

View File

@ -1226,28 +1226,74 @@
background: rgba(255, 255, 255, 0.03);
}
.settings-runtime-card {
display: grid;
gap: 0.75rem;
.settings-runtime-table-wrap {
overflow-x: auto;
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-table {
width: 100%;
min-width: 48rem;
border-collapse: collapse;
}
.settings-runtime-card.inactive {
opacity: 0.86;
.settings-runtime-table th,
.settings-runtime-table td {
padding: 0.8rem 0.9rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.settings-identity-list.compact {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
.settings-runtime-table th {
color: var(--text-muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.settings-runtime-table tr:last-child td {
border-bottom: 0;
}
.settings-runtime-table tr.inactive {
opacity: 0.78;
}
.settings-runtime-table td {
color: var(--text-muted);
}
.settings-runtime-table td > strong,
.settings-runtime-table td > span,
.settings-runtime-table td > code,
.settings-runtime-table td > small {
display: block;
}
.settings-runtime-table strong {
color: var(--text-strong);
}
.settings-runtime-table code {
margin-top: 0.15rem;
overflow-wrap: anywhere;
}
.settings-runtime-table small {
color: var(--text-muted);
}
.settings-runtime-detail-row td {
padding-top: 0;
background: rgba(255, 255, 255, 0.025);
}
.settings-muted-action {
color: var(--text-muted);
font-size: 0.82rem;
}
.settings-action-row {

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
import { workerConsoleHref } from '$lib/workspace-console/model';
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
@ -51,48 +52,48 @@
let route = $derived(routeFromView(view, objectiveId, repositoryId));
let currentPath = $derived(pathFromRoute(route));
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { signal });
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function loadWorkspace() {
async function loadWorkspace(signal?: AbortSignal) {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
workspace = await getJson<WorkspaceResponse>('/api/workspace', signal);
} catch (error) {
workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
}
}
async function loadHosts() {
async function loadHosts(signal?: AbortSignal) {
hostsError = null;
try {
hosts = await getJson<ListResponse<Host>>('/api/hosts');
hosts = await getJson<ListResponse<Host>>('/api/hosts', signal);
} catch (error) {
hostsError = error instanceof Error ? error.message : String(error);
hosts = null;
}
}
async function loadWorkers() {
async function loadWorkers(signal?: AbortSignal) {
workersError = null;
try {
workers = await getJson<ListResponse<Worker>>('/api/workers');
workers = await getJson<ListResponse<Worker>>('/api/workers', signal);
} catch (error) {
workersError = error instanceof Error ? error.message : String(error);
workers = null;
}
}
async function loadRepositories() {
async function loadRepositories(signal?: AbortSignal) {
repositoriesError = null;
try {
repositories = await getJson<RepositoryListResponse>('/api/repositories');
repositories = await getJson<RepositoryListResponse>('/api/repositories', signal);
} catch (error) {
repositoriesError = error instanceof Error ? error.message : String(error);
repositories = null;
@ -124,10 +125,10 @@
}
}
async function loadObjectives() {
async function loadObjectives(signal?: AbortSignal) {
objectivesError = null;
try {
objectives = await getJson<ObjectiveListResponse>('/api/objectives');
objectives = await getJson<ObjectiveListResponse>('/api/objectives', signal);
} catch (error) {
objectivesError = error instanceof Error ? error.message : String(error);
objectives = null;
@ -189,12 +190,15 @@
return value ?? 'not recorded';
}
$effect(() => {
void loadWorkspace();
void loadHosts();
void loadWorkers();
void loadRepositories();
void loadObjectives();
onMount(() => {
const controller = new AbortController();
void loadWorkspace(controller.signal);
void loadHosts(controller.signal);
void loadWorkers(controller.signal);
void loadRepositories(controller.signal);
void loadObjectives(controller.signal);
return () => controller.abort();
});
$effect(() => {

View File

@ -12,7 +12,6 @@
type RemoteRuntimeTestResponse,
type RuntimeConnectionMutationResponse,
type RuntimeConnectionSettingsResponse,
type RuntimeConnectionSummary,
} from "./model";
type RemoteAddForm = {
@ -33,6 +32,7 @@
let deleting = $state<string | null>(null);
let testing = $state<string | null>(null);
let submitting = $state(false);
let showAddRuntimeForm = $state(false);
let remoteForm = $state<RemoteAddForm>({
runtime_id: "",
display_name: "",
@ -117,6 +117,7 @@
const data = (await response.json()) as RuntimeConnectionMutationResponse;
applyRuntimeMutation(data);
remoteForm = { runtime_id: "", display_name: "", endpoint: "" };
showAddRuntimeForm = false;
} catch (err) {
mutationMessage = err instanceof Error ? err.message : "add remote Runtime failed";
} finally {
@ -289,8 +290,13 @@
{:else if runtimeError}
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
{:else if runtimeSettings}
{@render RuntimeConnectionCard({ connection: runtimeSettings.embedded })}
<div class="settings-action-row">
<button type="button" onclick={() => showAddRuntimeForm = !showAddRuntimeForm}>
{showAddRuntimeForm ? "Cancel adding Runtime" : "Add remote Runtime"}
</button>
</div>
{#if showAddRuntimeForm}
<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>
@ -308,30 +314,72 @@
</label>
<button type="submit" disabled={submitting}>{submitting ? "Saving…" : "Add Runtime"}</button>
</form>
{/if}
{#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}
<div class="settings-runtime-list" aria-label="Runtime connections">
<h3>Runtimes</h3>
<div class="settings-runtime-table-wrap">
<table class="settings-runtime-table">
<thead>
<tr>
<th scope="col">Runtime</th>
<th scope="col">Source</th>
<th scope="col">Connection</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr class:inactive={!runtimeSettings.embedded.active}>
<td>
<strong>{runtimeSettings.embedded.display_name}</strong>
<code>{runtimeSettings.embedded.runtime_id}</code>
</td>
<td>
<strong>embedded</strong>
<span>Workspace backend process</span>
</td>
<td>Local Backend runtime</td>
<td>
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
{#if runtimeSettings.embedded.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td><span class="settings-muted-action">Managed by backend</span></td>
</tr>
{#if runtimeSettings.embedded.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5">{@render DiagnosticsList({ diagnostics: runtimeSettings.embedded.diagnostics })}</td>
</tr>
{/if}
{#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>
<tr class:inactive={!remote.active}>
<td>
<strong>{remote.display_name}</strong>
<code>{remote.runtime_id}</code>
</td>
<td>
<strong>remote</strong>
<span>Configured Runtime endpoint</span>
</td>
<td>
<span>Endpoint: {remote.endpoint_configured ? "configured" : "not configured"}</span>
{#if remote.endpoint_configured}<small>hidden</small>{/if}
<span>Token: {remote.token_ref_configured ? "configured" : "not configured"}</span>
</td>
<td>
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
{#if remote.restart_required}
<span class="badge warning">restart required</span>
{/if}
</td>
<td>
<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"}
@ -340,10 +388,19 @@
{deleting === remote.runtime_id ? "Deleting…" : "Delete"}
</button>
</div>
</td>
</tr>
{#if remote.diagnostics.length > 0}
<tr class="settings-runtime-detail-row">
<td colspan="5">{@render DiagnosticsList({ diagnostics: remote.diagnostics })}</td>
</tr>
{/if}
{#if tests[remote.runtime_id]}
{@const test = tests[remote.runtime_id]}
{@const available = capabilityOperations(test, "available")}
{@const unchecked = capabilityOperations(test, "unknown")}
<tr class="settings-runtime-detail-row">
<td colspan="5">
<div class="settings-test-result">
<strong>Test: {test.state}</strong>
<span>{test.health_result} · {test.checked_at}</span>
@ -356,9 +413,15 @@
{/if}
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
</div>
</td>
</tr>
{/if}
</article>
{/each}
</tbody>
</table>
</div>
{#if runtimeSettings.remotes.length === 0}
<p class="status-message">No remote Runtime connections configured.</p>
{/if}
</div>
{/if}
@ -428,41 +491,6 @@
</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">

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
@ -15,11 +16,43 @@
let {
workspace,
workspaceError = null,
repositories = null,
repositoriesError = null,
repositories,
repositoriesError,
currentPath = '/'
}: Props = $props();
let settingsActive = $derived(currentPath.startsWith("/settings"));
let fallbackRepositories = $state<RepositoryListResponse | null>(null);
let fallbackRepositoriesError = $state<string | null>(null);
let displayedRepositories = $derived(repositories === undefined ? fallbackRepositories : repositories);
let displayedRepositoriesError = $derived(
repositoriesError === undefined ? fallbackRepositoriesError : repositoriesError
);
onMount(() => {
if (repositories !== undefined) {
return;
}
const controller = new AbortController();
void loadFallbackRepositories(controller.signal);
return () => controller.abort();
});
async function loadFallbackRepositories(signal?: AbortSignal) {
fallbackRepositoriesError = null;
try {
const response = await fetch('/api/repositories', { signal });
if (!response.ok) {
throw new Error(`repositories request failed (${response.status})`);
}
fallbackRepositories = (await response.json()) as RepositoryListResponse;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
fallbackRepositoriesError = error instanceof Error ? error.message : 'repositories request failed';
fallbackRepositories = null;
}
}
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
@ -40,33 +73,22 @@
<a
class="settings-button"
class:active={settingsActive}
href="/settings"
aria-label="Open Settings / Admin"
title="Settings / Admin"
aria-current={settingsActive ? 'page' : undefined}
>
</a>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
<RepositoriesNavSection
repositories={displayedRepositories}
repositoriesError={displayedRepositoriesError}
{currentPath}
/>
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />
<section class="nav-section" aria-labelledby="settings-heading">
<div class="section-heading-row">
<h2 id="settings-heading">settings</h2>
</div>
<ul class="nav-list" aria-label="Settings">
<li>
<a class="nav-item" class:active={settingsActive} href="/settings" aria-current={settingsActive ? 'page' : undefined}>
<span class="item-title">Settings / Admin</span>
<span class="item-meta">Backend shell and diagnostics</span>
</a>
</li>
</ul>
</section>
</nav>
</aside>