fix: redirect unscoped workspace routes
This commit is contained in:
parent
f6ad9cfcd3
commit
71340b8999
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::header::{CONTENT_TYPE, LOCATION};
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
|
|
@ -2737,6 +2737,12 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(location) =
|
||||
unscoped_workspace_ui_redirect(uri.path(), uri.query(), api.workspace_id())
|
||||
{
|
||||
return (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, location)]).into_response();
|
||||
}
|
||||
|
||||
let Some(static_root) = api.config.static_assets_dir.as_ref() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
|
@ -2753,6 +2759,30 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
|
|||
}
|
||||
}
|
||||
|
||||
fn unscoped_workspace_ui_redirect(
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
workspace_id: &str,
|
||||
) -> Option<String> {
|
||||
let scoped_tail = if path == "/" {
|
||||
""
|
||||
} else if ["/repositories", "/objectives", "/settings", "/runtimes"]
|
||||
.iter()
|
||||
.any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
|
||||
{
|
||||
path
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut location = format!("/w/{}{}", encode_path_segment(workspace_id), scoped_tail);
|
||||
if let Some(query) = query.filter(|query| !query.is_empty()) {
|
||||
location.push('?');
|
||||
location.push_str(query);
|
||||
}
|
||||
Some(location)
|
||||
}
|
||||
|
||||
fn workspace_id_from_ui_path(path: &str) -> Option<&str> {
|
||||
let tail = path.strip_prefix("/w/")?;
|
||||
let workspace_id = tail.split('/').next().unwrap_or_default();
|
||||
|
|
@ -3598,6 +3628,26 @@ mod tests {
|
|||
.contains(dir.path().to_string_lossy().as_ref())
|
||||
);
|
||||
|
||||
let unscoped_objectives = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/objectives?focus=active")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unscoped_objectives.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
let expected_location = format!("/w/{TEST_WORKSPACE_ID}/objectives?focus=active");
|
||||
assert_eq!(
|
||||
unscoped_objectives
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(expected_location.as_str())
|
||||
);
|
||||
|
||||
let tickets = get_json(app.clone(), "/api/tickets").await;
|
||||
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
|
||||
assert_eq!(tickets["items"][0]["state"], "ready");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { workspaceApiPath, workspaceRoute } from "./http.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
readTextFile(path: string | URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
|
|
@ -12,6 +13,12 @@ function assertEquals<T>(actual: T, expected: T): void {
|
|||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => {
|
||||
assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201");
|
||||
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
|
||||
|
|
@ -20,3 +27,29 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
|||
"/api/w/workspace%201/repositories/repo-a",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
|
||||
const layout = await Deno.readTextFile(new URL("../../routes/+layout.ts", import.meta.url));
|
||||
assert(
|
||||
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
|
||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||
);
|
||||
assert(
|
||||
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
|
||||
"unscoped layout should redirect to the scoped workspace route",
|
||||
);
|
||||
assert(
|
||||
!layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'),
|
||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||
);
|
||||
|
||||
const unscopedSettings = await Deno.readTextFile(
|
||||
new URL("../../routes/settings/+page.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
unscopedSettings.includes("Redirecting to scoped settings") &&
|
||||
!unscopedSettings.includes("fetch(") &&
|
||||
!unscopedSettings.includes("settingsApiPath"),
|
||||
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||
|
||||
Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs", async () => {
|
||||
const workspacePage = await Deno.readTextFile(
|
||||
new URL("./../../routes/+page.svelte", import.meta.url),
|
||||
new URL("./../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
|
||||
);
|
||||
const workersNav = await Deno.readTextFile(
|
||||
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@
|
|||
let initialText = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
workers = [];
|
||||
options = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void loadWorkers(controller.signal);
|
||||
void loadLaunchOptions(controller.signal);
|
||||
|
|
@ -102,6 +109,11 @@
|
|||
}
|
||||
|
||||
async function createWorker() {
|
||||
if (!workspaceId) {
|
||||
submitError = 'workspace id is unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
submitError = null;
|
||||
submitting = true;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
WorkspaceResponse,
|
||||
} from "$lib/workspace-sidebar/types";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
|
||||
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace-sidebar/types";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||
export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const apiPath = (path: string) =>
|
||||
workspaceId ? workspaceApiPath(workspaceId, path) : `/api${path}`;
|
||||
|
||||
if (!workspaceId) {
|
||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||
if (workspace.data) {
|
||||
const scopedPath = workspaceRoute(
|
||||
workspace.data.workspace_id,
|
||||
url.pathname === "/" ? "" : url.pathname,
|
||||
);
|
||||
throw redirect(307, `${scopedPath}${url.search}`);
|
||||
}
|
||||
return {
|
||||
workspace: null,
|
||||
workspaceError: workspace.error,
|
||||
repositories: null,
|
||||
repositoriesError: null,
|
||||
};
|
||||
}
|
||||
|
||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||
|
||||
const [workspace, repositories] = await Promise.all([
|
||||
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
|
||||
|
|
|
|||
|
|
@ -1,133 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Yoi Workspace Control Plane</title>
|
||||
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card">
|
||||
<h2>Workspace</h2>
|
||||
{#if data.workspace}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{data.workspace.workspace_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{data.workspace.display_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{data.workspace.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host / Worker bridge</dt>
|
||||
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if data.workspaceError}
|
||||
<p class="error">{data.workspaceError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<h2>Hosts</h2>
|
||||
{#if data.hosts}
|
||||
{#if data.hosts.items.length === 0}
|
||||
<p>No local Hosts are visible.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each data.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>Runtime</dt>
|
||||
<dd><code>{host.runtime_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scope</dt>
|
||||
<dd>{host.capabilities.workspace_scope}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Platform</dt>
|
||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if data.hostsError}
|
||||
<p class="error">{data.hostsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workers</h2>
|
||||
{#if data.workers}
|
||||
{#if data.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>
|
||||
<th>Attach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.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.visibility} · {worker.workspace.identity}</td>
|
||||
<td>{worker.implementation.kind}</td>
|
||||
<td><a class="inline-link" href={workerConsoleHref(worker, workspaceId)}>Open Console</a></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if data.workersError}
|
||||
<p class="error">{data.workersError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workers</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<main class="workspace-panel-shell">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped workspace…</h1>
|
||||
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/<workspace-id></code> route.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,3 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type { Host, ListResponse, Worker } from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, parent }) => {
|
||||
const layout = await parent();
|
||||
const workspaceId = layout.workspace?.workspace_id ?? "";
|
||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||
|
||||
const [hosts, workers] = await Promise.all([
|
||||
loadJson<ListResponse<Host>>(fetch, apiPath("/hosts")),
|
||||
loadJson<ListResponse<Worker>>(fetch, apiPath("/workers")),
|
||||
]);
|
||||
|
||||
return {
|
||||
hosts: hosts.data,
|
||||
hostsError: hosts.error,
|
||||
workers: workers.data,
|
||||
workersError: workers.error,
|
||||
};
|
||||
};
|
||||
export const load: PageLoad = async () => ({});
|
||||
|
|
|
|||
|
|
@ -1,46 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Objectives · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card">
|
||||
<h2>Objectives</h2>
|
||||
<p class="section-note">Objectives are read from canonical filesystem records through <code>/api/objectives</code>.</p>
|
||||
{#if data.objectives}
|
||||
{#if data.objectives.items.length === 0}
|
||||
<p>No Objective records are present.</p>
|
||||
{:else}
|
||||
<div class="objective-list">
|
||||
{#each data.objectives.items as objective (objective.id)}
|
||||
<a class="objective-row" href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||
<div class="objective-main">
|
||||
<div class="objective-title-row">
|
||||
<strong class="objective-title">{objective.title}</strong>
|
||||
<span class="state-pill">{objective.state}</span>
|
||||
</div>
|
||||
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||
</div>
|
||||
<div class="objective-meta" aria-label="Objective metadata">
|
||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
||||
<code>{objective.id}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.objectives.invalid_records.length > 0}
|
||||
<p class="error">{data.objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
||||
{/if}
|
||||
{:else if data.objectivesError}
|
||||
<p class="error">{data.objectivesError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/objectives</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
<main class="objectives-page">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped objectives…</h1>
|
||||
<p class="section-note">Objectives are available at the canonical <code>/w/<workspace-id>/objectives</code> route.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,3 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type { ObjectiveListResponse } from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, parent }) => {
|
||||
const layout = await parent();
|
||||
const workspaceId = layout.workspace?.workspace_id ?? "";
|
||||
const objectives = await loadJson<ObjectiveListResponse>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/objectives"),
|
||||
);
|
||||
return {
|
||||
workspaceId,
|
||||
objectives: objectives.data,
|
||||
objectivesError: objectives.error,
|
||||
};
|
||||
};
|
||||
export const load: PageLoad = async () => ({});
|
||||
|
|
|
|||
|
|
@ -1,76 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace-api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card">
|
||||
<h2>Objectives</h2>
|
||||
{#if data.objectives}
|
||||
<div class="objective-list compact">
|
||||
{#each data.objectives.items as objective (objective.id)}
|
||||
<a class="objective-row" class:active={objective.id === data.objectiveId} href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||
<div class="objective-main">
|
||||
<div class="objective-title-row">
|
||||
<strong class="objective-title">{objective.title}</strong>
|
||||
<span class="state-pill">{objective.state}</span>
|
||||
</div>
|
||||
<p class="objective-summary">{objective.summary || 'No summary text is available.'}</p>
|
||||
</div>
|
||||
<div class="objective-meta" aria-label="Objective metadata">
|
||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||
<code>{objective.id}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if data.objectivesError}
|
||||
<p class="error">{data.objectivesError}</p>
|
||||
{:else}
|
||||
<p>Loading objectives…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card objective-detail-card">
|
||||
<h2>Objective detail</h2>
|
||||
{#if data.objective}
|
||||
<div class="objective-title-row detail">
|
||||
<div>
|
||||
<h3>{data.objective.title}</h3>
|
||||
<p><code>{data.objective.id}</code></p>
|
||||
</div>
|
||||
<span class="state-pill">{data.objective.state}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{data.objective.created_at ? formatDate(data.objective.created_at) : 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{data.objective.updated_at ? formatDate(data.objective.updated_at) : 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record source</dt>
|
||||
<dd>{data.objective.record_source}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Linked tickets</dt>
|
||||
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<pre class="objective-body">{data.objective.body}</pre>
|
||||
{#if data.objective.body_truncated}
|
||||
<p class="error">Objective body was truncated by the Backend response limit.</p>
|
||||
{/if}
|
||||
{:else if data.objectiveError}
|
||||
<p class="error">{data.objectiveError}</p>
|
||||
{:else}
|
||||
<p>Loading objective detail…</p>
|
||||
{/if}
|
||||
</section>
|
||||
<main class="objective-detail-page">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped objective…</h1>
|
||||
<p class="section-note">Objective detail routes are canonical under <code>/w/<workspace-id>/objectives/<objective-id></code>.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,3 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type { ObjectiveDetail, ObjectiveListResponse } from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, parent }) => {
|
||||
const layout = await parent();
|
||||
const workspaceId = layout.workspace?.workspace_id ?? "";
|
||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||
const objectiveId = params.objectiveId;
|
||||
const [objectives, objective] = await Promise.all([
|
||||
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
|
||||
loadJson<ObjectiveDetail>(
|
||||
fetch,
|
||||
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
objectiveId,
|
||||
objectives: objectives.data,
|
||||
objectivesError: objectives.error,
|
||||
objective: objective.data,
|
||||
objectiveError: objective.error,
|
||||
};
|
||||
};
|
||||
export const load: PageLoad = async () => ({});
|
||||
|
|
|
|||
|
|
@ -1,105 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { formatDate } from '$lib/workspace-api/http';
|
||||
import RepositoryTicketKanban from '$lib/workspace-pages/RepositoryTicketKanban.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.repository?.item.display_name ?? data.repositoryId} · Repository</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card repository-detail-card">
|
||||
<h2>Repository</h2>
|
||||
{#if data.repository}
|
||||
<div class="repository-detail-heading">
|
||||
<div>
|
||||
<h3>{data.repository.item.display_name}</h3>
|
||||
<p><code>{data.repository.item.id}</code></p>
|
||||
</div>
|
||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dd>{data.repository.item.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider</dt>
|
||||
<dd>{data.repository.item.provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{data.repository.item.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default selector</dt>
|
||||
<dd>{data.repository.item.default_selector ?? 'none configured'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Branch</dt>
|
||||
<dd>{data.repository.item.git?.branch ?? 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>HEAD</dt>
|
||||
<dd><code>{data.repository.item.git?.head ?? 'unknown'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Dirty</dt>
|
||||
<dd>{data.repository.item.git?.dirty ? 'yes' : 'no'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if data.repository.item.diagnostics && data.repository.item.diagnostics.length > 0}
|
||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||
{#each data.repository.item.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if data.repositoryError}
|
||||
<p class="error">{data.repositoryError}</p>
|
||||
{:else}
|
||||
<p>Loading repository…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card repository-log-card">
|
||||
<h2>Recent commits</h2>
|
||||
{#if data.repositoryLog}
|
||||
{#if data.repositoryLog.items.length === 0}
|
||||
<p>No recent commits are available.</p>
|
||||
{:else}
|
||||
<div class="commit-list">
|
||||
{#each data.repositoryLog.items as commit}
|
||||
<article class="commit-card">
|
||||
<strong>{commit.summary}</strong>
|
||||
<span><code>{commit.short_hash}</code> · {commit.author_name} · {formatDate(commit.author_date)}</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.repositoryLog.diagnostics.length > 0}
|
||||
<ul class="diagnostics" aria-label="Repository log diagnostics">
|
||||
{#each data.repositoryLog.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if data.repositoryLogError}
|
||||
<p class="error">{data.repositoryLogError}</p>
|
||||
{:else}
|
||||
<p>Loading repository commits…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card repository-tickets-card">
|
||||
<h2>Repository Tickets</h2>
|
||||
{#if data.repositoryTickets}
|
||||
<RepositoryTicketKanban tickets={data.repositoryTickets} />
|
||||
{:else if data.repositoryTicketsError}
|
||||
<p class="error">{data.repositoryTicketsError}</p>
|
||||
{:else}
|
||||
<p>Loading repository tickets…</p>
|
||||
{/if}
|
||||
</section>
|
||||
<main class="repository-detail-page">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped repository…</h1>
|
||||
<p class="section-note">Repository routes are canonical under <code>/w/<workspace-id>/repositories/<repository-id></code>.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,3 @@
|
|||
import { loadJson, workspaceApiPath } from "$lib/workspace-api/http";
|
||||
import type {
|
||||
RepositoryDetailResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositoryTicketsResponse,
|
||||
} from "$lib/workspace-sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, parent }) => {
|
||||
const layout = await parent();
|
||||
const workspaceId = layout.workspace?.workspace_id ?? "";
|
||||
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
|
||||
const repositoryId = params.repositoryId;
|
||||
const [repository, log, tickets] = await Promise.all([
|
||||
loadJson<RepositoryDetailResponse>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||
),
|
||||
loadJson<RepositoryLogResponse>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
|
||||
),
|
||||
loadJson<RepositoryTicketsResponse>(
|
||||
fetch,
|
||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
repositoryId,
|
||||
repository: repository.data,
|
||||
repositoryError: repository.error,
|
||||
repositoryLog: log.data,
|
||||
repositoryLogError: log.error,
|
||||
repositoryTickets: tickets.data,
|
||||
repositoryTicketsError: tickets.error,
|
||||
};
|
||||
};
|
||||
export const load: PageLoad = async () => ({});
|
||||
|
|
|
|||
|
|
@ -1,399 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
projectConsole,
|
||||
type ConsoleLine
|
||||
} from '$lib/workspace-console/model';
|
||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||
import type {
|
||||
ClientWorkerEventWsFrame,
|
||||
Diagnostic,
|
||||
Worker,
|
||||
WorkerInputResult,
|
||||
WorkerTranscriptProjection
|
||||
} from '$lib/workspace-sidebar/types';
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
};
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const workspaceId = $derived(data.workspaceId);
|
||||
const runtimeId = $derived(data.runtimeId);
|
||||
const workerId = $derived(data.workerId);
|
||||
|
||||
function workerApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
let worker = $state<Worker | null>(null);
|
||||
let workerError = $state<string | null>(null);
|
||||
let transcript = $state<WorkerTranscriptProjection | null>(null);
|
||||
let transcriptError = $state<string | null>(null);
|
||||
let draft = $state('');
|
||||
let sending = $state(false);
|
||||
let sendError = $state<string | null>(null);
|
||||
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
|
||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||
let workerDetailsOpen = $state(false);
|
||||
let observedEvents = $state<Array<{ cursor: string; event: ClientWorkerEventWsFrame & { kind: 'event' } }>>([]);
|
||||
let nextReloadToken = 0;
|
||||
let reloadToken = $state(0);
|
||||
|
||||
type ConsoleTarget = {
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
};
|
||||
|
||||
const consoleTarget = $derived({ runtimeId, workerId });
|
||||
|
||||
const projection = $derived(
|
||||
projectConsole(
|
||||
transcript?.items ?? [],
|
||||
observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))
|
||||
)
|
||||
);
|
||||
const lines = $derived(projection.lines);
|
||||
const diagnostics = $derived(
|
||||
mergeDiagnostics(worker?.diagnostics ?? [], transcript?.diagnostics ?? [], streamDiagnostics)
|
||||
);
|
||||
const canSend = $derived(Boolean(worker?.capabilities.can_accept_input) && draft.trim().length > 0 && !sending);
|
||||
|
||||
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 postJson<T>(path: string, body: unknown, timeoutMs = 30_000): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch {
|
||||
detail = '';
|
||||
}
|
||||
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorker(target: ConsoleTarget) {
|
||||
workerError = null;
|
||||
try {
|
||||
worker = await getJson<Worker>(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`)
|
||||
);
|
||||
} catch (error) {
|
||||
workerError = error instanceof Error ? error.message : String(error);
|
||||
worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranscript(target: ConsoleTarget) {
|
||||
transcriptError = null;
|
||||
try {
|
||||
transcript = await getJson<WorkerTranscriptProjection>(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/transcript?limit=200`)
|
||||
);
|
||||
} catch (error) {
|
||||
transcriptError = error instanceof Error ? error.message : String(error);
|
||||
transcript = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConsoleData(target: ConsoleTarget) {
|
||||
await Promise.all([loadWorker(target), loadTranscript(target)]);
|
||||
}
|
||||
|
||||
function advanceReloadToken(): number {
|
||||
nextReloadToken += 1;
|
||||
reloadToken = nextReloadToken;
|
||||
return nextReloadToken;
|
||||
}
|
||||
|
||||
async function sendMessage(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const content = draft.trim();
|
||||
if (!content || sending || !worker?.capabilities.can_accept_input) {
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
sendError = null;
|
||||
try {
|
||||
const result = await postJson<WorkerInputResult>(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`),
|
||||
{ kind: 'user', content }
|
||||
);
|
||||
if (result.state === 'accepted') {
|
||||
draft = '';
|
||||
} else {
|
||||
sendError = diagnosticsToText(result.diagnostics) || `Input was ${result.state}.`;
|
||||
}
|
||||
await loadTranscript(consoleTarget);
|
||||
} catch (error) {
|
||||
sendError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function connectObservation(targetWorker: Worker | null, token: number, target: ConsoleTarget) {
|
||||
if (!targetWorker) {
|
||||
streamState = 'closed';
|
||||
return;
|
||||
}
|
||||
streamState = 'connecting';
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsPath = workerApiPath(`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
|
||||
target.workerId
|
||||
)}/events/ws`);
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}${wsPath}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (token === reloadToken) {
|
||||
streamState = 'open';
|
||||
}
|
||||
};
|
||||
ws.onmessage = (message) => {
|
||||
if (token !== reloadToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const frame = JSON.parse(String(message.data)) as ClientWorkerEventWsFrame;
|
||||
if (frame.kind === 'event') {
|
||||
observedEvents = [
|
||||
...observedEvents,
|
||||
{
|
||||
cursor: frame.envelope.cursor,
|
||||
event: frame
|
||||
}
|
||||
].slice(-500);
|
||||
} else {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: frame.diagnostic.code,
|
||||
severity: 'warning',
|
||||
message: frame.diagnostic.message
|
||||
}
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: 'worker_observation_frame_invalid',
|
||||
severity: 'warning',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (token === reloadToken) {
|
||||
streamState = 'error';
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: 'worker_observation_ws_error',
|
||||
severity: 'error',
|
||||
message: 'Worker observation WebSocket failed.'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (token === reloadToken && streamState !== 'error') {
|
||||
streamState = 'closed';
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}
|
||||
|
||||
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function diagnosticsToText(items: Diagnostic[]): string {
|
||||
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
|
||||
}
|
||||
|
||||
function lineClass(line: ConsoleLine): string {
|
||||
return line.error ? 'error' : line.kind;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
observedEvents = [];
|
||||
streamDiagnostics = [];
|
||||
advanceReloadToken();
|
||||
void loadConsoleData(target);
|
||||
});
|
||||
|
||||
$effect(() => connectObservation(worker, reloadToken, consoleTarget));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Worker Console · Yoi Workspace</title>
|
||||
<meta name="description" content="Worker attach console through Workspace Backend APIs" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="console-shell worker-console-shell">
|
||||
<section class="console-header card">
|
||||
<div>
|
||||
<h2>{worker?.label ?? workerId}</h2>
|
||||
</div>
|
||||
<div class="console-header-actions">
|
||||
<div class="console-status-pill" class:warn={streamState !== 'open'}>
|
||||
{worker?.state ?? 'unknown'} · {worker?.status ?? 'loading'} · stream {streamState}
|
||||
</div>
|
||||
<button type="button" class="secondary-button" aria-expanded={workerDetailsOpen} onclick={() => workerDetailsOpen = !workerDetailsOpen}>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="console-body">
|
||||
<article class="card console-card worker-console-card">
|
||||
{#if projection.status || projection.usage}
|
||||
<p class="section-note">
|
||||
{#if projection.status}status: {projection.status}{/if}
|
||||
{#if projection.status && projection.usage} · {/if}
|
||||
{#if projection.usage}usage: {projection.usage}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if workerError}
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
{#if transcriptError}
|
||||
<p class="error">{transcriptError}</p>
|
||||
{/if}
|
||||
|
||||
{#if lines.length === 0}
|
||||
<p>No console output is available for this Worker yet.</p>
|
||||
{:else}
|
||||
<ol class="console-log">
|
||||
{#each lines as item}
|
||||
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
|
||||
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
{#if item.streaming}<small>streaming</small>{/if}
|
||||
</div>
|
||||
{:else if item.streaming}
|
||||
<div class="message-heading streaming-heading">
|
||||
<small>streaming</small>
|
||||
</div>
|
||||
{/if}
|
||||
<pre>{item.body || '—'}</pre>
|
||||
{#if item.detail}
|
||||
<details class="message-detail">
|
||||
<summary>detail</summary>
|
||||
<p>{item.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
{#if workerDetailsOpen}
|
||||
<aside class="console-side-panel" aria-label="Worker detail">
|
||||
<header class="side-panel-header">
|
||||
<h3>Worker detail</h3>
|
||||
<button type="button" class="secondary-button" onclick={() => workerDetailsOpen = false}>Close</button>
|
||||
</header>
|
||||
{#if worker}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Runtime</dt>
|
||||
<dd><code>{worker.runtime_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Worker</dt>
|
||||
<dd><code>{worker.worker_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host</dt>
|
||||
<dd><code>{worker.host_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Role / profile</dt>
|
||||
<dd>{worker.role ?? 'unknown'} / {worker.profile ?? 'unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Workspace</dt>
|
||||
<dd>{worker.workspace.visibility} · {worker.workspace.identity}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Implementation</dt>
|
||||
<dd>{worker.implementation.kind} · {worker.implementation.display_hint}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<details class="metadata-details">
|
||||
<summary>Capabilities</summary>
|
||||
<ul>
|
||||
<li>input: {worker.capabilities.can_accept_input ? 'available' : 'unsupported'}</li>
|
||||
<li>stop: {worker.capabilities.can_stop ? 'available' : 'unsupported'}</li>
|
||||
<li>follow-up spawn: {worker.capabilities.can_spawn_followup ? 'available' : 'unsupported'}</li>
|
||||
</ul>
|
||||
</details>
|
||||
{:else if !workerError}
|
||||
<p>Loading Worker detail…</p>
|
||||
{/if}
|
||||
|
||||
{#if diagnostics.length > 0}
|
||||
<details class="metadata-details" open={streamState === 'error'}>
|
||||
<summary>Diagnostics ({diagnostics.length})</summary>
|
||||
<ul>
|
||||
{#each diagnostics as diagnostic}
|
||||
<li>
|
||||
<strong>{diagnostic.severity}</strong>
|
||||
<code>{diagnostic.code}</code>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<form class="console-composer card" onsubmit={sendMessage}>
|
||||
<textarea
|
||||
id="worker-console-message"
|
||||
aria-label="Console input"
|
||||
bind:value={draft}
|
||||
disabled={!worker?.capabilities.can_accept_input || sending}
|
||||
></textarea>
|
||||
<div class="composer-actions">
|
||||
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<main class="worker-console-page">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped Worker Console…</h1>
|
||||
<p class="section-note">Worker Console routes are canonical under <code>/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console</code>.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
const layout = await parent();
|
||||
return {
|
||||
workspaceId: layout.workspace?.workspace_id ?? "",
|
||||
runtimeId: params.runtimeId,
|
||||
workerId: params.workerId,
|
||||
};
|
||||
};
|
||||
export const load: PageLoad = async () => ({});
|
||||
|
|
|
|||
|
|
@ -1,487 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { PageProps } from "./$types";
|
||||
import { workspaceApiPath, workspaceRoute } from "$lib/workspace-api/http";
|
||||
import {
|
||||
SETTINGS_PATTERNS,
|
||||
SETTINGS_PERMISSION_NOTICE,
|
||||
SETTINGS_SECTIONS,
|
||||
diagnosticLabel,
|
||||
settingsSectionHref,
|
||||
type Diagnostic,
|
||||
type RemoteRuntimeConnectionSummary,
|
||||
type RemoteRuntimeTestResponse,
|
||||
type RuntimeConnectionMutationResponse,
|
||||
type RuntimeConnectionSettingsResponse,
|
||||
} from "$lib/workspace-settings/model";
|
||||
|
||||
type RemoteAddForm = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspace = $derived(data.workspace);
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
let workspaceError = $derived(data.workspaceError);
|
||||
|
||||
function settingsApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
||||
let runtimeLoading = $state(true);
|
||||
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 showAddRuntimeForm = $state(false);
|
||||
let remoteForm = $state<RemoteAddForm>({
|
||||
runtime_id: "",
|
||||
display_name: "",
|
||||
endpoint: "",
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
runtimeLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRuntimeSettings() {
|
||||
runtimeLoading = true;
|
||||
runtimeError = null;
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRuntimeSettings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function submitRemoteRuntime() {
|
||||
submitting = true;
|
||||
mutationMessage = null;
|
||||
mutationDiagnostics = [];
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/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: "" };
|
||||
showAddRuntimeForm = false;
|
||||
} 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(settingsApiPath(`/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(settingsApiPath(`/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 capabilityOperations(test: RemoteRuntimeTestResponse, state: "available" | "unknown" | "incompatible"): string[] {
|
||||
const suffix = `:${state}`;
|
||||
return test.capabilities
|
||||
.filter((capability) => capability.endsWith(suffix))
|
||||
.map((capability) => capability.slice(0, -suffix.length));
|
||||
}
|
||||
|
||||
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>
|
||||
<title>Settings · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-shell" aria-labelledby="settings-title">
|
||||
<section class="hero settings-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace Browser</p>
|
||||
<h1 id="settings-title">Settings / Admin</h1>
|
||||
<p class="hero-copy">
|
||||
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">local only</span>
|
||||
</section>
|
||||
|
||||
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
|
||||
<div>
|
||||
<p class="eyebrow">Authority boundary</p>
|
||||
<h2 id="settings-boundary-title">No browser admin permission model</h2>
|
||||
<p>{SETTINGS_PERMISSION_NOTICE}</p>
|
||||
</div>
|
||||
<div class="settings-diagnostic" role="note">
|
||||
<strong>Restart-required</strong>
|
||||
<span>Runtime config changes are persisted, then applied after backend restart.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-nav-card" aria-label="Settings sections">
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
<a class="settings-nav-link" href={workspaceRoute(workspaceId, settingsSectionHref(section.id))}>
|
||||
<span>{section.label}</span>
|
||||
<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}
|
||||
<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>
|
||||
<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}
|
||||
|
||||
{#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="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)}
|
||||
<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"}
|
||||
</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>
|
||||
</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>
|
||||
<p>{test.compatibility_basis}</p>
|
||||
{#if available.length > 0}
|
||||
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
|
||||
{/if}
|
||||
{#if unchecked.length > 0}
|
||||
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
|
||||
{/if}
|
||||
{@render DiagnosticsList({ diagnostics: test.diagnostics })}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if runtimeSettings.remotes.length === 0}
|
||||
<p class="status-message">No remote Runtime connections configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="grid settings-grid">
|
||||
{#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>
|
||||
<p class="eyebrow">{section.status}</p>
|
||||
<h2 id={`${section.id}-title`}>{section.label}</h2>
|
||||
</div>
|
||||
{#if section.status === "placeholder"}
|
||||
<span class="badge neutral">not implemented</span>
|
||||
{:else}
|
||||
<span class="badge success">read-only</span>
|
||||
{/if}
|
||||
</header>
|
||||
<p>{section.summary}</p>
|
||||
<ul>
|
||||
{#each section.bullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if section.id === "workspace-identity"}
|
||||
<dl class="settings-identity-list">
|
||||
<div>
|
||||
<dt>Workspace id</dt>
|
||||
<dd><code>{workspace?.workspace_id ?? "loading"}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Display name</dt>
|
||||
<dd>{workspace?.display_name ?? "loading"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>.yoi tickets/objectives through the Backend projection</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
|
||||
<div>
|
||||
<p class="eyebrow">Implementation patterns</p>
|
||||
<h2 id="settings-patterns-title">How settings should appear</h2>
|
||||
</div>
|
||||
<div class="grid settings-pattern-grid">
|
||||
{#each SETTINGS_PATTERNS as pattern}
|
||||
<article class="settings-pattern">
|
||||
<h3>{pattern.title}</h3>
|
||||
<p>{pattern.body}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if !workspace && !workspaceError}
|
||||
<p class="status-message">Loading workspace summary…</p>
|
||||
{:else if workspaceError}
|
||||
<p class="status-message error">Workspace summary unavailable: {workspaceError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
<main class="settings-page">
|
||||
<section class="workspace-card">
|
||||
<h1>Redirecting to scoped settings…</h1>
|
||||
<p class="section-note">Settings are available at the canonical <code>/w/<workspace-id>/settings</code> route.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user