Merge branch 'orchestration' into impl/00001KVNG9B9Z-workspace-sidebar
# Conflicts: # web/workspace/src/routes/+page.svelte
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { WorkerSummary } from './types';
|
||||
import type { ListResponse, Worker } from './types';
|
||||
|
||||
const MAX_VISIBLE_WORKERS = 6;
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let workers = $state<WorkerSummary[]>([]);
|
||||
let workers = $state<Worker[]>([]);
|
||||
let placeholder = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
@@ -28,8 +28,8 @@
|
||||
if (!response.ok) {
|
||||
throw new Error(`workers request failed (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
workers = normalizeWorkers(payload).slice(0, MAX_VISIBLE_WORKERS);
|
||||
const payload = (await response.json()) as ListResponse<Worker>;
|
||||
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
|
||||
if (workers.length === 0) {
|
||||
placeholder = 'No workers reported by the current API.';
|
||||
}
|
||||
@@ -45,47 +45,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkers(payload: unknown): WorkerSummary[] {
|
||||
const items = Array.isArray(payload)
|
||||
? payload
|
||||
: isRecord(payload) && Array.isArray(payload.items)
|
||||
? payload.items
|
||||
: [];
|
||||
|
||||
return items.map((item, index) => normalizeWorker(item, index));
|
||||
}
|
||||
|
||||
function normalizeWorker(item: unknown, index: number): WorkerSummary {
|
||||
if (!isRecord(item)) {
|
||||
return {
|
||||
id: `worker-${index + 1}`,
|
||||
label: `worker ${index + 1}`,
|
||||
status: 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
const id = readText(item, ['id', 'worker_id', 'name']) ?? `worker-${index + 1}`;
|
||||
const label = readText(item, ['display_name', 'label', 'name', 'worker_id', 'id']) ?? id;
|
||||
const status = readText(item, ['status', 'state', 'lifecycle']) ?? 'unknown';
|
||||
const detail = readText(item, ['role', 'profile', 'note']);
|
||||
|
||||
return { id, label, status, detail };
|
||||
}
|
||||
|
||||
function readText(record: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="workers-heading">
|
||||
@@ -104,11 +63,11 @@
|
||||
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Workers">
|
||||
{#each workers as worker (worker.id)}
|
||||
{#each workers as worker (worker.worker_id)}
|
||||
<li class="nav-item">
|
||||
<span class="item-title">{worker.label}</span>
|
||||
<span class="item-meta">
|
||||
{worker.status}{worker.detail ? ` · ${worker.detail}` : ''}
|
||||
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
export type ExtensionPoint = {
|
||||
status: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type WorkspaceResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
record_authority: string;
|
||||
extension_points: {
|
||||
event_stream: { status: string; note: string };
|
||||
runner_connection: { status: string; note: string };
|
||||
event_stream: ExtensionPoint;
|
||||
host_worker_bridge: ExtensionPoint;
|
||||
};
|
||||
};
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type Host = {
|
||||
host_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
observed_at: string;
|
||||
last_seen_at: string;
|
||||
capabilities: {
|
||||
local_pod_inspection: string;
|
||||
workspace_root: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
max_workers: number;
|
||||
};
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type Worker = {
|
||||
worker_id: string;
|
||||
host_id: string;
|
||||
label: string;
|
||||
pod_name: string;
|
||||
role?: string;
|
||||
profile?: string;
|
||||
workspace_root?: string;
|
||||
state: string;
|
||||
status: string;
|
||||
last_seen_at?: string;
|
||||
implementation: { kind: string; pod_name: string };
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ListResponse<T> = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: T[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ObjectiveSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -29,10 +80,3 @@ export type ObjectiveListResponse = {
|
||||
invalid_records: InvalidProjectRecord[];
|
||||
record_authority: string;
|
||||
};
|
||||
|
||||
export type WorkerSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
detail?: string | null;
|
||||
};
|
||||
|
||||
@@ -1,33 +1,69 @@
|
||||
<script lang="ts">
|
||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||
import type { WorkspaceResponse } from '$lib/workspace-sidebar/types';
|
||||
import type { Diagnostic, Host, ListResponse, Worker, WorkspaceResponse } from '$lib/workspace-sidebar/types';
|
||||
|
||||
const endpoints = [
|
||||
{ label: 'Workspace', path: '/api/workspace' },
|
||||
{ label: 'Tickets', path: '/api/tickets' },
|
||||
{ label: 'Objectives', path: '/api/objectives' },
|
||||
{ label: 'Runs', path: '/api/runs' },
|
||||
{ label: 'Runners', path: '/api/runners' }
|
||||
{ label: 'Hosts', path: '/api/hosts' },
|
||||
{ label: 'Workers', path: '/api/workers' }
|
||||
];
|
||||
|
||||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let workspaceError = $state<string | null>(null);
|
||||
let hostsError = $state<string | null>(null);
|
||||
let workersError = $state<string | null>(null);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
loadError = null;
|
||||
workspaceError = null;
|
||||
try {
|
||||
const response = await fetch('/api/workspace');
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET /api/workspace failed: ${response.status}`);
|
||||
}
|
||||
workspace = await response.json();
|
||||
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
||||
} catch (error) {
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
workspaceError = error instanceof Error ? error.message : String(error);
|
||||
workspace = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHosts() {
|
||||
hostsError = null;
|
||||
try {
|
||||
hosts = await getJson<ListResponse<Host>>('/api/hosts');
|
||||
} catch (error) {
|
||||
hostsError = error instanceof Error ? error.message : String(error);
|
||||
hosts = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
workersError = null;
|
||||
try {
|
||||
workers = await getJson<ListResponse<Worker>>('/api/workers');
|
||||
} catch (error) {
|
||||
workersError = error instanceof Error ? error.message : String(error);
|
||||
workers = null;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
|
||||
return groups.flatMap((group) => group ?? []);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadWorkspace();
|
||||
void loadHosts();
|
||||
void loadWorkers();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,7 +76,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-layout">
|
||||
<WorkspaceSidebar {workspace} workspaceError={loadError} />
|
||||
<WorkspaceSidebar {workspace} {workspaceError} />
|
||||
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
@@ -48,8 +84,9 @@
|
||||
<h1>Yoi Workspace Control Plane</h1>
|
||||
<p>
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||
through bounded backend APIs. Ticket and Objective lifecycle authority stays
|
||||
in the existing local record workflow.
|
||||
and the local Host / Worker execution view through bounded backend APIs.
|
||||
Ticket and Objective lifecycle authority stays in the existing local record
|
||||
workflow.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -69,9 +106,13 @@
|
||||
<dt>Record authority</dt>
|
||||
<dd>{workspace.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host / Worker bridge</dt>
|
||||
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
{:else if workspaceError}
|
||||
<p class="error">{workspaceError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||
{/if}
|
||||
@@ -90,12 +131,118 @@
|
||||
<div class="card">
|
||||
<h2>Reserved seams</h2>
|
||||
<p>
|
||||
Event streams and runner connections are represented as extension-point
|
||||
state in the backend response, but no scheduler, write API, or hosted
|
||||
multi-tenant behavior is implemented in this slice.
|
||||
Event streams remain represented as extension-point state in the backend
|
||||
response. Hosts and Workers are read-only local observations; no
|
||||
scheduler, lifecycle control, or hosted multi-tenant behavior is
|
||||
implemented in this slice.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<h2>Hosts</h2>
|
||||
{#if hosts}
|
||||
{#if hosts.items.length === 0}
|
||||
<p>No local Hosts are visible.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each hosts.items as host}
|
||||
<article class="runtime-card">
|
||||
<div class="runtime-heading">
|
||||
<strong>{host.label}</strong>
|
||||
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd><code>{host.host_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dd>{host.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Local inspection</dt>
|
||||
<dd>{host.capabilities.local_pod_inspection}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Platform</dt>
|
||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if hostsError}
|
||||
<p class="error">{hostsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workers</h2>
|
||||
{#if workers}
|
||||
{#if workers.items.length === 0}
|
||||
<p>No local Workers are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Workspace</th>
|
||||
<th>Implementation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workers.items as worker}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{worker.label}</strong>
|
||||
{#if worker.role || worker.profile}
|
||||
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td><code>{worker.host_id}</code></td>
|
||||
<td>{worker.state} · {worker.status}</td>
|
||||
<td>{worker.workspace_root ?? 'unknown'}</td>
|
||||
<td>{worker.implementation.kind}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if workersError}
|
||||
<p class="error">{workersError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workers</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if hosts || workers}
|
||||
{@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)}
|
||||
{#if diagnostics.length > 0}
|
||||
<section class="card diagnostics">
|
||||
<h2>Diagnostics</h2>
|
||||
<ul>
|
||||
{#each diagnostics as diagnostic}
|
||||
<li>
|
||||
<strong>{diagnostic.severity}</strong>
|
||||
<code>{diagnostic.code}</code>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +263,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
width: min(1240px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
min-width: 0;
|
||||
@@ -171,6 +318,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 20px;
|
||||
@@ -180,6 +331,33 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.runtime-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.runtime-heading span {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.runtime-heading span.warn {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -193,6 +371,46 @@
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.diagnostics li {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
Reference in New Issue
Block a user