fix: navigate before loading page data

This commit is contained in:
2026-10-13 19:56:38 +09:00
parent a82e70047a
commit 26ab6130e0
119 changed files with 12748 additions and 11071 deletions
+2
View File
@@ -16,6 +16,8 @@ Workspace Webは、Workspaceの確定した状態を確認し、注意が必要
存在しないWorkspace、閲覧できないWorkspace、読込みに失敗したWorkspaceは区別し、別のWorkspaceへ暗黙に切り替えない。読取り専用の場合も、利用できる閲覧機能を残す。
ページ遷移はAPI応答を待たずに遷移先と読込み中表示を出す。旧ページを新しいURLの内容として残さず、遅れて届いた旧対象の応答で表示やcanonical遷移を上書きしない。同じ対象の再取得は別の更新中表示にし、Consoleの下書きなど編集中の内容を破棄しない。権限が必要な操作は確認済みのデータが届くまで表示しない。
## ページの目的
各routeの主要目的は、「Ticketを選ぶ」「Workerを確認する」「Repositoryのアクセスを設定する」のように、動詞と対象を一つずつ使って表現できるものにする。
@@ -0,0 +1,496 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { type Browser, chromium, type Page, type Request, type Route } from "npm:playwright@1.59.1";
import { loadScenario } from "../src/scenario.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
// Explicit browser boundary: production static assets + synthetic loopback API.
// No real Backend auth, Worker execution, or application test hooks.
// The caller supplies the built workspace via the standard suite layout; for isolated
// runs, copy this suite into a tree whose web/workspace/build points at isolated assets.
// This suite NEVER runs a build, mutates assets, or falls back to another static root.
const staticRoot = new URL("../../../web/workspace/build/", import.meta.url).pathname;
const output = new URL("../../../.yoi/dev/navigation-visual/", import.meta.url).pathname;
const phase = "after";
const listPath = "/w/tickets/tickets";
const detailPath = "/w/tickets/tickets/T-2-read-only-investigation";
const otherDetailPath = "/w/tickets/tickets/T-3-independent-review-before-merge";
async function withFixture(run: (browser: Browser, base: string) => Promise<void>) {
// A missing build is an explicit failure; no alternate root or implicit build.
await Deno.stat(`${staticRoot}index.html`);
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
const base = `http://127.0.0.1:${port}`;
scenario.processes![0].args![4] = String(port);
scenario.processes![0].args![5] = staticRoot;
scenario.processes![0].readyUrl = `${base}/health`;
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
`${output}${phase}/logs/${port}`,
[],
);
let browser: Browser | null = null;
try {
browser = await chromium.launch({ headless: true });
await run(browser, base);
} finally {
await browser?.close();
assertEquals(await stopOwnedProcesses(processes), [], "owned fixture cleanup");
}
}
async function bounded<T>(promise: Promise<T>): Promise<T> {
let timer: number | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Response gate timed out")), 15_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
// Fetch the real synthetic response, but do not deliver it to the browser until release.
// held means response bytes exist in Playwright, NOT requestfinished in the browser.
async function responseGate(
page: Page,
pattern: string | RegExp,
matches: (request: Request) => boolean = () => true,
replacement?: { status: number; body: string; contentType: string },
transform?: (body: Record<string, unknown>) => unknown,
) {
const held = Promise.withResolvers<void>();
const releaseSignal = Promise.withResolvers<void>();
const delivered = Promise.withResolvers<void>();
let closed = true;
let intercepted = false;
let browserFinished = false;
let gatedRequest: Request | undefined;
const onFinished = (request: Request) => {
if (request === gatedRequest) browserFinished = true;
};
page.on("requestfinished", onFinished);
const handler = async (route: Route) => {
if (intercepted || !matches(route.request())) return await route.fallback();
intercepted = true;
gatedRequest = route.request();
try {
const response = replacement ? undefined : await route.fetch();
const json = transform && response ? transform(await response.json()) : undefined;
held.resolve();
await releaseSignal.promise;
if (replacement) await route.fulfill(replacement);
else await route.fulfill({ response, ...(json === undefined ? {} : { json }) });
delivered.resolve();
} catch (error) {
held.reject(error);
delivered.reject(error);
}
};
await page.route(pattern, handler);
// Attach handlers immediately so cancellation during teardown isn't an unhandled rejection.
void held.promise.catch(() => {});
void delivered.promise.catch(() => {});
return {
held: () => bounded(held.promise),
assertClosed() {
assert(intercepted, "API response must be intercepted first");
assert(closed, "response gate must remain CLOSED during navigation assertions");
assertEquals(browserFinished, false, "browser must not have finished the held request");
},
release() {
closed = false;
releaseSignal.resolve();
},
delivered: () => bounded(delivered.promise),
async dispose() {
closed = false;
releaseSignal.resolve();
await page.unroute(pattern, handler);
page.off("requestfinished", onFinished);
},
};
}
type Gate = Awaited<ReturnType<typeof responseGate>>;
async function loading(page: Page, gate: Gate, path: string, title: RegExp, updating = false) {
await gate.held();
gate.assertClosed();
await page.waitForURL((url) => url.pathname + url.search === path, { timeout: 5_000 });
await page.getByRole("heading", { name: title, level: 1 }).waitFor({ timeout: 5_000 });
await page.getByRole("status").filter({ hasText: updating ? /^Updating…$/ : /Loading/ })
.first().waitFor({ timeout: 5_000 });
gate.assertClosed();
}
// Synthetic links exercise the public SvelteKit delegated link handler, not a full reload.
// Used only where the fixture has no product link (cross-workspace and stale slug scenarios).
async function navigate(page: Page, path: string) {
await page.evaluate((path) => {
const link = document.createElement("a");
link.href = path;
link.dataset.sveltekitPreloadData = "off";
document.body.append(link);
link.click();
link.remove();
}, path);
}
async function clickTickets(page: Page) {
// Actual product sidebar link, even when the mobile sidebar is folded.
await page.locator("a.sidebar-link").filter({ hasText: /^Tickets$/ })
.evaluate((link) => (link as HTMLAnchorElement).click());
}
async function paint(page: Page) {
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
}
async function capture(page: Page, name: string, gate?: Gate) {
gate?.assertClosed();
await paint(page);
const directory = `${output}${phase}`;
await Deno.mkdir(directory, { recursive: true });
await page.screenshot({ path: `${directory}/${name}.png`, fullPage: true });
// Safe, fixture-only evidence. Deliberately no cookies, storage, headers, trace or HAR.
const context = await page.evaluate(() => ({
path: location.pathname + location.search,
title: document.title,
headings: [...document.querySelectorAll("h1")].map((node) => node.textContent),
statuses: [...document.querySelectorAll('[role="status"]')].map((node) => node.textContent),
liveRegions: [...document.querySelectorAll('[aria-live="polite"]')].map((node) =>
node.textContent
),
alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent),
viewport: { width: innerWidth, height: innerHeight },
theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
pageOverflow: document.documentElement.scrollWidth > innerWidth,
}));
assertEquals(context.pageOverflow, false, "navigation state must not overflow viewport");
await Deno.writeTextFile(
`${directory}/${name}.json`,
JSON.stringify(
{
...context,
responseGate: gate ? "closed" : "resolved",
staticRoot,
boundary: "synthetic loopback fixture; no real Backend authentication",
},
null,
2,
),
);
gate?.assertClosed();
}
async function rows(page: Page) {
await page.locator("a.ticket-list-row").first().waitFor();
}
async function search(page: Page, query: string) {
await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(query);
await page.getByRole("button", { name: "Search", exact: true }).click();
}
const queryPattern = "**/api/w/tickets/tickets/query";
Deno.test("immediate navigation: Orchestrator -> Tickets -> detail before responses, visual matrix", async () => {
await withFixture(async (browser, base) => {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
colorScheme,
viewport: { width, height: 900 },
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
await page.goto(`${base}/w/tickets/orchestrator`);
await page.locator(".orchestrator-assignment").first().waitFor();
const listGate = await responseGate(page, queryPattern);
try {
await clickTickets(page);
await loading(page, listGate, listPath, /^Tickets$/);
assertEquals(await page.locator(".orchestrator-assignment").count(), 0);
await capture(page, `${colorScheme}-${width}-tickets-gated`, listGate);
listGate.release();
await rows(page);
await capture(page, `${colorScheme}-${width}-tickets-resolved`);
} finally {
await listGate.dispose();
}
const detailGate = await responseGate(page, "**/api/w/tickets/tickets/T-2");
try {
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
await loading(page, detailGate, detailPath, /^Ticket$/);
assertEquals(await page.locator("a.ticket-list-row").count(), 0);
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
await capture(page, `${colorScheme}-${width}-detail-gated`, detailGate);
detailGate.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.waitFor();
await capture(page, `${colorScheme}-${width}-detail-resolved`);
} finally {
await detailGate.dispose();
}
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
});
});
Deno.test("immediate navigation: query and back/forward commit URLs with response gates closed", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}${listPath}`);
await rows(page);
const open = await responseGate(page, queryPattern);
try {
await search(page, "state:closed");
await loading(page, open, `${listPath}?q=state%3Aclosed`, /^Tickets$/, true);
open.release();
await page.locator(".ticket-list-key").filter({ hasText: /^T-4$/ }).waitFor();
} finally {
await open.dispose();
}
for (const direction of ["back", "forward"] as const) {
const gate = await responseGate(page, queryPattern);
try {
// Don't await goBack/goForward's optional load lifecycle while API is gated.
await page.evaluate(
(direction) => direction === "back" ? history.back() : history.forward(),
direction,
);
const path = direction === "back" ? listPath : `${listPath}?q=state%3Aclosed`;
await loading(page, gate, path, /^Tickets$/, true);
await capture(page, `query-${direction}-gated`, gate);
gate.release();
await page.getByRole("searchbox", { name: "Search tickets", exact: true })
.waitFor();
await page.waitForFunction(
(value) =>
(document.querySelector('input[type="search"]') as HTMLInputElement)?.value === value,
direction === "back" ? "state:open" : "state:closed",
);
await rows(page);
if (direction === "forward") {
assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]);
}
} finally {
await gate.dispose();
}
}
});
});
Deno.test("immediate navigation: rapid A -> B rejects late A data and stale canonical redirects", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}${listPath}`);
await rows(page);
// First canonical A (late data), then an obsolete slug A (late canonical redirect).
for (const pathA of [detailPath, "/w/tickets/tickets/T-2-obsolete-title"]) {
const gateA = await responseGate(page, "**/api/w/tickets/tickets/T-2");
const gateB = await responseGate(page, "**/api/w/tickets/tickets/T-3");
try {
await navigate(page, pathA);
await loading(page, gateA, pathA, /^Ticket$/);
await navigate(page, otherDetailPath);
await loading(page, gateB, otherDetailPath, /^Ticket$/);
gateA.assertClosed();
gateB.release();
await page.getByRole("heading", { name: "Independent review before merge", exact: true })
.waitFor();
const response = page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/w/tickets/tickets/T-2"
);
gateA.release();
await response;
await gateA.delivered();
await paint(page);
assertEquals(new URL(page.url()).pathname, otherDetailPath);
assert(
await page.getByRole("heading", {
name: "Independent review before merge",
exact: true,
}).isVisible(),
);
assertEquals(
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.count(),
0,
);
await capture(
page,
pathA === detailPath
? "late-old-response-resolved"
: "stale-canonical-redirect-resolved",
);
} finally {
await gateA.dispose();
await gateB.dispose();
}
await navigate(page, listPath);
await rows(page);
}
});
});
Deno.test("immediate navigation: slow workspace switch clears old identity and permission projection", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
// Same existing fixture server, distinct identities and explicit opposite permissions.
await page.route("**/api/w/*/workspace", async (route) => {
const response = await route.fetch();
const body = await response.json();
const old = body.workspace_id === "tickets";
body.display_name = old ? "Old privileged workspace" : "New read-only workspace";
body.permissions = {
manage_repositories: old,
manage_secrets: old,
manage_runtimes: old,
delete_workspace: old,
};
await route.fulfill({ response, json: body });
});
await page.goto(`${base}${detailPath}`);
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.getByText("Old privileged workspace", { exact: true }).first().waitFor();
assert(
await page.getByRole("button", { name: "Release assignment", exact: true }).isVisible(),
);
// route.fetch bypasses other Playwright handlers: transform this held response too.
const gate = await responseGate(
page,
"**/api/w/tickets-readonly/workspace",
() => true,
undefined,
(body) => ({ ...body, display_name: "New read-only workspace" }),
);
const target = detailPath.replace("/w/tickets/", "/w/tickets-readonly/");
try {
await navigate(page, target);
await loading(page, gate, target, /^Workspace$/);
assertEquals(await page.getByText("Old privileged workspace", { exact: true }).count(), 0);
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
assertEquals(
await page.locator('a[href="/w/tickets/settings"], a[href="/w/tickets"]').count(),
0,
);
await capture(page, "workspace-switch-gated", gate);
gate.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.getByText("New read-only workspace", { exact: true }).first().waitFor();
assertEquals(
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
0,
);
await capture(page, "workspace-switch-resolved");
} finally {
await gate.dispose();
}
});
});
Deno.test("immediate navigation: cold load and unrelated global catalog response cannot block destination", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const catalog = await responseGate(page, /\/api\/workspaces(?:\?|$)/);
const tickets = await responseGate(page, queryPattern);
try {
await page.goto(`${base}${listPath}`, { waitUntil: "domcontentloaded" });
await catalog.held();
await loading(page, tickets, listPath, /^Tickets$/);
catalog.assertClosed();
await capture(page, "cold-load-gated", tickets);
tickets.release();
await rows(page);
// Global catalog remains unavailable while the page is fully usable.
catalog.assertClosed();
const detail = await responseGate(page, "**/api/w/tickets/tickets/T-2");
try {
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
await loading(page, detail, detailPath, /^Ticket$/);
catalog.assertClosed();
detail.release();
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
.waitFor();
catalog.assertClosed();
await capture(page, "global-catalog-delayed-detail-resolved");
} finally {
await detail.dispose();
}
catalog.release();
} finally {
await catalog.dispose();
await tickets.dispose();
}
});
});
Deno.test("immediate navigation: failed read stays at destination and gated Retry recovers", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/orchestrator`);
await page.locator(".orchestrator-assignment").first().waitFor();
const failure = await responseGate(page, queryPattern, () => true, {
status: 503,
contentType: "application/json",
body: JSON.stringify({ message: "Navigation fixture unavailable" }),
});
try {
await clickTickets(page);
await loading(page, failure, listPath, /^Tickets$/);
failure.release();
await page.getByRole("alert").filter({
hasText: /Ticket Browser API request failed with HTTP 503/,
})
.waitFor();
assertEquals(new URL(page.url()).pathname, listPath);
assertEquals(
await page.getByText("No tickets match this query.", { exact: true }).count(),
0,
);
await capture(page, "read-error");
} finally {
await failure.dispose();
}
const retry = await responseGate(page, queryPattern);
try {
await page.getByRole("button", { name: "Retry", exact: true }).click();
// Product Retry performs a local query read, not SvelteKit invalidation.
// Its polite live-region status must be visible while that response stays CLOSED.
await retry.held();
retry.assertClosed();
assertEquals(new URL(page.url()).pathname, listPath);
await page.getByRole("heading", { name: "Tickets", level: 1, exact: true }).waitFor();
await page.locator('.ticket-list-page-state[aria-live="polite"]')
.filter({ hasText: /^Loading…$/ }).waitFor();
retry.assertClosed();
await capture(page, "read-retry-gated", retry);
retry.release();
await rows(page);
assertEquals(await page.getByRole("alert").count(), 0);
await capture(page, "read-retry-resolved");
} finally {
await retry.dispose();
}
});
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
},
"fmt": {
"lineWidth": 100,
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
@@ -220,11 +220,11 @@ Deno.test("design lab demonstrates nested sidebar slots without flattening level
Deno.test("workspace Worker list lives on the dedicated Workers page", async () => {
const workspacePage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+page.svelte", import.meta.url),
new URL("./../../../routes/w/[workspaceId]/PageContent.svelte", import.meta.url),
);
const workersPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/PageContent.svelte",
import.meta.url,
),
);
@@ -337,7 +337,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
);
const ticketsPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.svelte",
"./../../../routes/w/[workspaceId]/tickets/PageContent.svelte",
import.meta.url,
),
);
@@ -349,7 +349,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
);
const ticketDetailPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte",
import.meta.url,
),
);
@@ -364,7 +364,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
);
const repositoryPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte",
"./../../../routes/w/[workspaceId]/repositories/[repositoryKey]/PageContent.svelte",
import.meta.url,
),
);
@@ -444,7 +444,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
);
const subjectIndexPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/memory/+page.svelte",
"./../../../routes/w/[workspaceId]/memory/PageContent.svelte",
import.meta.url,
),
);
@@ -456,7 +456,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
);
const subjectPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte",
"./../../../routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte",
import.meta.url,
),
);
@@ -468,7 +468,7 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
);
const memoryDetailPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte",
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/PageContent.svelte",
import.meta.url,
),
);
@@ -544,7 +544,7 @@ Deno.test("root layout keeps Workspace selection explicit", async () => {
Deno.test("Worker Console uses protocol observation events without transcript fetch", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -569,7 +569,7 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
Deno.test("Worker Console models availability separately from projected lines", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -619,7 +619,7 @@ Deno.test("Worker Console models availability separately from projected lines",
Deno.test("Worker Console owns its narrower centered shell width", async () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -733,7 +733,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
Deno.test("Model information stays below Composer in one compact monospace row", async () => {
const page = await Deno.readTextFile(new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
));
const composer = page.indexOf('<form class="console-composer"');
@@ -767,7 +767,7 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
"WorkerContextStatus.svelte",
"ConsoleDisplayState.svelte",
"ConsoleTurnNavigation.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
];
for (const file of files) {
const source = await Deno.readTextFile(new URL(file, import.meta.url));
@@ -807,7 +807,7 @@ Deno.test("Console and app shell keep compact layout spacing", async () => {
);
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -830,7 +830,7 @@ Deno.test("Console and app shell keep compact layout spacing", async () => {
Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves scrolling", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -884,7 +884,7 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -913,7 +913,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -946,7 +946,7 @@ Deno.test("Worker Console composer keeps a compact bounded chip editor", async (
Deno.test("Worker Console composer separates the editor from its icon toolbar", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -987,7 +987,7 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
Deno.test("Worker Console keeps Notify beside Submit without persistent notices", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1022,7 +1022,7 @@ Deno.test("Worker Console keeps Notify beside Submit without persistent notices"
Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1059,7 +1059,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
Deno.test("Worker Console routes image paste through the existing attachment upload", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1116,7 +1116,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
);
const runtimesPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/PageContent.svelte",
import.meta.url,
),
);
@@ -1125,7 +1125,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
);
const workdirsPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/PageContent.svelte",
import.meta.url,
),
);
@@ -1170,7 +1170,7 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
);
const newWorkerPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/new/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/new/PageContent.svelte",
import.meta.url,
),
);
@@ -1192,7 +1192,7 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
Deno.test("Worker Console route resolves logical Worker authority before Runtime APIs", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1375,8 +1375,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
globalNavSections.includes("workspaces") &&
globalNavSections.includes("workspace.display_name") &&
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
rootLayout.includes("workspaces={data.accessibleWorkspaces}") &&
rootLayout.includes("workspaceError={data.workspaceCatalogError}") &&
rootLayout.includes("observeDeferred(() => data.deferredCatalog") &&
rootLayout.includes("workspaces={catalog.data?.accessibleWorkspaces ?? null}") &&
rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") &&
rootLayoutLoad.includes("deferredCatalog: settle(") &&
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
!globalNavSections.includes("Tickets") &&
!globalNavSections.includes("Repositories"),
@@ -1388,6 +1390,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
workspaceLayout.includes("controller={parentSidebarController}") &&
workspaceLayout.includes("sidebar={workspaceSidebar}") &&
workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("deferredWorkspace: settle(") &&
workspaceLayout.includes("observeDeferred(() => data.deferredWorkspace") &&
workspaceLayout.includes("loaded.data?.workspace ?? null") &&
workspaceLayout.includes("loaded.failure?.message ?? null") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
);
@@ -1441,7 +1447,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
Deno.test("Workspace Worker list and Console share the multiplexed connection", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1478,7 +1484,7 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
Deno.test("Web Console renders the client-projected Worker task store", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1517,7 +1523,7 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
Deno.test("Web Console switches main and direct SubWorker views from the Tasks row", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1561,7 +1567,7 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1594,7 +1600,7 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
Deno.test("mini task summary owns pane toggling instead of the header", async () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1625,7 +1631,7 @@ Deno.test("mini task summary owns pane toggling instead of the header", async ()
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1675,7 +1681,7 @@ Deno.test("Pending inputs are single-line previews between Tasks and Composer",
Deno.test("Web Console uses Notify while running and exposes queued input cancellation", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -1738,7 +1744,7 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
Deno.test("Web Console gates compaction progress before rendering run status", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte",
import.meta.url,
),
);
@@ -12,14 +12,15 @@
let { workspaceId, workspace = null, workspaceError = null }: Props = $props();
const workerName = $derived.by(() => {
const data = page.data as Record<string, unknown>;
const worker = data.worker as
| { display_name?: string | null; label?: string | null }
| null
| undefined;
return worker?.display_name ?? worker?.label ?? null;
});
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
import { settle, type DeferredResult } from '#lib/workspace/navigation/deferred.ts';
type WorkerData = { worker?: { display_name?: string | null; label?: string | null } | null };
const resource = observeDeferred(
() => (page.data as { deferredPage?: Promise<DeferredResult<WorkerData>> }).deferredPage ?? settle({} as WorkerData),
() => page.url.pathname,
);
const workerName = $derived(resource.data?.worker?.display_name ?? resource.data?.worker?.label ?? null);
const breadcrumbs = $derived(
buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }),
);
@@ -0,0 +1,79 @@
<script lang="ts" generics="T extends { deferredWorkspace?: Promise<DeferredResult<object>>; deferredPage?: Promise<DeferredResult<object>>; deferredCatalog?: unknown }">
import { goto, invalidateAll } from '$app/navigation';
import { page, navigating } from '$app/state';
import type { Snippet } from 'svelte';
import { resolvePageData, type DeferredResult, type DeferredFailure, type ResolvedPageData } from './deferred.ts';
let { data, title, children, identity }: {
identity?: string;
data: T;
title: string;
children: Snippet<[ResolvedPageData<T>]>;
} = $props();
let resolved = $state.raw<{ path: string; value: ResolvedPageData<T> } | null>(null);
let failure = $state.raw<{ path: string; value: DeferredFailure } | null>(null);
let loading = $state(true);
const path = $derived(identity ?? page.url.pathname);
const current = $derived(resolved?.path === path ? resolved.value : null);
const currentFailure = $derived(failure?.path === path ? failure.value : null);
$effect(() => {
const requestedPath = path;
const requestedData = data;
let active = true;
loading = true;
failure = null;
void resolvePageData(requestedData).then(async (result) => {
if (!active) return;
if (result.ok) {
resolved = { path: requestedPath, value: result.data };
loading = false;
} else if (result.failure.redirect) {
// 次のroute moduleを待っている間も、旧ページのcanonical遷移で奪い返さない。
if (navigating.to && navigating.to.url.href !== page.url.href) return;
resolved = null;
try {
await goto(result.failure.redirect, { replace: true });
} catch (error) {
if (active) {
failure = { path: requestedPath, value: { message: String(error), status: null, redirect: null } };
loading = false;
}
}
} else {
resolved = null;
failure = { path: requestedPath, value: result.failure };
loading = false;
}
});
return () => { active = false; };
});
</script>
<svelte:head>
{#if currentFailure}<title>{title} · Unavailable</title>
{:else if !current}<title>{title} · Loading</title>{/if}
</svelte:head>
{#if currentFailure}
<section class="workspace-page deferred-page" aria-label={title}>
<h1>{title}</h1>
<p role="alert">{currentFailure.message}</p>
<button type="button" class="workspace-secondary-button" onclick={() => invalidateAll()}>Retry</button>
</section>
{:else if current}
{#if loading}<p class="deferred-refresh" role="status">Updating…</p>{/if}
{#key path}{@render children(current)}{/key}
{:else}
<section class="workspace-page deferred-page" aria-label={title} aria-busy="true">
<h1>{title}</h1>
<p role="status">Loading…</p>
</section>
{/if}
<style>
.deferred-page { min-width: 0; }
.deferred-page h1 { color: var(--text-strong); font-size: var(--font-size-title); line-height: var(--line-height-title); margin: 0; }
.deferred-page p, .deferred-refresh { color: var(--text-muted); }
.deferred-refresh { margin: 0; font-size: var(--font-size-compact); }
</style>
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { createRawSnippet, tick } from "svelte";
import DeferredPage from "./DeferredPage.svelte";
import { type DeferredResult, settle } from "./deferred.ts";
const navigation = vi.hoisted(() => ({
goto: vi.fn(async () => {}),
invalidateAll: vi.fn(async () => {}),
to: null as { url: URL } | null,
}));
vi.mock("$app/navigation", () => navigation);
vi.mock(
"$app/state",
() => ({ page: { url: new URL("https://example.test/w/team/tickets") }, navigating: navigation }),
);
afterEach(() => {
cleanup();
vi.clearAllMocks();
navigation.to = null;
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve: (value: T) => resolve(value) };
}
type Content = { label: string };
const TestPage = DeferredPage<
{ deferredPage: Promise<DeferredResult<Content>> }
>;
const children = createRawSnippet<[Content]>((getData) => ({
render: () =>
`<div><label>Draft<input aria-label="Draft" /></label><p>${getData().label}</p></div>`,
}));
test("pending route shows destination loading without constructing its editor", async () => {
const pending = gate<DeferredResult<Content>>();
const view = render(TestPage, {
data: { deferredPage: pending.promise },
title: "Tickets",
children,
});
expect(view.getByRole("heading").textContent).toBe("Tickets");
expect(view.getByRole("status").textContent).toBe("Loading…");
expect(view.queryByLabelText("Draft")).toBeNull();
pending.resolve({ ok: true, data: { label: "First page" } });
await view.findByLabelText("Draft");
expect(view.queryByRole("status")).toBeNull();
});
test("same-path refresh keeps the mounted input and pending edits", async () => {
const view = render(TestPage, {
data: { deferredPage: settle({ label: "First page" }) },
title: "Tickets",
children,
});
const input = await view.findByLabelText("Draft") as HTMLInputElement;
await fireEvent.input(input, { target: { value: "Unsent draft" } });
const refresh = gate<DeferredResult<Content>>();
await view.rerender({
data: { deferredPage: refresh.promise },
title: "Tickets",
children,
});
expect(view.getByRole("status").textContent).toBe("Updating…");
expect(view.getByLabelText("Draft")).toBe(input);
refresh.resolve({ ok: true, data: { label: "Fresh data" } });
await waitFor(() => expect(view.queryByRole("status")).toBeNull());
expect(view.getByLabelText("Draft")).toBe(input);
expect(input.value).toBe("Unsent draft");
});
test("late previous load cannot redirect or overwrite a newer result", async () => {
const old = gate<DeferredResult<Content>>();
const view = render(TestPage, {
data: { deferredPage: old.promise },
title: "Tickets",
children,
});
await view.rerender({
data: { deferredPage: settle({ label: "Current page" }) },
title: "Tickets",
children,
});
await view.findByText("Current page");
old.resolve({
ok: false,
failure: { message: "Redirecting…", status: 308, redirect: "/stale" },
});
await tick();
expect(navigation.goto).not.toHaveBeenCalled();
expect(view.getByText("Current page")).toBeTruthy();
});
test("permission failure never renders content and Retry requests fresh load", async () => {
const view = render(TestPage, {
data: {
deferredPage: Promise.resolve({
ok: false as const,
failure: { message: "Permission denied", status: 403, redirect: null },
}),
},
title: "Settings",
children,
});
await view.findByRole("alert");
expect(view.queryByLabelText("Draft")).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "Retry" }));
expect(navigation.invalidateAll).toHaveBeenCalledOnce();
});
test("denied refresh clears cached controls throughout a pending Retry", async () => {
const view = render(TestPage, {data:{deferredPage:settle({label:"Authorized"})}, title:"Settings", children});
await view.findByLabelText("Draft");
await view.rerender({data:{deferredPage:Promise.resolve({ok:false, failure:{message:"Denied",status:403,redirect:null}})}, title:"Settings", children});
await view.findByRole("alert");
const retry=gate<DeferredResult<Content>>();
await view.rerender({data:{deferredPage:retry.promise},title:"Settings",children});
expect(view.queryByLabelText("Draft")).toBeNull();
expect(view.getByRole("status").textContent).toBe("Loading…");
});
test("query-derived launch identity remounts the editor even on the same pathname", async () => {
const view=render(TestPage,{data:{deferredPage:settle({label:"Ticket A"})},title:"New Worker",identity:"team:ticket-a",children});
const old=await view.findByLabelText("Draft") as HTMLInputElement;
old.value="Ticket A instruction";
await view.rerender({data:{deferredPage:settle({label:"Ticket B"})},title:"New Worker",identity:"team:ticket-b",children});
await view.findByText("Ticket B");
expect(view.getByLabelText("Draft")).not.toBe(old);
expect((view.getByLabelText("Draft") as HTMLInputElement).value).toBe("");
});
test("old canonical redirect cannot overtake a navigation still loading its route module", async () => {
const old=gate<DeferredResult<Content>>();
render(TestPage,{data:{deferredPage:old.promise},title:"Tickets",children});
navigation.to={url:new URL("https://example.test/w/team/objectives")};
old.resolve({ok:false,failure:{message:"Redirecting…",status:308,redirect:"/stale"}});
await tick();
expect(navigation.goto).not.toHaveBeenCalled();
});
@@ -0,0 +1,96 @@
import { error, redirect } from "@sveltejs/kit";
import { deferPage, resolvePageData, settle } from "./deferred.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assert(condition: unknown): asserts condition {
if (!condition) throw new Error("Assertion failed");
}
function gate<T>() {
let release!: (value: T) => void;
return {
promise: new Promise<T>((resolve) => {
release = resolve;
}),
release: (value: T) => release(value),
};
}
Deno.test("deferred load returns before API completion and snapshots navigation dependencies", async () => {
const api = gate<string>();
const event = {
params: { id: "first" },
url: new URL("https://example.test/first?q=old"),
};
const load = deferPage(async ({ params, url }: typeof event) => ({
value: await api.promise,
id: params.id,
query: url.searchParams.get("q"),
}));
const response = load(event);
assert(!(response instanceof Promise));
event.params.id = "second";
event.url.searchParams.set("q", "new");
api.release("complete");
const result = await response.deferredPage;
assert(
result.ok && result.data.id === "first" && result.data.query === "old" &&
result.data.value === "complete",
);
});
Deno.test("deferred redirects and HTTP failures are handled results, not unhandled rejections", async () => {
for (
const [run, status, location] of [
[() => redirect(308, "/canonical"), 308, "/canonical"],
[() => error(403, "Permission denied"), 403, null],
[
() => {
throw new Error("Failed read");
},
null,
null,
],
] as const
) {
const load = deferPage((_event: { params: {}; url: URL }) => run());
const result = await load({
params: {},
url: new URL("https://example.test/"),
}).deferredPage;
assert(
!result.ok && result.failure.status === status &&
result.failure.redirect === location,
);
}
});
Deno.test("page content cannot override a failed Workspace permission lookup", async () => {
const result = await resolvePageData({
deferredWorkspace: settle(Promise.reject(new Error("Workspace denied"))),
deferredPage: settle({ title: "Sensitive content" }),
});
assert(!result.ok && result.failure.message === "Workspace denied");
});
Deno.test("resolved data preserves page overrides without waiting for the global catalog", async () => {
const catalog = gate<never>();
const result = await resolvePageData({
deferredWorkspace: settle({
workspace: { name: "team" },
repositories: { items: [] },
}),
deferredCatalog: catalog.promise,
workspaceId: "team",
deferredPage: settle({
repositories: { data: null, error: "Unavailable" },
ticket: "T-1",
}),
});
assert(
result.ok && result.data.repositories.error === "Unavailable" &&
result.data.workspace.name === "team" && result.data.ticket === "T-1" &&
result.data.workspaceId === "team",
);
});
@@ -0,0 +1,104 @@
import { isHttpError, isRedirect } from "@sveltejs/kit";
export type DeferredFailure = {
message: string;
status: number | null;
redirect: string | null;
};
export type DeferredResult<T> =
| { ok: true; data: T }
| { ok: false; failure: DeferredFailure };
// Routerへは即座に戻り、拒否されたPromiseも遷移後の表示境界で扱う。
// 読込み中のキャンセルは外部副作用の取消ではなく、古い結果の表示抑止とする。
export async function settle<T>(
value: T | PromiseLike<T>,
): Promise<DeferredResult<T>> {
try {
return { ok: true, data: await value };
} catch (error) {
return {
ok: false,
failure: isRedirect(error)
? {
message: "Redirecting…",
status: error.status,
redirect: error.location,
}
: {
message: isHttpError(error)
? error.body.message
: error instanceof Error
? error.message
: String(error),
status: isHttpError(error) ? error.status : null,
redirect: null,
},
};
}
}
export function deferPage<
E extends { params: Record<string, string | undefined>; url: URL },
T extends object,
>(
loader: (event: E) => T | Promise<T>,
): (event: E) => { deferredPage: Promise<DeferredResult<T>> } {
return (event) => {
// 遅延処理が読むparams/URLも、loadが返る前にRouterの依存として記録する。
const snapshot = {
...event,
params: { ...event.params },
url: new URL(event.url.href),
};
try {
return { deferredPage: settle(loader(snapshot)) };
} catch (error) {
return { deferredPage: settle(Promise.reject(error)) };
}
};
}
type Merge<A, B> = Omit<A, keyof B> & B;
type WorkspaceValue<T> = T extends
{ deferredWorkspace: Promise<DeferredResult<infer W>> } ? W : {};
type PageValue<T> = T extends { deferredPage: Promise<DeferredResult<infer P>> }
? P
: {};
export type ResolvedPageData<T> = Merge<
Merge<
WorkspaceValue<T>,
Omit<T, "deferredWorkspace" | "deferredPage" | "deferredCatalog">
>,
PageValue<T>
>;
type DeferredData = {
deferredWorkspace?: Promise<DeferredResult<object>>;
deferredPage?: Promise<DeferredResult<object>>;
deferredCatalog?: unknown;
};
export async function resolvePageData<T extends DeferredData>(
data: T,
): Promise<DeferredResult<ResolvedPageData<T>>> {
const {
deferredWorkspace,
deferredPage,
deferredCatalog: _catalog,
...base
} = data;
const [workspace, content] = await Promise.all([
deferredWorkspace ?? settle({}),
deferredPage ?? settle({}),
]);
// Workspace取得の失敗をpage成功で補わず、権限の確認前に操作を描画しない。
if (!workspace.ok) return workspace;
if (!content.ok) return content;
return {
ok: true,
data: { ...workspace.data, ...base, ...content.data } as ResolvedPageData<
T
>,
};
}
@@ -0,0 +1,43 @@
import type { DeferredFailure, DeferredResult } from "./deferred.ts";
// 同じ対象の再取得では確定済み表示を保持し、対象切替では旧値を即座に隠す。
export function observeDeferred<T>(
read: () => Promise<DeferredResult<T>>,
identity: () => string,
) {
let settled = $state.raw<{ identity: string; value: T } | null>(null);
let failure = $state.raw<{ identity: string; value: DeferredFailure } | null>(
null,
);
let loading = $state(true);
$effect(() => {
const key = identity();
const promise = read();
let active = true;
loading = true;
failure = null;
void promise.then((result) => {
if (!active) return;
loading = false;
if (result.ok) settled = { identity: key, value: result.data };
else {
settled = null;
failure = { identity: key, value: result.failure };
}
});
return () => {
active = false;
};
});
return {
get data() {
return settled?.identity === identity() ? settled.value : null;
},
get failure() {
return failure?.identity === identity() ? failure.value : null;
},
get loading() {
return loading;
},
};
}
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { afterEach, expect, test, vi } from "vitest";
import { refreshPageData } from "./refresh.ts";
import { type DeferredResult, settle } from "./deferred.ts";
const state = vi.hoisted(() => ({ url: new URL("https://example.test/settings"), data: {} as object, invalidateAll: vi.fn(async () => {}) }));
vi.mock("$app/navigation", () => ({ invalidateAll: state.invalidateAll }));
vi.mock("$app/state", () => ({page: state}));
afterEach(() => { vi.clearAllMocks(); });
function gate<T>() { let release!: (value:T)=>void; const promise=new Promise<T>(resolve=>{release=resolve});return{promise,release:(value:T)=>release(value)}; }
test("mutation reload waits for newly published Workspace and page authority", async () => {
const workspace=gate<DeferredResult<object>>();
const content=gate<DeferredResult<object>>();
state.data={deferredPage:settle({old:true})};
state.invalidateAll.mockImplementation(async()=>{state.data={deferredWorkspace:workspace.promise,deferredPage:content.promise}});
let complete=false;
const refresh=refreshPageData().then(()=>{complete=true});
await Promise.resolve();
workspace.release({ok:true,data:{}});
await Promise.resolve();
expect(complete).toBe(false);
content.release({ok:true,data:{}});
await refresh;
expect(complete).toBe(true);
});
test("denied or superseded reload is not reported as refreshed authority", async () => {
state.invalidateAll.mockImplementation(async()=>{state.data={deferredPage:Promise.resolve({ok:false,failure:{message:"Denied",status:403,redirect:null}})}});
await expect(refreshPageData()).rejects.toThrow("Denied");
const pending=gate<DeferredResult<object>>();
state.invalidateAll.mockImplementation(async()=>{state.data={deferredPage:pending.promise}});
const refresh=refreshPageData();
await Promise.resolve();
state.data={deferredPage:settle({newer:true})};
pending.release({ok:true,data:{}});
await expect(refresh).rejects.toThrow("superseded");
});
@@ -0,0 +1,20 @@
import { invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { type DeferredResult, resolvePageData } from "./deferred.ts";
// mutation後のauthority再取得は、RouterがPromiseを差し替えた時点では完了していない。
// 保持中のcontent propsではなく、新しく公開されたroute dataの応答まで待つ。
export async function refreshPageData(): Promise<void> {
const url = page.url.href;
await invalidateAll();
if (page.url.href !== url) throw new Error("Page changed while refreshing");
const data = page.data as {
deferredWorkspace?: Promise<DeferredResult<object>>;
deferredPage?: Promise<DeferredResult<object>>;
};
const result = await resolvePageData(data);
if (page.url.href !== url || page.data !== data) {
throw new Error("Page refresh was superseded");
}
if (!result.ok) throw new Error(result.failure.message);
}
@@ -7,7 +7,7 @@
type Props = {
currentPath: string;
content?: SidebarSnippet | null;
workspaces?: WorkspaceCatalogRecord[];
workspaces?: WorkspaceCatalogRecord[] | null;
workspaceError?: string | null;
};
@@ -79,7 +79,7 @@
{#if content}
{@render content()}
{:else}
{:else if workspace && !ownsRoutePath(workspaceSettingsHref, currentPath)}
<nav class="sidebar-sections" aria-label="Workspace sections">
<TicketsNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
@@ -87,10 +87,9 @@ Deno.test("workspace header prefixes breadcrumbs with the same Workspace menu",
"Workspace selector should precede the breadcrumb trail",
);
assert(
workspaceLayoutSource.includes("workspace={data.workspace ?? null}") &&
workspaceLayoutSource.includes(
"workspaceError={data.workspaceError ?? null}",
),
workspaceLayoutSource.includes("loaded.data?.workspace ?? null") &&
workspaceLayoutSource.includes("{workspace}") &&
workspaceLayoutSource.includes("{workspaceError}"),
"Workspace layout should pass the authoritative Workspace name to the header selector",
);
});
@@ -15,7 +15,7 @@ const ticketLoader = await Deno.readTextFile(
);
const ticketPage = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
"../../../routes/w/[workspaceId]/tickets/[ticketId]/PageContent.svelte",
import.meta.url,
),
);
@@ -33,7 +33,7 @@ const detailLoader = await Deno.readTextFile(
);
const detailPage = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte",
"../../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/PageContent.svelte",
import.meta.url,
),
);
@@ -167,7 +167,7 @@ Deno.test("handling summary uses only the current self-report and never Worker o
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/+page.svelte",
"../../../routes/w/[workspaceId]/tickets/PageContent.svelte",
import.meta.url,
),
);
+6 -3
View File
@@ -23,7 +23,10 @@
import '../app.css';
import type { LayoutProps } from './$types';
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
let { children, data }: LayoutProps = $props();
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
sidebar = activeSidebar;
@@ -52,7 +55,7 @@
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
$effect(() => {
const error = data.workspaceCatalogError;
const error = catalog.data?.workspaceCatalogError;
if (error) {
pushWorkspaceAlert('error', error, {
id: 'workspace-catalog-load',
@@ -103,8 +106,8 @@
<GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
workspaces={data.accessibleWorkspaces}
workspaceError={data.workspaceCatalogError}
workspaces={catalog.data?.accessibleWorkspaces ?? null}
workspaceError={catalog.data?.workspaceCatalogError}
/>
</SidebarFrame>
<Bevel as="div" class="app-shell__topbar-bevel" top={false} right={false} left={false} fill>
+16 -15
View File
@@ -1,20 +1,21 @@
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
import { settle } from "#lib/workspace/navigation/deferred.ts";
import type { Load } from "@sveltejs/kit";
export const ssr = false;
export const load = (async ({ fetch }) => {
try {
return {
accessibleWorkspaces: await listWorkspaces(fetch),
workspaceCatalogError: null,
};
} catch (error) {
return {
accessibleWorkspaces: [],
workspaceCatalogError: error instanceof Error
? error.message
: "Unable to load Workspaces",
};
}
}) satisfies Load;
export const load = ({ fetch }: Parameters<Load>[0]) => ({
deferredCatalog: settle((async () => {
try {
return {
accessibleWorkspaces: await listWorkspaces(fetch),
workspaceCatalogError: null,
};
} catch (error) {
return {
accessibleWorkspaces: [],
workspaceCatalogError: error instanceof Error ? error.message : "Unable to load Workspaces",
};
}
})()),
});
+7 -189
View File
@@ -1,192 +1,10 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
createOperationKey,
createWorkspace,
creationErrorMessage,
loadWorkspaceCatalog,
type CreateWorkspaceRequest,
type WorkspaceCatalogItem,
} from "#lib/workspace/api/workspace-catalog.ts";
import "#lib/workspace/styles/workspace-catalog.css";
let { data } = $props();
let workspaces = $state<WorkspaceCatalogItem[]>([]);
let catalogError = $state<string | null>(null);
let refreshing = $state(false);
let creating = $state(false);
let creationError = $state<string | null>(null);
let displayName = $state("");
let includeRepository = $state(false);
let repositoryKey = $state("");
let repositoryUri = $state("");
let defaultRef = $state("");
let lastSubmission = $state<{
signature: string;
request: CreateWorkspaceRequest;
} | null>(null);
$effect(() => {
workspaces = data.workspaces;
catalogError = data.catalogError;
});
async function refreshCatalog() {
refreshing = true;
catalogError = null;
try {
workspaces = await loadWorkspaceCatalog(fetch);
} catch (error) {
catalogError = error instanceof Error ? error.message : String(error);
} finally {
refreshing = false;
}
}
async function submitCreation(event: SubmitEvent) {
event.preventDefault();
if (creating) return;
const normalized = {
display_name: displayName.trim(),
repository: includeRepository ? {
repository_key: repositoryKey.trim(),
uri: repositoryUri.trim(),
default_ref: defaultRef.trim() || null,
} : null,
};
const signature = JSON.stringify(normalized);
const request = lastSubmission?.signature === signature
? lastSubmission.request
: { operation_key: createOperationKey(), ...normalized };
lastSubmission = { signature, request };
creating = true;
creationError = null;
try {
const response = await createWorkspace(fetch, request);
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
} catch (error) {
creationError = creationErrorMessage(error);
} finally {
creating = false;
}
}
function formatUpdated(value: string): string {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp)
? value
: new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Workspaces · Yoi</title>
</svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-catalog-heading">
<div>
<p class="workspace-catalog-eyebrow">Backend</p>
<h1>Workspaces</h1>
<p>Select an accessible team space or create one on this Backend.</p>
</div>
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
</section>
{#if catalogError}
<div class="workspace-catalog-alert" role="alert">
Refresh failed. Existing results were kept. {catalogError}
</div>
{/if}
<section aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Available Workspaces</h2>
{#if workspaces.length === 0}
<div class="workspace-empty-state">
<strong>No accessible Workspaces</strong>
<p>Create the first Workspace if you have Backend permission.</p>
</div>
{:else}
<div class="workspace-catalog-grid">
{#each workspaces as workspace (workspace.workspace_id)}
<a
class="workspace-catalog-card"
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
>
<span class="workspace-card-heading">
<strong>{workspace.display_name}</strong>
<span class:workspace-state-active={workspace.state === "active"}>
{workspace.state}
</span>
</span>
<code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]}
<span class="workspace-repository-summary">
{workspace.repositories[0].repository_key}
<small>
{workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind}
</small>
</span>
{:else if workspace.repository_error}
<small>Repository summary unavailable</small>
{:else}
<small>No repositories</small>
{/if}
<small>Updated {formatUpdated(workspace.updated_at)}</small>
</a>
{/each}
</div>
{/if}
</section>
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
<div>
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<p>You can add repositories later in Workspace Settings.</p>
</div>
<form onsubmit={submitCreation}>
<label>
Workspace display name
<input bind:value={displayName} required autocomplete="off" />
</label>
<label class="workspace-repository-toggle">
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
Add an initial repository (optional)
</label>
{#if includeRepository}
<div id="initial-repository-fields" class="workspace-repository-fields">
<p>
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
</p>
<label>
Initial repository absolute path or URI
<input bind:value={repositoryUri} required autocomplete="off" />
</label>
<div class="workspace-create-row">
<label>
Repository key
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
</label>
<label>
Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
</label>
</div>
</div>
{/if}
{#if creationError}
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
{/if}
<button class="workspace-primary-action" type="submit" disabled={creating}>
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
</button>
</form>
</section>
</div>
<DeferredPage {data} title="Workspaces">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
+3 -2
View File
@@ -1,7 +1,8 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
import { loadWorkspaceCatalog } from "#lib/workspace/api/workspace-catalog.ts";
export const load: PageLoad = async ({ fetch }) => {
export const load = deferPage(async ({ fetch }: Parameters<PageLoad>[0]) => {
try {
return {
workspaces: await loadWorkspaceCatalog(fetch),
@@ -13,4 +14,4 @@ export const load: PageLoad = async ({ fetch }) => {
catalogError: error instanceof Error ? error.message : String(error),
};
}
};
});
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
createOperationKey,
createWorkspace,
creationErrorMessage,
loadWorkspaceCatalog,
type CreateWorkspaceRequest,
type WorkspaceCatalogItem,
} from "#lib/workspace/api/workspace-catalog.ts";
import "#lib/workspace/styles/workspace-catalog.css";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
let { data }: {data: ResolvedPageData<RouteData>} = $props();
let workspaces = $state<WorkspaceCatalogItem[]>([]);
let catalogError = $state<string | null>(null);
let refreshing = $state(false);
let creating = $state(false);
let creationError = $state<string | null>(null);
let displayName = $state("");
let includeRepository = $state(false);
let repositoryKey = $state("");
let repositoryUri = $state("");
let defaultRef = $state("");
let lastSubmission = $state<{
signature: string;
request: CreateWorkspaceRequest;
} | null>(null);
$effect(() => {
workspaces = data.workspaces;
catalogError = data.catalogError;
});
async function refreshCatalog() {
refreshing = true;
catalogError = null;
try {
workspaces = await loadWorkspaceCatalog(fetch);
} catch (error) {
catalogError = error instanceof Error ? error.message : String(error);
} finally {
refreshing = false;
}
}
async function submitCreation(event: SubmitEvent) {
event.preventDefault();
if (creating) return;
const normalized = {
display_name: displayName.trim(),
repository: includeRepository ? {
repository_key: repositoryKey.trim(),
uri: repositoryUri.trim(),
default_ref: defaultRef.trim() || null,
} : null,
};
const signature = JSON.stringify(normalized);
const request = lastSubmission?.signature === signature
? lastSubmission.request
: { operation_key: createOperationKey(), ...normalized };
lastSubmission = { signature, request };
creating = true;
creationError = null;
try {
const response = await createWorkspace(fetch, request);
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
} catch (error) {
creationError = creationErrorMessage(error);
} finally {
creating = false;
}
}
function formatUpdated(value: string): string {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp)
? value
: new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
</script>
<svelte:head>
<title>Workspaces · Yoi</title>
</svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-catalog-heading">
<div>
<p class="workspace-catalog-eyebrow">Backend</p>
<h1>Workspaces</h1>
<p>Select an accessible team space or create one on this Backend.</p>
</div>
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
</section>
{#if catalogError}
<div class="workspace-catalog-alert" role="alert">
Refresh failed. Existing results were kept. {catalogError}
</div>
{/if}
<section aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Available Workspaces</h2>
{#if workspaces.length === 0}
<div class="workspace-empty-state">
<strong>No accessible Workspaces</strong>
<p>Create the first Workspace if you have Backend permission.</p>
</div>
{:else}
<div class="workspace-catalog-grid">
{#each workspaces as workspace (workspace.workspace_id)}
<a
class="workspace-catalog-card"
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
>
<span class="workspace-card-heading">
<strong>{workspace.display_name}</strong>
<span class:workspace-state-active={workspace.state === "active"}>
{workspace.state}
</span>
</span>
<code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]}
<span class="workspace-repository-summary">
{workspace.repositories[0].repository_key}
<small>
{workspace.repositories[0].default_selector ?? "repository default"} ·
{workspace.repositories[0].kind}
</small>
</span>
{:else if workspace.repository_error}
<small>Repository summary unavailable</small>
{:else}
<small>No repositories</small>
{/if}
<small>Updated {formatUpdated(workspace.updated_at)}</small>
</a>
{/each}
</div>
{/if}
</section>
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
<div>
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<p>You can add repositories later in Workspace Settings.</p>
</div>
<form onsubmit={submitCreation}>
<label>
Workspace display name
<input bind:value={displayName} required autocomplete="off" />
</label>
<label class="workspace-repository-toggle">
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
Add an initial repository (optional)
</label>
{#if includeRepository}
<div id="initial-repository-fields" class="workspace-repository-fields">
<p>
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
</p>
<label>
Initial repository absolute path or URI
<input bind:value={repositoryUri} required autocomplete="off" />
</label>
<div class="workspace-create-row">
<label>
Repository key
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
</label>
<label>
Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
</label>
</div>
</div>
{/if}
{#if creationError}
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
{/if}
<button class="workspace-primary-action" type="submit" disabled={creating}>
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
</button>
</form>
</section>
</div>
@@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet, tick } from "svelte";
import { logout } from "#lib/workspace/auth/api.ts";
import { settle } from '#lib/workspace/navigation/deferred.ts';
import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
@@ -12,7 +13,7 @@ const legacyKey = "yoi.sidebar.folded.v1";
const props = {
params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
data: { deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: null }) },
};
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
const folded = () => frame().classList.contains("folded");
@@ -35,8 +36,7 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
props: {
...props,
data: {
accessibleWorkspaces: [],
workspaceCatalogError: "Workspace catalog request failed",
deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: "Workspace catalog request failed" }),
},
},
});
@@ -24,7 +24,13 @@
import '#lib/workspace/styles/workers.css';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
import { invalidateAll } from '$app/navigation';
let { data, children, params }: LayoutProps = $props();
const loaded = observeDeferred(() => data.deferredWorkspace, () => params.workspaceId);
const workspace = $derived(loaded.data?.workspace ?? null);
const workspaceError = $derived(loaded.failure?.message ?? null);
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
@@ -35,7 +41,7 @@
registerSidebar: sidebarContentOverrides.register,
});
const workspaceId = $derived(data.workspace?.workspace_id ?? page.params.workspaceId ?? '');
const workspaceId = $derived(params.workspaceId);
const ownsCurrentRoute = $derived(
workspaceId !== '' && ownsRoutePath(workspaceRoute(workspaceId), page.url.pathname),
);
@@ -48,7 +54,7 @@
}
$effect(() => {
const nextWorkspaceId = data.workspace?.workspace_id ?? null;
const nextWorkspaceId = workspaceId || null;
if (nextWorkspaceId === activeWorkspaceId) return;
const previousWorkspaceId = activeWorkspaceId;
activeWorkspaceId = nextWorkspaceId;
@@ -62,16 +68,16 @@
{#snippet workspaceHeader()}
<WorkspaceBreadcrumbs
workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''}
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
workspaceId={workspaceId}
{workspace}
{workspaceError}
/>
{/snippet}
{#snippet workspaceSidebar()}
<WorkspaceSidebar
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
{workspace}
{workspaceError}
currentPath={page.url.pathname}
content={sidebarContent}
/>
@@ -84,4 +90,22 @@
active={ownsCurrentRoute}
/>
{@render children()}
<svelte:head>
{#if workspaceError}<title>Workspace · Unavailable</title>
{:else if !workspace}<title>Workspace · Loading</title>{/if}
</svelte:head>
{#if workspaceError}
<section class="workspace-page">
<h1>Workspace unavailable</h1>
<p role="alert">{workspaceError}</p>
<button type="button" class="workspace-secondary-button" onclick={() => invalidateAll()}>Retry</button>
</section>
{:else if workspace}
{@render children()}
{:else}
<section class="workspace-page" aria-busy="true">
<h1>Workspace</h1>
<p role="status">Loading workspace…</p>
</section>
{/if}
@@ -2,10 +2,15 @@ import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { parseWorkspaceResponse } from "#lib/workspace/api/workspace-model.ts";
import { settle } from "#lib/workspace/navigation/deferred.ts";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
export const load = ({ fetch, params }: Parameters<LayoutLoad>[0]) => {
const workspaceId = params.workspaceId;
return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) };
};
async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspaceId: string) {
const [workspaceResult, repositoryResult] = await Promise.all([
loadJson(
fetch,
@@ -1,53 +1,10 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import DashboardSection from '#lib/workspace/home/DashboardSection.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
async function refresh() {
if (refreshing) return;
refreshing = true;
refreshError = null;
try {
await invalidate('workspace:home');
await Promise.all(Object.values(data.dashboard));
} catch {
refreshError = 'Could not refresh. Try again.';
} finally {
refreshing = false;
}
}
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
<div class="workspace-home">
<header class="home-header">
<h1>Activity</h1>
<button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
</header>
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
<div class="home-columns">
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
<DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} />
<DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} />
</div>
</div>
<style>
.workspace-home { container-type: inline-size; min-width: 0; }
.home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:active:not(:disabled) { background: var(--interactive-selected); }
button:disabled { opacity: 0.6; cursor: wait; }
.home-columns { display: grid; gap: var(--space-6); align-items: start; }
.home-error { color: var(--danger); overflow-wrap: anywhere; }
@container (min-width: 52rem) {
.home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
</style>
<DeferredPage {data} title="Activity">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -0,0 +1,55 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import DashboardSection from '#lib/workspace/home/DashboardSection.svelte';
import type { PageData as RouteData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
async function refresh() {
if (refreshing) return;
refreshing = true;
refreshError = null;
try {
await invalidate('workspace:home');
await Promise.all(Object.values(data.dashboard));
} catch {
refreshError = 'Could not refresh. Try again.';
} finally {
refreshing = false;
}
}
</script>
<svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
<div class="workspace-home">
<header class="home-header">
<h1>Activity</h1>
<button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
</header>
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
<div class="home-columns">
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
<DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} />
<DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} />
</div>
</div>
<style>
.workspace-home { container-type: inline-size; min-width: 0; }
.home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:active:not(:disabled) { background: var(--interactive-selected); }
button:disabled { opacity: 0.6; cursor: wait; }
.home-columns { display: grid; gap: var(--space-6); align-items: start; }
.home-error { color: var(--danger); overflow-wrap: anywhere; }
@container (min-width: 52rem) {
.home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
</style>
@@ -9,7 +9,7 @@ import {
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import { invalidate } from "$app/navigation";
import Home from "./+page.svelte";
import Home from "./PageContent.svelte";
import { load } from "./+page";
import {
type Dashboard,
@@ -44,7 +44,7 @@ function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data {
}
function mount(value = data()) {
return render(Home, {
props: { params: { workspaceId: value.workspaceId }, data: value },
props: { data: value },
});
}
afterEach(() => {
@@ -1,573 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
createSubjektivSubject,
SubjektivSubjectCreateError,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
}
function pageHref(cursor?: string | null): string {
const path = workspaceRoute(data.workspaceId, '/memory');
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
roleInput?.focus();
}
async function cancelCreate(): Promise<void> {
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
createButton?.focus();
}
async function submitCreate(): Promise<void> {
if (submitting || createdSubject) return;
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
} catch {
createError = 'Subject created, but navigation failed. Open the created Subject below.';
}
} catch (error) {
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
createOutcomeUnknown = error.kind === 'unknown_outcome';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid || behaviorInvalid) {
await tick();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
createError = 'The request outcome is unknown. A Subject may have been created.';
}
} finally {
submitting = false;
}
}
function createFormKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && !submitting) {
event.preventDefault();
void cancelCreate();
}
}
</script>
<svelte:head>
<title>Subjects · Memory · Yoi Workspace</title>
<meta name="description" content="Workspace Memory subjects" />
</svelte:head>
<section class="memory-page memory-subject-index" aria-labelledby="memory-subjects-heading" data-memory-view="subjects">
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory</p>
<h1 id="memory-subjects-heading">Subjects</h1>
<p>Durable context, organized by explicit subject.</p>
</div>
<div class="memory-header-actions">
{#if data.subjects.data}
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
{/if}
<button
bind:this={createButton}
type="button"
class="subject-create-trigger"
aria-expanded={createExpanded}
aria-controls="subject-create-panel"
onclick={() => void openCreateForm()}
>New Subject</button>
</div>
</header>
{#if createExpanded}
<form
id="subject-create-panel"
class="subject-create-panel"
aria-labelledby="subject-create-heading"
aria-busy={submitting}
onsubmit={(event) => { event.preventDefault(); void submitCreate(); }}
>
<div class="subject-create-copy">
<h2 id="subject-create-heading">Create Subject</h2>
<p id="subject-role-help">Role describes the Subject’s durable identity. The service assigns its ID; creating it does not start or attach a Worker.</p>
</div>
<label class="subject-role-field">
<span>Role</span>
<input
bind:this={roleInput}
bind:value={role}
type="text"
autocomplete="off"
required
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/>
</label>
<label class="subject-role-field">
<span>Behavior <small>Optional, user-managed</small></span>
<textarea
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
></textarea>
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
</label>
{#if createError}
<div
id="subject-create-error"
class:unknown-outcome={createOutcomeUnknown}
class="subject-create-error"
role="alert"
>
<p>{createError}</p>
{#if createOutcomeUnknown}
<p><a href={pageHref()}>Check the first Subjects page before retrying.</a></p>
{:else if createdSubject}
<p><a href={subjectHref(createdSubject.id)}>Open {createdSubject.id}</a></p>
{/if}
</div>
{/if}
<div class="subject-create-actions">
<button type="submit" disabled={submitting || createdSubject !== null}>
{submitting ? 'Creating…' : 'Create Subject'}
</button>
<button type="button" class="subject-create-cancel" disabled={submitting} onclick={() => void cancelCreate()}>Cancel</button>
</div>
</form>
{/if}
{#if data.subjects.data}
{#if subjects.length === 0}
<div class="memory-state" role="status" data-memory-state="empty">
<strong>No Memory subjects.</strong>
<p>Subjects will appear here after they are created for this Workspace.</p>
{#if data.cursor}<p><a href={pageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="subject-list" aria-label="Memory subjects">
{#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}>
<div class="subject-copy">
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
<h2>{subject.role}</h2>
<code title={subject.id}>{subject.id}</code>
</div>
<dl class="subject-meta">
<div>
<dt>Worker connection</dt>
<dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
</div>
<nav class="memory-pagination" aria-label="Subject pages">
{#if data.cursor}<a href={pageHref()}>First page</a>{/if}
{#if data.subjects.data.has_more && data.subjects.data.next_cursor}
<a href={pageHref(data.subjects.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.subjects.error}
<div class="memory-state is-error" role="alert" data-memory-state="error">
<strong>Subjects unavailable.</strong>
<p>{data.subjects.error}</p>
</div>
{:else}
<div class="memory-state" role="status" data-memory-state="unavailable">
<p>Subject data is unavailable.</p>
</div>
{/if}
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-5);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header h1,
.subject-copy h2 {
margin: 0;
color: var(--text-strong);
}
.memory-page-header h1 {
font-size: var(--font-size-title);
}
.memory-page-header p:not(.memory-eyebrow) {
max-width: 46rem;
margin: var(--space-2) 0 0;
color: var(--text-muted);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.memory-count {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-header-actions,
.subject-create-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.memory-header-actions {
justify-content: flex-end;
}
.subject-create-trigger,
.subject-create-actions button {
min-height: 2.25rem;
border: 0;
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-4);
background: var(--accent);
color: var(--bg);
font-weight: 700;
cursor: pointer;
}
.subject-create-trigger:hover,
.subject-create-trigger:focus-visible,
.subject-create-actions button:hover:not(:disabled),
.subject-create-actions button:focus-visible:not(:disabled) {
filter: brightness(1.08);
}
.subject-create-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.subject-create-actions .subject-create-cancel {
border: 1px solid var(--line);
background: transparent;
color: var(--text-strong);
}
.subject-create-actions {
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-create-copy {
grid-column: 1;
grid-row: 1 / span 4;
align-self: start;
}
.subject-create-copy h2,
.subject-create-copy p,
.subject-create-error p {
margin: 0;
}
.subject-create-copy p,
.subject-create-error {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-role-field {
display: grid;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 700;
}
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--bg);
color: var(--text-strong);
}
.subject-role-field textarea {
min-height: 8rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.subject-role-field input[aria-invalid='true'],
.subject-role-field textarea[aria-invalid='true'] {
border-color: var(--danger);
}
.subject-create-error {
grid-column: 2 / -1;
color: var(--danger);
}
.subject-create-error a {
color: inherit;
}
.subject-create-error.unknown-outcome {
color: var(--warning);
}
.subject-list {
display: grid;
border-top: 1px solid var(--line);
}
.subject-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.8fr);
gap: var(--space-5);
min-width: 0;
padding: var(--space-4);
border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.subject-row:hover,
.subject-row:focus-visible {
background: var(--interactive-hover);
}
.subject-copy {
display: grid;
justify-items: start;
gap: var(--space-1);
min-width: 0;
}
.subject-copy h2 {
font-size: var(--font-size-body);
overflow-wrap: anywhere;
}
.subject-copy code {
display: block;
max-width: 100%;
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.memory-state-pill {
color: var(--success);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.memory-state-pill.is-retired {
color: var(--text-faint);
}
.subject-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
align-self: center;
}
.subject-meta div {
display: block;
min-width: 0;
}
.subject-meta dt {
white-space: normal;
}
.subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state {
padding: var(--space-5) 0;
color: var(--text-muted);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.subject-create-panel {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
grid-row: auto;
}
.subject-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
}
@media (max-width: 600px) {
.memory-page-header {
display: grid;
gap: var(--space-2);
}
.memory-header-actions {
justify-content: flex-start;
}
.subject-create-panel {
padding: var(--space-3);
}
.subject-row {
padding-inline: 0;
}
.subject-meta {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<DeferredPage {data} title="Subjects">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -3,6 +3,7 @@ import {
MEMORY_API_LOAD_POLICY,
parseSubjektivSubjectListResponse,
} from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
const MAX_CURSOR_BYTES = 16_384;
@@ -14,7 +15,7 @@ function boundedCursor(value: string | null): string | null {
: null;
}
export const load: PageLoad = async ({ fetch, params, url }) => {
export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const cursor = boundedCursor(url.searchParams.get("cursor"));
const query = new URLSearchParams({ limit: "100" });
if (cursor) query.set("cursor", cursor);
@@ -29,4 +30,4 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
MEMORY_API_LOAD_POLICY,
),
};
};
});
@@ -0,0 +1,576 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
createSubjektivSubject,
SubjektivSubjectCreateError,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
}
function pageHref(cursor?: string | null): string {
const path = workspaceRoute(data.workspaceId, '/memory');
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
roleInput?.focus();
}
async function cancelCreate(): Promise<void> {
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
createButton?.focus();
}
async function submitCreate(): Promise<void> {
if (submitting || createdSubject) return;
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
} catch {
createError = 'Subject created, but navigation failed. Open the created Subject below.';
}
} catch (error) {
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
createOutcomeUnknown = error.kind === 'unknown_outcome';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid || behaviorInvalid) {
await tick();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
createError = 'The request outcome is unknown. A Subject may have been created.';
}
} finally {
submitting = false;
}
}
function createFormKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && !submitting) {
event.preventDefault();
void cancelCreate();
}
}
</script>
<svelte:head>
<title>Subjects · Memory · Yoi Workspace</title>
<meta name="description" content="Workspace Memory subjects" />
</svelte:head>
<section class="memory-page memory-subject-index" aria-labelledby="memory-subjects-heading" data-memory-view="subjects">
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory</p>
<h1 id="memory-subjects-heading">Subjects</h1>
<p>Durable context, organized by explicit subject.</p>
</div>
<div class="memory-header-actions">
{#if data.subjects.data}
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
{/if}
<button
bind:this={createButton}
type="button"
class="subject-create-trigger"
aria-expanded={createExpanded}
aria-controls="subject-create-panel"
onclick={() => void openCreateForm()}
>New Subject</button>
</div>
</header>
{#if createExpanded}
<form
id="subject-create-panel"
class="subject-create-panel"
aria-labelledby="subject-create-heading"
aria-busy={submitting}
onsubmit={(event) => { event.preventDefault(); void submitCreate(); }}
>
<div class="subject-create-copy">
<h2 id="subject-create-heading">Create Subject</h2>
<p id="subject-role-help">Role describes the Subject’s durable identity. The service assigns its ID; creating it does not start or attach a Worker.</p>
</div>
<label class="subject-role-field">
<span>Role</span>
<input
bind:this={roleInput}
bind:value={role}
type="text"
autocomplete="off"
required
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/>
</label>
<label class="subject-role-field">
<span>Behavior <small>Optional, user-managed</small></span>
<textarea
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
></textarea>
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
</label>
{#if createError}
<div
id="subject-create-error"
class:unknown-outcome={createOutcomeUnknown}
class="subject-create-error"
role="alert"
>
<p>{createError}</p>
{#if createOutcomeUnknown}
<p><a href={pageHref()}>Check the first Subjects page before retrying.</a></p>
{:else if createdSubject}
<p><a href={subjectHref(createdSubject.id)}>Open {createdSubject.id}</a></p>
{/if}
</div>
{/if}
<div class="subject-create-actions">
<button type="submit" disabled={submitting || createdSubject !== null}>
{submitting ? 'Creating…' : 'Create Subject'}
</button>
<button type="button" class="subject-create-cancel" disabled={submitting} onclick={() => void cancelCreate()}>Cancel</button>
</div>
</form>
{/if}
{#if data.subjects.data}
{#if subjects.length === 0}
<div class="memory-state" role="status" data-memory-state="empty">
<strong>No Memory subjects.</strong>
<p>Subjects will appear here after they are created for this Workspace.</p>
{#if data.cursor}<p><a href={pageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="subject-list" aria-label="Memory subjects">
{#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}>
<div class="subject-copy">
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
<h2>{subject.role}</h2>
<code title={subject.id}>{subject.id}</code>
</div>
<dl class="subject-meta">
<div>
<dt>Worker connection</dt>
<dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
</div>
<nav class="memory-pagination" aria-label="Subject pages">
{#if data.cursor}<a href={pageHref()}>First page</a>{/if}
{#if data.subjects.data.has_more && data.subjects.data.next_cursor}
<a href={pageHref(data.subjects.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.subjects.error}
<div class="memory-state is-error" role="alert" data-memory-state="error">
<strong>Subjects unavailable.</strong>
<p>{data.subjects.error}</p>
</div>
{:else}
<div class="memory-state" role="status" data-memory-state="unavailable">
<p>Subject data is unavailable.</p>
</div>
{/if}
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-5);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header h1,
.subject-copy h2 {
margin: 0;
color: var(--text-strong);
}
.memory-page-header h1 {
font-size: var(--font-size-title);
}
.memory-page-header p:not(.memory-eyebrow) {
max-width: 46rem;
margin: var(--space-2) 0 0;
color: var(--text-muted);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.memory-count {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-header-actions,
.subject-create-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.memory-header-actions {
justify-content: flex-end;
}
.subject-create-trigger,
.subject-create-actions button {
min-height: 2.25rem;
border: 0;
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-4);
background: var(--accent);
color: var(--bg);
font-weight: 700;
cursor: pointer;
}
.subject-create-trigger:hover,
.subject-create-trigger:focus-visible,
.subject-create-actions button:hover:not(:disabled),
.subject-create-actions button:focus-visible:not(:disabled) {
filter: brightness(1.08);
}
.subject-create-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.subject-create-actions .subject-create-cancel {
border: 1px solid var(--line);
background: transparent;
color: var(--text-strong);
}
.subject-create-actions {
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-create-copy {
grid-column: 1;
grid-row: 1 / span 4;
align-self: start;
}
.subject-create-copy h2,
.subject-create-copy p,
.subject-create-error p {
margin: 0;
}
.subject-create-copy p,
.subject-create-error {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-role-field {
display: grid;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 700;
}
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: var(--space-2) var(--space-3);
background: var(--bg);
color: var(--text-strong);
}
.subject-role-field textarea {
min-height: 8rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.subject-role-field input[aria-invalid='true'],
.subject-role-field textarea[aria-invalid='true'] {
border-color: var(--danger);
}
.subject-create-error {
grid-column: 2 / -1;
color: var(--danger);
}
.subject-create-error a {
color: inherit;
}
.subject-create-error.unknown-outcome {
color: var(--warning);
}
.subject-list {
display: grid;
border-top: 1px solid var(--line);
}
.subject-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.8fr);
gap: var(--space-5);
min-width: 0;
padding: var(--space-4);
border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.subject-row:hover,
.subject-row:focus-visible {
background: var(--interactive-hover);
}
.subject-copy {
display: grid;
justify-items: start;
gap: var(--space-1);
min-width: 0;
}
.subject-copy h2 {
font-size: var(--font-size-body);
overflow-wrap: anywhere;
}
.subject-copy code {
display: block;
max-width: 100%;
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.memory-state-pill {
color: var(--success);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.memory-state-pill.is-retired {
color: var(--text-faint);
}
.subject-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
align-self: center;
}
.subject-meta div {
display: block;
min-width: 0;
}
.subject-meta dt {
white-space: normal;
}
.subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state {
padding: var(--space-5) 0;
color: var(--text-muted);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state p {
margin: var(--space-1) 0 0;
color: var(--text-muted);
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.subject-create-panel {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
grid-row: auto;
}
.subject-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
}
@media (max-width: 600px) {
.memory-page-header {
display: grid;
gap: var(--space-2);
}
.memory-header-actions {
justify-content: flex-start;
}
.subject-create-panel {
padding: var(--space-3);
}
.subject-row {
padding-inline: 0;
}
.subject-meta {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -1,799 +1,13 @@
<script lang="ts">
// identity、current Worker、Committed Memory、生成contextの利用可否を先に示す。
// current WorkerはBackend singleton projectionであり、過去のSession帰属から推測しない。
// behaviorは利用者が管理する本文、surfaceはMemoryからの生成物として分ける。
// ready-empty/ungenerated/stale/failedとrequest failureを区別し、surfaceの現在性を
// 実行中Workerの履歴へ反映済みであることの証明には使わない。診断値は詳細へ分離する。
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
// SvelteKit reuses this page across Subject and Workspace routes. A new
// loader snapshot owns a new editor and fences all previous mutations.
$effect(() => {
const workspaceId = data.workspaceId;
const subjectId = data.subjectId;
const loadedSubject = data.subject.data;
untrack(() => {
routeGeneration += 1;
subjectWorkspaceId = workspaceId;
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
behaviorDraft = subject?.behavior_md ?? '';
editingBehavior = false;
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
});
});
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
const generation = routeGeneration;
const workspaceId = subjectWorkspaceId;
const subjectId = subject.id;
const request = {
expected_behavior_md: subject.behavior_md,
behavior_md: behaviorDraft,
};
const isCurrent = () => generation === routeGeneration &&
data.workspaceId === workspaceId && data.subjectId === subjectId;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
if (!isCurrent()) return;
subject = updated;
behaviorDraft = updated.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
if (!isCurrent()) return;
behaviorError = error instanceof SubjektivSubjectCreateError
? (error.status === 401 || error.status === 403
? 'You do not have permission to edit this Subject.'
: error.message)
: 'The save outcome is unknown. Reload the Subject before retrying.';
} finally {
if (isCurrent()) behaviorSaving = false;
}
}
function beginBehaviorEdit(): void {
if (!subject) return;
behaviorDraft = subject.behavior_md;
behaviorError = null;
behaviorSaveConfirmed = false;
editingBehavior = true;
}
function cancelBehaviorEdit(): void {
behaviorDraft = subject?.behavior_md ?? '';
behaviorError = null;
editingBehavior = false;
}
const surface = $derived(data.surface.data);
const snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
function subjectsHref(): string {
return workspaceRoute(data.workspaceId, '/memory');
}
function memoryHref(memoryId: string, changeId?: string): string {
const path = workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
);
return changeId ? `${path}?change_id=${encodeURIComponent(changeId)}` : path;
}
function memoryPageHref(cursor?: string | null): string {
const path = workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}`,
);
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function stateLabel(state: SubjektivMemoryState): string {
return state[0].toUpperCase() + state.slice(1);
}
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
if (availability === 'ungenerated') return 'Not generated';
if (availability === 'stale') return 'Needs refresh';
return 'Generation failed';
}
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim()
? 'Current generated context is available.'
: 'A current empty surface exists; there is no generated Memory context to show.';
}
function subjectStateLabel(state: 'active' | 'retired'): string {
return state === 'active' ? 'Available' : 'Retired';
}
</script>
<svelte:head>
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<meta name="description" content="Subject resident surface and current committed Memories" />
</svelte:head>
<section class="memory-page memory-subject-page" aria-labelledby="subject-heading" data-memory-view="subject" data-memory-state={surface?.availability ?? (data.surface.error ? 'error' : 'unavailable')}>
<a class="memory-back-link" href={subjectsHref()}>← All subjects</a>
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory subject</p>
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div>
<span class="read-only-label">Behavior editable</span>
</header>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
{#if data.memories.data}
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
{:else}
<strong>Unavailable</strong>
<p>The current Memory list could not be read.</p>
{/if}
</div>
<div>
<span>Generated Memory context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
{:else}
<strong>Unavailable</strong>
<p>The resident context status could not be read.</p>
{/if}
</div>
</section>
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div>
<dt>Memory fingerprint</dt>
<dd>{subject.memory_fingerprint}<small>Content condition for current committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</details>
{:else if data.subject.error}
<div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong>
<p>{data.subject.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/if}
{#if subject}
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">User-managed context</p>
<h2 id="subject-behavior-heading">Behavior</h2>
</div>
{#if !editingBehavior}
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
{/if}
</header>
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest text before later model requests; this page does not report that application.</p>
{#if editingBehavior}
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
<label>
<span class="sr-only">Subject behavior</span>
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
</label>
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
<div class="behavior-actions">
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
</div>
</form>
{:else if subject.behavior_md.length > 0}
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
{:else}
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
{/if}
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed. This page does not confirm application by a connected Worker.</p>{/if}
</section>
{/if}
<section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident context</h2>
</div>
{#if surface}
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if}
</header>
{#if surface?.availability === 'ready' && snapshot}
<div class="surface-meta">
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
</div>
{#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident context is current but empty.</strong>
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div>
{:else}
<article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} />
</article>
{/if}
<details class="surface-details">
<summary>Surface sources and diagnostics</summary>
<dl>
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div>
<dt>Generated from Memory fingerprint</dt>
<dd>{snapshot.built_from_memory_fingerprint}<small>The content condition used to generate this surface.</small></dd>
</div>
<div>
<dt>Referenced Memory changes</dt>
<dd>
{#if snapshot.memory_refs.length === 0}
None
{:else}
<ul class="reference-list">
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.change_id}`)}
<li><a href={memoryHref(reference.memory_id, reference.change_id)}><code>{reference.memory_id}</code> · change {reference.change_id}</a></li>
{/each}
</ul>
{/if}
</dd>
</div>
</dl>
</details>
{:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status">
<strong>Resident context has not been generated.</strong>
<p>Committed Memories, if any, remain available below.</p>
</div>
{:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status">
<strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div>
{:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert">
<strong>Resident context generation failed.</strong>
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div>
{:else if data.surface.error}
<div class="memory-state is-error" role="alert">
<strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
{/if}
</section>
<section class="memories-section" aria-labelledby="current-memories-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2>
</div>
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
{#if data.memories.data}
{#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty>
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="memory-list" aria-label="Current committed Memories">
{#each memories as memory (memory.id)}
<a class="memory-row" href={memoryHref(memory.id)}>
<div class="memory-row-heading">
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{stateLabel(memory.state)}</span>
<span class="memory-kind">{kindLabel(memory.kind)}</span>
</div>
<h3>{memory.claim}</h3>
<p>{memory.excerpt || 'No excerpt.'}</p>
</div>
<dl>
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Memory change</dt><dd>{memory.change_id}</dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
</div>
<nav class="memory-pagination" aria-label="Current Memory pages">
{#if data.cursor}<a href={memoryPageHref()}>First page</a>{/if}
{#if data.memories.data.has_more && data.memories.data.next_cursor}
<a href={memoryPageHref(data.memories.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.memories.error}
<div class="memory-state is-error" role="alert">
<strong>Current Memories unavailable.</strong>
<p>{data.memories.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Current Memory data is unavailable.</p></div>
{/if}
</section>
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-back-link {
width: fit-content;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-page-header,
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
}
.memory-page-header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header h1,
.section-heading h2,
.memory-row h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.memory-page-header h1 {
font-size: var(--font-size-title);
}
.section-heading h2 {
font-size: var(--font-size-body);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.subject-id {
display: block;
max-width: min(60rem, 100%);
margin-top: var(--space-2);
color: var(--text-muted);
overflow-wrap: anywhere;
}
.read-only-label,
.memory-count {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-overview > div {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
}
.subject-overview span,
.subject-overview p,
.subject-technical-details summary,
.subject-technical-details small,
.surface-details small {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-overview strong {
color: var(--text-strong);
overflow-wrap: anywhere;
}
.subject-overview p {
margin: 0;
overflow-wrap: anywhere;
}
.subject-technical-details {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.subject-technical-details summary {
width: fit-content;
cursor: pointer;
font-weight: 700;
}
.subject-technical-details dl,
.surface-details dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-3);
}
.subject-technical-details dl > div,
.surface-details dl > div {
display: block;
min-width: 0;
}
.subject-technical-details dd,
.surface-details dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.subject-technical-details small,
.surface-details small {
display: block;
margin-top: var(--space-1);
line-height: 1.4;
}
.subject-state,
.availability,
.memory-pill,
.memory-kind {
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.subject-state,
.availability.is-ready,
.memory-pill.is-active {
color: var(--success);
}
.subject-state.is-retired,
.availability.is-ungenerated,
.memory-pill.is-retracted {
color: var(--text-faint);
}
.availability.is-stale,
.memory-pill.is-resolved {
color: var(--warning);
}
.availability.is-failed {
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
gap: var(--space-4);
min-width: 0;
padding-top: var(--space-3);
}
.behavior-help,
.behavior-saved,
.behavior-error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.behavior-error { color: var(--danger); }
.behavior-saved { color: var(--success); }
.behavior-form,
.behavior-form label {
display: grid;
gap: var(--space-3);
}
.behavior-form textarea {
width: 100%;
min-height: 10rem;
padding: var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.behavior-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.behavior-edit,
.behavior-actions button {
padding: var(--space-2) var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
cursor: pointer;
}
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
.behavior-document pre {
margin: 0;
padding: var(--space-4);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.surface-meta,
.pill-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.surface-document {
min-width: 0;
}
.surface-details {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.surface-details summary {
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-size-compact);
font-weight: 700;
}
.surface-details dl {
margin-top: var(--space-3);
}
.surface-details dd,
.reference-list code {
overflow-wrap: anywhere;
}
.reference-list {
display: grid;
gap: var(--space-1);
margin: 0;
padding-left: 1.25rem;
}
.memory-list {
display: grid;
border-top: 1px solid var(--line);
}
.memory-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(19rem, 0.8fr);
gap: var(--space-5);
min-width: 0;
padding: var(--space-4);
border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.memory-row:hover,
.memory-row:focus-visible {
background: var(--interactive-hover);
}
.memory-row-heading {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.memory-row h3 {
font-size: var(--font-size-body);
}
.memory-row p {
margin: 0;
color: var(--text-muted);
}
.memory-kind {
color: var(--accent);
}
.memory-row dl {
align-content: center;
}
.memory-row dl > div {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.memory-row dd,
.memory-row code {
min-width: 0;
overflow-wrap: anywhere;
}
.memory-state {
padding: var(--space-4) 0;
color: var(--text-muted);
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-state p {
margin: var(--space-1) 0 0;
}
.memory-state.is-warning,
.memory-state.is-warning strong {
color: var(--warning);
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
}
@media (max-width: 600px) {
.memory-page-header,
.section-heading {
display: grid;
gap: var(--space-2);
}
.subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr);
}
.subject-overview {
padding: var(--space-3);
}
.memory-row {
padding-inline: 0;
}
.memory-row dl > div,
.surface-details dl > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
}
}
</style>
<DeferredPage {data} title="Subject">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -9,6 +9,7 @@ import {
parseSubjektivResidentSurfaceResponse,
parseSubjektivSubjectResponse,
} from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
const MAX_CURSOR_BYTES = 16_384;
@@ -20,7 +21,7 @@ function boundedCursor(value: string | null): string | null {
: null;
}
export const load: PageLoad = async ({ fetch, params, url }) => {
export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const subjectId = params.subjectId;
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
const cursor = boundedCursor(url.searchParams.get("cursor"));
@@ -60,4 +61,4 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
surface,
memories,
};
};
});
@@ -0,0 +1,810 @@
<script lang="ts">
// identity、current Worker、Committed Memory、生成contextの利用可否を先に示す。
// current WorkerはBackend singleton projectionであり、過去のSession帰属から推測しない。
// behaviorは利用者が管理する本文、surfaceはMemoryからの生成物として分ける。
// ready-empty/ungenerated/stale/failedとrequest failureを区別し、surfaceの現在性を
// 実行中Workerの履歴へ反映済みであることの証明には使わない。診断値は詳細へ分離する。
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
let behaviorExpected = untrack(() => subject?.behavior_md ?? '');
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
let editorSubjectId = untrack(() => data.subjectId);
// Same-identity snapshots update the projection, not the editor's observed CAS text.
// Only a Subject or Workspace switch owns a new editor and fences prior mutations.
$effect(() => {
const workspaceId = data.workspaceId;
const subjectId = data.subjectId;
const loadedSubject = data.subject.data;
untrack(() => {
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
if (workspaceId === subjectWorkspaceId && subjectId === editorSubjectId) return;
routeGeneration += 1;
subjectWorkspaceId = workspaceId;
editorSubjectId = subjectId;
behaviorDraft = subject?.behavior_md ?? '';
behaviorExpected = subject?.behavior_md ?? '';
editingBehavior = false;
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
});
});
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
const generation = routeGeneration;
const workspaceId = subjectWorkspaceId;
const subjectId = subject.id;
const request = {
expected_behavior_md: behaviorExpected,
behavior_md: behaviorDraft,
};
const isCurrent = () => generation === routeGeneration &&
data.workspaceId === workspaceId && data.subjectId === subjectId;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
if (!isCurrent()) return;
subject = updated;
behaviorDraft = updated.behavior_md;
behaviorExpected = updated.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
if (!isCurrent()) return;
behaviorError = error instanceof SubjektivSubjectCreateError
? (error.status === 401 || error.status === 403
? 'You do not have permission to edit this Subject.'
: error.message)
: 'The save outcome is unknown. Reload the Subject before retrying.';
} finally {
if (isCurrent()) behaviorSaving = false;
}
}
function beginBehaviorEdit(): void {
if (!subject) return;
behaviorDraft = subject.behavior_md;
behaviorExpected = subject.behavior_md;
behaviorError = null;
behaviorSaveConfirmed = false;
editingBehavior = true;
}
function cancelBehaviorEdit(): void {
behaviorDraft = subject?.behavior_md ?? '';
behaviorExpected = subject?.behavior_md ?? '';
behaviorError = null;
editingBehavior = false;
}
const surface = $derived(data.surface.data);
const snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
function subjectsHref(): string {
return workspaceRoute(data.workspaceId, '/memory');
}
function memoryHref(memoryId: string, changeId?: string): string {
const path = workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
);
return changeId ? `${path}?change_id=${encodeURIComponent(changeId)}` : path;
}
function memoryPageHref(cursor?: string | null): string {
const path = workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}`,
);
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function stateLabel(state: SubjektivMemoryState): string {
return state[0].toUpperCase() + state.slice(1);
}
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
if (availability === 'ungenerated') return 'Not generated';
if (availability === 'stale') return 'Needs refresh';
return 'Generation failed';
}
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim()
? 'Current generated context is available.'
: 'A current empty surface exists; there is no generated Memory context to show.';
}
function subjectStateLabel(state: 'active' | 'retired'): string {
return state === 'active' ? 'Available' : 'Retired';
}
</script>
<svelte:head>
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<meta name="description" content="Subject resident surface and current committed Memories" />
</svelte:head>
<section class="memory-page memory-subject-page" aria-labelledby="subject-heading" data-memory-view="subject" data-memory-state={surface?.availability ?? (data.surface.error ? 'error' : 'unavailable')}>
<a class="memory-back-link" href={subjectsHref()}>← All subjects</a>
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory subject</p>
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div>
<span class="read-only-label">Behavior editable</span>
</header>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
{#if data.memories.data}
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
{:else}
<strong>Unavailable</strong>
<p>The current Memory list could not be read.</p>
{/if}
</div>
<div>
<span>Generated Memory context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
{:else}
<strong>Unavailable</strong>
<p>The resident context status could not be read.</p>
{/if}
</div>
</section>
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div>
<dt>Memory fingerprint</dt>
<dd>{subject.memory_fingerprint}<small>Content condition for current committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</details>
{:else if data.subject.error}
<div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong>
<p>{data.subject.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/if}
{#if subject}
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">User-managed context</p>
<h2 id="subject-behavior-heading">Behavior</h2>
</div>
{#if !editingBehavior}
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
{/if}
</header>
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest text before later model requests; this page does not report that application.</p>
{#if editingBehavior}
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
<label>
<span class="sr-only">Subject behavior</span>
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
</label>
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
<div class="behavior-actions">
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
</div>
</form>
{:else if subject.behavior_md.length > 0}
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
{:else}
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
{/if}
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed. This page does not confirm application by a connected Worker.</p>{/if}
</section>
{/if}
<section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident context</h2>
</div>
{#if surface}
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if}
</header>
{#if surface?.availability === 'ready' && snapshot}
<div class="surface-meta">
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
</div>
{#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident context is current but empty.</strong>
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div>
{:else}
<article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} />
</article>
{/if}
<details class="surface-details">
<summary>Surface sources and diagnostics</summary>
<dl>
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div>
<dt>Generated from Memory fingerprint</dt>
<dd>{snapshot.built_from_memory_fingerprint}<small>The content condition used to generate this surface.</small></dd>
</div>
<div>
<dt>Referenced Memory changes</dt>
<dd>
{#if snapshot.memory_refs.length === 0}
None
{:else}
<ul class="reference-list">
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.change_id}`)}
<li><a href={memoryHref(reference.memory_id, reference.change_id)}><code>{reference.memory_id}</code> · change {reference.change_id}</a></li>
{/each}
</ul>
{/if}
</dd>
</div>
</dl>
</details>
{:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status">
<strong>Resident context has not been generated.</strong>
<p>Committed Memories, if any, remain available below.</p>
</div>
{:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status">
<strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div>
{:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert">
<strong>Resident context generation failed.</strong>
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div>
{:else if data.surface.error}
<div class="memory-state is-error" role="alert">
<strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
{/if}
</section>
<section class="memories-section" aria-labelledby="current-memories-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2>
</div>
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
{#if data.memories.data}
{#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty>
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<div class="memory-list" aria-label="Current committed Memories">
{#each memories as memory (memory.id)}
<a class="memory-row" href={memoryHref(memory.id)}>
<div class="memory-row-heading">
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{stateLabel(memory.state)}</span>
<span class="memory-kind">{kindLabel(memory.kind)}</span>
</div>
<h3>{memory.claim}</h3>
<p>{memory.excerpt || 'No excerpt.'}</p>
</div>
<dl>
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Memory change</dt><dd>{memory.change_id}</dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
</div>
<nav class="memory-pagination" aria-label="Current Memory pages">
{#if data.cursor}<a href={memoryPageHref()}>First page</a>{/if}
{#if data.memories.data.has_more && data.memories.data.next_cursor}
<a href={memoryPageHref(data.memories.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.memories.error}
<div class="memory-state is-error" role="alert">
<strong>Current Memories unavailable.</strong>
<p>{data.memories.error}</p>
</div>
{:else}
<div class="memory-state" role="status"><p>Current Memory data is unavailable.</p></div>
{/if}
</section>
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-back-link {
width: fit-content;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-page-header,
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
}
.memory-page-header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header h1,
.section-heading h2,
.memory-row h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.memory-page-header h1 {
font-size: var(--font-size-title);
}
.section-heading h2 {
font-size: var(--font-size-body);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
color: var(--accent);
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.subject-id {
display: block;
max-width: min(60rem, 100%);
margin-top: var(--space-2);
color: var(--text-muted);
overflow-wrap: anywhere;
}
.read-only-label,
.memory-count {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-overview > div {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
}
.subject-overview span,
.subject-overview p,
.subject-technical-details summary,
.subject-technical-details small,
.surface-details small {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.subject-overview strong {
color: var(--text-strong);
overflow-wrap: anywhere;
}
.subject-overview p {
margin: 0;
overflow-wrap: anywhere;
}
.subject-technical-details {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.subject-technical-details summary {
width: fit-content;
cursor: pointer;
font-weight: 700;
}
.subject-technical-details dl,
.surface-details dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-3);
}
.subject-technical-details dl > div,
.surface-details dl > div {
display: block;
min-width: 0;
}
.subject-technical-details dd,
.surface-details dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.subject-technical-details small,
.surface-details small {
display: block;
margin-top: var(--space-1);
line-height: 1.4;
}
.subject-state,
.availability,
.memory-pill,
.memory-kind {
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.subject-state,
.availability.is-ready,
.memory-pill.is-active {
color: var(--success);
}
.subject-state.is-retired,
.availability.is-ungenerated,
.memory-pill.is-retracted {
color: var(--text-faint);
}
.availability.is-stale,
.memory-pill.is-resolved {
color: var(--warning);
}
.availability.is-failed {
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
gap: var(--space-4);
min-width: 0;
padding-top: var(--space-3);
}
.behavior-help,
.behavior-saved,
.behavior-error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.behavior-error { color: var(--danger); }
.behavior-saved { color: var(--success); }
.behavior-form,
.behavior-form label {
display: grid;
gap: var(--space-3);
}
.behavior-form textarea {
width: 100%;
min-height: 10rem;
padding: var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.behavior-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.behavior-edit,
.behavior-actions button {
padding: var(--space-2) var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
cursor: pointer;
}
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
.behavior-document pre {
margin: 0;
padding: var(--space-4);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.surface-meta,
.pill-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.surface-document {
min-width: 0;
}
.surface-details {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.surface-details summary {
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-size-compact);
font-weight: 700;
}
.surface-details dl {
margin-top: var(--space-3);
}
.surface-details dd,
.reference-list code {
overflow-wrap: anywhere;
}
.reference-list {
display: grid;
gap: var(--space-1);
margin: 0;
padding-left: 1.25rem;
}
.memory-list {
display: grid;
border-top: 1px solid var(--line);
}
.memory-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(19rem, 0.8fr);
gap: var(--space-5);
min-width: 0;
padding: var(--space-4);
border-bottom: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.memory-row:hover,
.memory-row:focus-visible {
background: var(--interactive-hover);
}
.memory-row-heading {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.memory-row h3 {
font-size: var(--font-size-body);
}
.memory-row p {
margin: 0;
color: var(--text-muted);
}
.memory-kind {
color: var(--accent);
}
.memory-row dl {
align-content: center;
}
.memory-row dl > div {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.memory-row dd,
.memory-row code {
min-width: 0;
overflow-wrap: anywhere;
}
.memory-state {
padding: var(--space-4) 0;
color: var(--text-muted);
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-state p {
margin: var(--space-1) 0 0;
}
.memory-state.is-warning,
.memory-state.is-warning strong {
color: var(--warning);
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.memory-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-3);
}
}
@media (max-width: 600px) {
.memory-page-header,
.section-heading {
display: grid;
gap: var(--space-2);
}
.subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr);
}
.subject-overview {
padding: var(--space-3);
}
.memory-row {
padding-inline: 0;
}
.memory-row dl > div,
.surface-details dl > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
}
}
</style>
@@ -1,665 +1,13 @@
<script lang="ts">
// current/historicalはchange_idとcurrent_change_idの一致で区別する。
// 本文・evidenceの続きも同じimmutable changeへ固定し、現在版の内容を混ぜない。
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type {
MemoryEvidenceOrigin,
SubjektivMemoryChangeRef,
SubjektivMemorySourceEvidenceRef,
} from '#lib/generated/memory-api.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const memory = $derived(data.memory.data);
const changes = $derived(data.changes.data?.items ?? []);
function subjectHref(): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`);
}
function memoryPath(memoryId = data.memoryId): string {
return workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
);
}
function changeHref(changeId: string): string {
return `${memoryPath()}?change_id=${encodeURIComponent(changeId)}`;
}
function changePageHref(cursor?: string | null): string {
const query = new URLSearchParams();
if (memory) query.set('change_id', String(memory.change_id));
if (cursor) query.set('change_cursor', cursor);
const encoded = query.toString();
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
}
function derivedHref(reference: SubjektivMemoryChangeRef): string {
return `${memoryPath(reference.memory_id)}?change_id=${encodeURIComponent(reference.change_id)}`;
}
function bodyContinuationHref(): string | null {
if (!memory?.body_truncated || memory.body_next_offset == null || memory.body_next_byte_offset == null) return null;
const query = new URLSearchParams({
change_id: String(memory.change_id),
offset: String(memory.body_next_offset),
byte_offset: String(memory.body_next_byte_offset),
});
return `${memoryPath()}?${query}`;
}
function evidenceContinuationHref(): string | null {
if (!memory?.evidence_has_more || !memory.evidence_next_cursor) return null;
const query = new URLSearchParams({
change_id: String(memory.change_id),
evidence_cursor: memory.evidence_next_cursor,
});
return `${memoryPath()}?${query}`;
}
function label(value: string): string {
return value.replaceAll('_', ' ');
}
function titleLabel(value: string): string {
const normalized = label(value);
return normalized[0].toUpperCase() + normalized.slice(1);
}
function rangeLabel(range: [number, number] | null | undefined): string {
return range ? `${range[0]}–${range[1]}` : 'None';
}
function originLabel(origin: MemoryEvidenceOrigin | null | undefined): string {
if (!origin) return 'Origin unavailable';
const identity = [
origin.account_id && `account ${origin.account_id}`,
origin.workspace_id && `workspace ${origin.workspace_id}`,
origin.runtime_id && `runtime ${origin.runtime_id}`,
origin.worker_id && `worker ${origin.worker_id}`,
origin.flow_selector && `flow ${origin.flow_selector}`,
origin.flow_definition_id && `definition ${origin.flow_definition_id}`,
].filter(Boolean);
return `${label(origin.kind)}${identity.length ? ` · ${identity.join(' · ')}` : ''}`;
}
function sourceRefTitle(reference: SubjektivMemorySourceEvidenceRef): string {
return reference.label || reference.summary || reference.evidence_id || reference.segment_id || 'Source reference';
}
</script>
<svelte:head>
<title>{memory?.claim ?? data.memoryId} · Memory · Yoi Workspace</title>
<meta name="description" content="Committed Memory detail, immutable changes, and provenance" />
</svelte:head>
<section class="memory-page memory-detail-page" aria-labelledby="memory-detail-heading" data-memory-view="detail" data-memory-state={data.memory.error ? 'error' : memory ? memory.state : 'unavailable'}>
<a class="memory-back-link" href={subjectHref()}>← {data.subject.data?.role ?? 'Subject'}</a>
{#if memory}
<header class="memory-page-header">
<div>
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.change_id !== memory.current_change_id}<span class="historical-pill">Historical change</span>{/if}
</div>
<h1 id="memory-detail-heading">{memory.claim}</h1>
<code class="memory-id" title={memory.memory_id}>{memory.memory_id}</code>
</div>
<span class="read-only-label">Read-only</span>
</header>
<dl class="memory-facts" aria-label="Memory details">
<div><dt>Memory change</dt><dd><code>{memory.change_id}</code><small>{memory.change_id === memory.current_change_id ? 'Current change' : `Current change: ${memory.current_change_id}`}</small></dd></div>
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
<header class="section-heading"><h2 id="memory-body-heading">Memory</h2></header>
<p class="why-useful"><strong>Why useful:</strong> {memory.why_useful}</p>
{#if memory.staleness}<p class="staleness"><strong>Staleness:</strong> {memory.staleness}</p>{/if}
<p class="change-reason"><strong>Change reason:</strong> {memory.change_reason}</p>
{#if memory.body_md.trim().length === 0}
<div class="memory-state" role="status"><strong>This Memory body is empty.</strong></div>
{:else}
<article class="memory-document" aria-label="Committed Memory body">
<DocumentMarkdown text={memory.body_md} />
</article>
{/if}
{#if bodyContinuationHref()}
<div class="continuation-note" role="status">
<p>This bounded body segment continues at line {memory.body_next_offset}, byte {memory.body_next_byte_offset}.</p>
<a href={bodyContinuationHref() ?? undefined}>View next body segment</a>
</div>
{/if}
</section>
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
<span>{memory.source_candidates.length} shown</span>
</header>
{#if memory.source_candidates.length === 0}
<div class="memory-state" role="status"><p>No candidate evidence is included on this page.</p></div>
{:else}
<div class="candidate-list">
{#each memory.source_candidates as candidate, candidateIndex (candidate.candidate_id)}
<article class="candidate-record">
<header>
<h3>Candidate</h3>
<code title={candidate.candidate_id}>{candidate.candidate_id}</code>
</header>
<section aria-labelledby={`evidence-${candidateIndex}`}>
<h4 id={`evidence-${candidateIndex}`}>Evidence <span>{candidate.evidence.length} of {candidate.evidence_total}</span></h4>
{#if candidate.evidence.length === 0}
<p class="empty-copy">No evidence anchors on this page.</p>
{:else}
<ul class="evidence-list">
{#each candidate.evidence as evidence (evidence.id)}
<li>
<div class="evidence-heading"><strong>{evidence.id}</strong><span>{label(evidence.kind)}</span></div>
<dl>
<div><dt>Entry range</dt><dd>{rangeLabel(evidence.entry_range)}</dd></div>
<div><dt>Origin</dt><dd>{originLabel(evidence.origin)}</dd></div>
</dl>
{#if evidence.summary}<p>{evidence.summary}</p>{/if}
{#if evidence.excerpt}<blockquote>{evidence.excerpt}</blockquote>{/if}
</li>
{/each}
</ul>
{/if}
{#if candidate.evidence_truncated}<p class="bounded-note">Additional evidence anchors are available.</p>{/if}
</section>
<section aria-labelledby={`sources-${candidateIndex}`}>
<h4 id={`sources-${candidateIndex}`}>Source refs <span>{candidate.source_refs.length} of {candidate.source_refs_total}</span></h4>
{#if candidate.source_refs.length === 0}
<p class="empty-copy">No source refs on this page.</p>
{:else}
<ul class="source-list">
{#each candidate.source_refs as reference, index (`${candidate.candidate_id}:source:${index}`)}
<li>
<strong>{sourceRefTitle(reference)}</strong>
<dl>
<div><dt>Session</dt><dd><code>{reference.session_id ?? 'None'}</code></dd></div>
<div><dt>Segment</dt><dd><code>{reference.segment_id ?? 'None'}</code></dd></div>
<div><dt>Entry range</dt><dd>{rangeLabel(reference.entry_range)}</dd></div>
<div><dt>Evidence id</dt><dd><code>{reference.evidence_id ?? 'None'}</code></dd></div>
<div><dt>Evidence kind</dt><dd>{reference.evidence_kind ? label(reference.evidence_kind) : 'None'}</dd></div>
<div><dt>Origin</dt><dd>{originLabel(reference.origin)}</dd></div>
</dl>
{#if reference.summary}<p>{reference.summary}</p>{/if}
</li>
{/each}
</ul>
{/if}
{#if candidate.source_refs_truncated}<p class="bounded-note">Additional source refs are available.</p>{/if}
</section>
</article>
{/each}
</div>
{/if}
{#if evidenceContinuationHref()}
<div class="continuation-note" role="status">
<p>Candidate evidence continues on another bounded page.</p>
<a href={evidenceContinuationHref() ?? undefined}>View next evidence page</a>
</div>
{/if}
</section>
<section class="detail-section derived-section" aria-labelledby="derived-heading">
<header class="section-heading"><h2 id="derived-heading">Derived from</h2><span>{memory.derived_from.length}</span></header>
{#if memory.derived_from.length === 0}
<p class="empty-copy">No derivation references.</p>
{:else}
<ul class="derived-list">
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.change_id}`)}
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Change {reference.change_id}</span></a></li>
{/each}
</ul>
{/if}
</section>
<section class="detail-section change-section" aria-labelledby="change-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Immutable record</p><h2 id="change-heading">Change history</h2></div>
{#if data.changes.data}<span>{changes.length} shown{data.changes.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
<p class="section-intro">These are immutable changes to this Memory record. Change IDs identify content; they do not imply a numeric order.</p>
{#if data.changes.data}
{#if changes.length === 0}
<div>
<p class="empty-copy">No change history is available.</p>
{#if data.changeCursor}<p><a href={changePageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<ol class="change-list">
{#each changes as change (change.change_id)}
<li class:current={change.change_id === memory.change_id}>
<a href={changeHref(change.change_id)} aria-current={change.change_id === memory.change_id ? 'page' : undefined}>
<div><strong>Memory change {change.change_id}</strong>{#if change.change_id === memory.current_change_id}<span>Current</span>{/if}<span class="memory-pill is-{change.state}">{titleLabel(change.state)}</span><span class="memory-kind">{titleLabel(change.kind)}</span></div>
<p>{change.claim}</p>
<small>{change.change_reason} · <time datetime={change.updated_at}>{formatDate(change.updated_at)}</time></small>
</a>
</li>
{/each}
</ol>
<nav class="memory-pagination" aria-label="Change history pages">
{#if data.changeCursor}<a href={changePageHref()}>First page</a>{/if}
{#if data.changes.data.has_more && data.changes.data.next_cursor}
<a href={changePageHref(data.changes.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.changes.error}
<div class="memory-state is-error" role="alert"><strong>Change history unavailable.</strong><p>{data.changes.error}</p></div>
{:else}
<div class="memory-state" role="status"><p>Change history data is unavailable.</p></div>
{/if}
</section>
{:else if data.memory.error}
<header class="memory-page-header">
<div><p class="memory-eyebrow">Committed Memory</p><h1 id="memory-detail-heading">{data.memoryId}</h1></div>
<span class="read-only-label">Read-only</span>
</header>
<div class="memory-state is-error" role="alert">
<strong>Memory unavailable.</strong>
<p>{data.memory.error}</p>
</div>
{:else}
<header class="memory-page-header"><h1 id="memory-detail-heading">{data.memoryId}</h1></header>
<div class="memory-state" role="status"><p>Memory data is unavailable.</p></div>
{/if}
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-back-link {
width: fit-content;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-page-header,
.section-heading,
.candidate-record > header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
min-width: 0;
}
.memory-page-header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header > div,
.section-heading > div {
min-width: 0;
}
.memory-page-header h1,
.section-heading h2,
.candidate-record h3,
.candidate-record h4 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.memory-page-header h1 {
margin-top: var(--space-2);
font-size: var(--font-size-title);
}
.section-heading h2 {
font-size: var(--font-size-body);
}
.section-heading > span,
.candidate-record h4 span,
.read-only-label {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.pill-row,
.change-list a > div,
.evidence-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
}
.memory-pill,
.memory-kind,
.historical-pill,
.memory-eyebrow {
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.memory-pill.is-active {
color: var(--success);
}
.memory-pill.is-resolved,
.historical-pill {
color: var(--warning);
}
.memory-pill.is-retracted {
color: var(--text-faint);
}
.memory-kind,
.memory-eyebrow {
color: var(--accent);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
}
.memory-id {
display: block;
max-width: 100%;
margin-top: var(--space-2);
color: var(--text-muted);
overflow-wrap: anywhere;
}
.memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
.memory-facts > div {
display: block;
}
.memory-facts dt {
white-space: normal;
}
.memory-facts small { display: block; margin-top: var(--space-1); color: var(--text-muted); }
.memory-facts dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.detail-section {
display: grid;
gap: var(--space-4);
min-width: 0;
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.why-useful,
.staleness,
.change-reason,
.section-intro,
.empty-copy,
.bounded-note {
margin: 0;
color: var(--text-muted);
}
.why-useful strong,
.change-reason strong {
color: var(--text);
}
.staleness,
.staleness strong {
color: var(--warning);
}
.memory-document {
min-width: 0;
}
.continuation-note {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border-left: 3px solid var(--warning);
background: var(--bg-raised);
color: var(--text-muted);
}
.continuation-note p {
margin: 0;
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.candidate-list {
display: grid;
gap: var(--space-4);
}
.candidate-record {
display: grid;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--bg-raised);
}
.candidate-record > header {
align-items: baseline;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.candidate-record > header code {
min-width: 0;
color: var(--text-muted);
overflow-wrap: anywhere;
}
.candidate-record section {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.evidence-list,
.source-list,
.derived-list,
.change-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.evidence-list li,
.source-list li {
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--line);
background: var(--bg-subtle);
}
.evidence-heading span {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.evidence-list dl,
.source-list dl {
margin-top: var(--space-2);
}
.evidence-list dd,
.source-list dd,
.source-list code {
overflow-wrap: anywhere;
}
.evidence-list p,
.source-list p {
margin: var(--space-2) 0 0;
color: var(--text-muted);
}
.evidence-list blockquote {
margin: var(--space-2) 0 0;
padding-left: var(--space-3);
border-left: 3px solid var(--line-strong);
color: var(--text-muted);
}
.derived-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.derived-list a {
display: grid;
gap: var(--space-1);
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.derived-list a:hover,
.derived-list a:focus-visible,
.change-list a:hover,
.change-list a:focus-visible {
background: var(--interactive-hover);
}
.derived-list code {
overflow-wrap: anywhere;
}
.derived-list span {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.change-list {
border-top: 1px solid var(--line);
}
.change-list li {
border-bottom: 1px solid var(--line);
}
.change-list li.current {
border-left: 3px solid var(--accent);
}
.change-list a {
display: grid;
gap: var(--space-1);
min-width: 0;
padding: var(--space-3) var(--space-4);
color: inherit;
text-decoration: none;
}
.change-list strong,
.change-list p,
.change-list small {
margin: 0;
overflow-wrap: anywhere;
}
.change-list small {
color: var(--text-muted);
}
.memory-state {
padding: var(--space-4) 0;
color: var(--text-muted);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-state p {
margin: var(--space-1) 0 0;
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.memory-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.memory-page-header,
.section-heading,
.candidate-record > header,
.continuation-note {
display: grid;
gap: var(--space-2);
}
.memory-facts,
.derived-list {
grid-template-columns: minmax(0, 1fr);
}
.memory-facts > div,
.evidence-list dl > div,
.source-list dl > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
}
.candidate-record {
padding-inline: var(--space-3);
}
}
</style>
<DeferredPage {data} title="Memory">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -5,6 +5,7 @@ import {
parseSubjektivMemoryReadResponse,
parseSubjektivSubjectResponse,
} from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
@@ -32,7 +33,7 @@ function safeIntegerQuery(
return String(parsed);
}
export const load: PageLoad = async ({ fetch, params, url }) => {
export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const subjectId = params.subjectId;
const memoryId = params.memoryId;
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
@@ -93,4 +94,4 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
memory,
changes,
};
};
});
@@ -0,0 +1,668 @@
<script lang="ts">
// current/historicalはchange_idとcurrent_change_idの一致で区別する。
// 本文・evidenceの続きも同じimmutable changeへ固定し、現在版の内容を混ぜない。
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type {
MemoryEvidenceOrigin,
SubjektivMemoryChangeRef,
SubjektivMemorySourceEvidenceRef,
} from '#lib/generated/memory-api.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
const memory = $derived(data.memory.data);
const changes = $derived(data.changes.data?.items ?? []);
function subjectHref(): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`);
}
function memoryPath(memoryId = data.memoryId): string {
return workspaceRoute(
data.workspaceId,
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
);
}
function changeHref(changeId: string): string {
return `${memoryPath()}?change_id=${encodeURIComponent(changeId)}`;
}
function changePageHref(cursor?: string | null): string {
const query = new URLSearchParams();
if (memory) query.set('change_id', String(memory.change_id));
if (cursor) query.set('change_cursor', cursor);
const encoded = query.toString();
return encoded ? `${memoryPath()}?${encoded}` : memoryPath();
}
function derivedHref(reference: SubjektivMemoryChangeRef): string {
return `${memoryPath(reference.memory_id)}?change_id=${encodeURIComponent(reference.change_id)}`;
}
function bodyContinuationHref(): string | null {
if (!memory?.body_truncated || memory.body_next_offset == null || memory.body_next_byte_offset == null) return null;
const query = new URLSearchParams({
change_id: String(memory.change_id),
offset: String(memory.body_next_offset),
byte_offset: String(memory.body_next_byte_offset),
});
return `${memoryPath()}?${query}`;
}
function evidenceContinuationHref(): string | null {
if (!memory?.evidence_has_more || !memory.evidence_next_cursor) return null;
const query = new URLSearchParams({
change_id: String(memory.change_id),
evidence_cursor: memory.evidence_next_cursor,
});
return `${memoryPath()}?${query}`;
}
function label(value: string): string {
return value.replaceAll('_', ' ');
}
function titleLabel(value: string): string {
const normalized = label(value);
return normalized[0].toUpperCase() + normalized.slice(1);
}
function rangeLabel(range: [number, number] | null | undefined): string {
return range ? `${range[0]}–${range[1]}` : 'None';
}
function originLabel(origin: MemoryEvidenceOrigin | null | undefined): string {
if (!origin) return 'Origin unavailable';
const identity = [
origin.account_id && `account ${origin.account_id}`,
origin.workspace_id && `workspace ${origin.workspace_id}`,
origin.runtime_id && `runtime ${origin.runtime_id}`,
origin.worker_id && `worker ${origin.worker_id}`,
origin.flow_selector && `flow ${origin.flow_selector}`,
origin.flow_definition_id && `definition ${origin.flow_definition_id}`,
].filter(Boolean);
return `${label(origin.kind)}${identity.length ? ` · ${identity.join(' · ')}` : ''}`;
}
function sourceRefTitle(reference: SubjektivMemorySourceEvidenceRef): string {
return reference.label || reference.summary || reference.evidence_id || reference.segment_id || 'Source reference';
}
</script>
<svelte:head>
<title>{memory?.claim ?? data.memoryId} · Memory · Yoi Workspace</title>
<meta name="description" content="Committed Memory detail, immutable changes, and provenance" />
</svelte:head>
<section class="memory-page memory-detail-page" aria-labelledby="memory-detail-heading" data-memory-view="detail" data-memory-state={data.memory.error ? 'error' : memory ? memory.state : 'unavailable'}>
<a class="memory-back-link" href={subjectHref()}>← {data.subject.data?.role ?? 'Subject'}</a>
{#if memory}
<header class="memory-page-header">
<div>
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.change_id !== memory.current_change_id}<span class="historical-pill">Historical change</span>{/if}
</div>
<h1 id="memory-detail-heading">{memory.claim}</h1>
<code class="memory-id" title={memory.memory_id}>{memory.memory_id}</code>
</div>
<span class="read-only-label">Read-only</span>
</header>
<dl class="memory-facts" aria-label="Memory details">
<div><dt>Memory change</dt><dd><code>{memory.change_id}</code><small>{memory.change_id === memory.current_change_id ? 'Current change' : `Current change: ${memory.current_change_id}`}</small></dd></div>
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
<header class="section-heading"><h2 id="memory-body-heading">Memory</h2></header>
<p class="why-useful"><strong>Why useful:</strong> {memory.why_useful}</p>
{#if memory.staleness}<p class="staleness"><strong>Staleness:</strong> {memory.staleness}</p>{/if}
<p class="change-reason"><strong>Change reason:</strong> {memory.change_reason}</p>
{#if memory.body_md.trim().length === 0}
<div class="memory-state" role="status"><strong>This Memory body is empty.</strong></div>
{:else}
<article class="memory-document" aria-label="Committed Memory body">
<DocumentMarkdown text={memory.body_md} />
</article>
{/if}
{#if bodyContinuationHref()}
<div class="continuation-note" role="status">
<p>This bounded body segment continues at line {memory.body_next_offset}, byte {memory.body_next_byte_offset}.</p>
<a href={bodyContinuationHref() ?? undefined}>View next body segment</a>
</div>
{/if}
</section>
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
<span>{memory.source_candidates.length} shown</span>
</header>
{#if memory.source_candidates.length === 0}
<div class="memory-state" role="status"><p>No candidate evidence is included on this page.</p></div>
{:else}
<div class="candidate-list">
{#each memory.source_candidates as candidate, candidateIndex (candidate.candidate_id)}
<article class="candidate-record">
<header>
<h3>Candidate</h3>
<code title={candidate.candidate_id}>{candidate.candidate_id}</code>
</header>
<section aria-labelledby={`evidence-${candidateIndex}`}>
<h4 id={`evidence-${candidateIndex}`}>Evidence <span>{candidate.evidence.length} of {candidate.evidence_total}</span></h4>
{#if candidate.evidence.length === 0}
<p class="empty-copy">No evidence anchors on this page.</p>
{:else}
<ul class="evidence-list">
{#each candidate.evidence as evidence (evidence.id)}
<li>
<div class="evidence-heading"><strong>{evidence.id}</strong><span>{label(evidence.kind)}</span></div>
<dl>
<div><dt>Entry range</dt><dd>{rangeLabel(evidence.entry_range)}</dd></div>
<div><dt>Origin</dt><dd>{originLabel(evidence.origin)}</dd></div>
</dl>
{#if evidence.summary}<p>{evidence.summary}</p>{/if}
{#if evidence.excerpt}<blockquote>{evidence.excerpt}</blockquote>{/if}
</li>
{/each}
</ul>
{/if}
{#if candidate.evidence_truncated}<p class="bounded-note">Additional evidence anchors are available.</p>{/if}
</section>
<section aria-labelledby={`sources-${candidateIndex}`}>
<h4 id={`sources-${candidateIndex}`}>Source refs <span>{candidate.source_refs.length} of {candidate.source_refs_total}</span></h4>
{#if candidate.source_refs.length === 0}
<p class="empty-copy">No source refs on this page.</p>
{:else}
<ul class="source-list">
{#each candidate.source_refs as reference, index (`${candidate.candidate_id}:source:${index}`)}
<li>
<strong>{sourceRefTitle(reference)}</strong>
<dl>
<div><dt>Session</dt><dd><code>{reference.session_id ?? 'None'}</code></dd></div>
<div><dt>Segment</dt><dd><code>{reference.segment_id ?? 'None'}</code></dd></div>
<div><dt>Entry range</dt><dd>{rangeLabel(reference.entry_range)}</dd></div>
<div><dt>Evidence id</dt><dd><code>{reference.evidence_id ?? 'None'}</code></dd></div>
<div><dt>Evidence kind</dt><dd>{reference.evidence_kind ? label(reference.evidence_kind) : 'None'}</dd></div>
<div><dt>Origin</dt><dd>{originLabel(reference.origin)}</dd></div>
</dl>
{#if reference.summary}<p>{reference.summary}</p>{/if}
</li>
{/each}
</ul>
{/if}
{#if candidate.source_refs_truncated}<p class="bounded-note">Additional source refs are available.</p>{/if}
</section>
</article>
{/each}
</div>
{/if}
{#if evidenceContinuationHref()}
<div class="continuation-note" role="status">
<p>Candidate evidence continues on another bounded page.</p>
<a href={evidenceContinuationHref() ?? undefined}>View next evidence page</a>
</div>
{/if}
</section>
<section class="detail-section derived-section" aria-labelledby="derived-heading">
<header class="section-heading"><h2 id="derived-heading">Derived from</h2><span>{memory.derived_from.length}</span></header>
{#if memory.derived_from.length === 0}
<p class="empty-copy">No derivation references.</p>
{:else}
<ul class="derived-list">
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.change_id}`)}
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Change {reference.change_id}</span></a></li>
{/each}
</ul>
{/if}
</section>
<section class="detail-section change-section" aria-labelledby="change-heading">
<header class="section-heading">
<div><p class="memory-eyebrow">Immutable record</p><h2 id="change-heading">Change history</h2></div>
{#if data.changes.data}<span>{changes.length} shown{data.changes.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
<p class="section-intro">These are immutable changes to this Memory record. Change IDs identify content; they do not imply a numeric order.</p>
{#if data.changes.data}
{#if changes.length === 0}
<div>
<p class="empty-copy">No change history is available.</p>
{#if data.changeCursor}<p><a href={changePageHref()}>Return to the first page</a></p>{/if}
</div>
{:else}
<ol class="change-list">
{#each changes as change (change.change_id)}
<li class:current={change.change_id === memory.change_id}>
<a href={changeHref(change.change_id)} aria-current={change.change_id === memory.change_id ? 'page' : undefined}>
<div><strong>Memory change {change.change_id}</strong>{#if change.change_id === memory.current_change_id}<span>Current</span>{/if}<span class="memory-pill is-{change.state}">{titleLabel(change.state)}</span><span class="memory-kind">{titleLabel(change.kind)}</span></div>
<p>{change.claim}</p>
<small>{change.change_reason} · <time datetime={change.updated_at}>{formatDate(change.updated_at)}</time></small>
</a>
</li>
{/each}
</ol>
<nav class="memory-pagination" aria-label="Change history pages">
{#if data.changeCursor}<a href={changePageHref()}>First page</a>{/if}
{#if data.changes.data.has_more && data.changes.data.next_cursor}
<a href={changePageHref(data.changes.data.next_cursor)}>Next page →</a>
{/if}
</nav>
{/if}
{:else if data.changes.error}
<div class="memory-state is-error" role="alert"><strong>Change history unavailable.</strong><p>{data.changes.error}</p></div>
{:else}
<div class="memory-state" role="status"><p>Change history data is unavailable.</p></div>
{/if}
</section>
{:else if data.memory.error}
<header class="memory-page-header">
<div><p class="memory-eyebrow">Committed Memory</p><h1 id="memory-detail-heading">{data.memoryId}</h1></div>
<span class="read-only-label">Read-only</span>
</header>
<div class="memory-state is-error" role="alert">
<strong>Memory unavailable.</strong>
<p>{data.memory.error}</p>
</div>
{:else}
<header class="memory-page-header"><h1 id="memory-detail-heading">{data.memoryId}</h1></header>
<div class="memory-state" role="status"><p>Memory data is unavailable.</p></div>
{/if}
</section>
<style>
.memory-page {
display: grid;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-back-link {
width: fit-content;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.memory-page-header,
.section-heading,
.candidate-record > header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
min-width: 0;
}
.memory-page-header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--line);
}
.memory-page-header > div,
.section-heading > div {
min-width: 0;
}
.memory-page-header h1,
.section-heading h2,
.candidate-record h3,
.candidate-record h4 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.memory-page-header h1 {
margin-top: var(--space-2);
font-size: var(--font-size-title);
}
.section-heading h2 {
font-size: var(--font-size-body);
}
.section-heading > span,
.candidate-record h4 span,
.read-only-label {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.pill-row,
.change-list a > div,
.evidence-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
}
.memory-pill,
.memory-kind,
.historical-pill,
.memory-eyebrow {
font-size: var(--font-size-compact);
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.memory-pill.is-active {
color: var(--success);
}
.memory-pill.is-resolved,
.historical-pill {
color: var(--warning);
}
.memory-pill.is-retracted {
color: var(--text-faint);
}
.memory-kind,
.memory-eyebrow {
color: var(--accent);
}
.memory-eyebrow {
margin: 0 0 var(--space-1);
}
.memory-id {
display: block;
max-width: 100%;
margin-top: var(--space-2);
color: var(--text-muted);
overflow-wrap: anywhere;
}
.memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
.memory-facts > div {
display: block;
}
.memory-facts dt {
white-space: normal;
}
.memory-facts small { display: block; margin-top: var(--space-1); color: var(--text-muted); }
.memory-facts dd {
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
.detail-section {
display: grid;
gap: var(--space-4);
min-width: 0;
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.why-useful,
.staleness,
.change-reason,
.section-intro,
.empty-copy,
.bounded-note {
margin: 0;
color: var(--text-muted);
}
.why-useful strong,
.change-reason strong {
color: var(--text);
}
.staleness,
.staleness strong {
color: var(--warning);
}
.memory-document {
min-width: 0;
}
.continuation-note {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border-left: 3px solid var(--warning);
background: var(--bg-raised);
color: var(--text-muted);
}
.continuation-note p {
margin: 0;
}
.memory-pagination {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
font-size: var(--font-size-compact);
}
.candidate-list {
display: grid;
gap: var(--space-4);
}
.candidate-record {
display: grid;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--bg-raised);
}
.candidate-record > header {
align-items: baseline;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.candidate-record > header code {
min-width: 0;
color: var(--text-muted);
overflow-wrap: anywhere;
}
.candidate-record section {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.evidence-list,
.source-list,
.derived-list,
.change-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.evidence-list li,
.source-list li {
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--line);
background: var(--bg-subtle);
}
.evidence-heading span {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.evidence-list dl,
.source-list dl {
margin-top: var(--space-2);
}
.evidence-list dd,
.source-list dd,
.source-list code {
overflow-wrap: anywhere;
}
.evidence-list p,
.source-list p {
margin: var(--space-2) 0 0;
color: var(--text-muted);
}
.evidence-list blockquote {
margin: var(--space-2) 0 0;
padding-left: var(--space-3);
border-left: 3px solid var(--line-strong);
color: var(--text-muted);
}
.derived-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.derived-list a {
display: grid;
gap: var(--space-1);
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--line);
color: inherit;
text-decoration: none;
}
.derived-list a:hover,
.derived-list a:focus-visible,
.change-list a:hover,
.change-list a:focus-visible {
background: var(--interactive-hover);
}
.derived-list code {
overflow-wrap: anywhere;
}
.derived-list span {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.change-list {
border-top: 1px solid var(--line);
}
.change-list li {
border-bottom: 1px solid var(--line);
}
.change-list li.current {
border-left: 3px solid var(--accent);
}
.change-list a {
display: grid;
gap: var(--space-1);
min-width: 0;
padding: var(--space-3) var(--space-4);
color: inherit;
text-decoration: none;
}
.change-list strong,
.change-list p,
.change-list small {
margin: 0;
overflow-wrap: anywhere;
}
.change-list small {
color: var(--text-muted);
}
.memory-state {
padding: var(--space-4) 0;
color: var(--text-muted);
}
.memory-state strong {
color: var(--text-strong);
}
.memory-state p {
margin: var(--space-1) 0 0;
}
.memory-state.is-error,
.memory-state.is-error strong {
color: var(--danger);
}
@media (max-width: 900px) {
.memory-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.memory-page-header,
.section-heading,
.candidate-record > header,
.continuation-note {
display: grid;
gap: var(--space-2);
}
.memory-facts,
.derived-list {
grid-template-columns: minmax(0, 1fr);
}
.memory-facts > div,
.evidence-list dl > div,
.source-list dl > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
}
.candidate-record {
padding-inline: var(--space-3);
}
}
</style>
@@ -15,9 +15,9 @@ const { gotoMock } = vi.hoisted(() => ({
vi.mock("$app/navigation", () => ({ goto: gotoMock }));
import SubjectIndexPage from "./+page.svelte";
import SubjectPage from "./[subjectId]/+page.svelte";
import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte";
import SubjectIndexPage from "./PageContent.svelte";
import SubjectPage from "./[subjectId]/PageContent.svelte";
import MemoryDetailPage from "./[subjectId]/[memoryId]/PageContent.svelte";
afterEach(() => {
cleanup();
@@ -397,6 +397,59 @@ function subjectPageData(record = subject(), workspaceId = "workspace-1") {
};
}
test("same-identity refresh updates Subject projection without rebasing an active behavior draft", async () => {
const fetchMock = vi.fn(async (_path: string, _init?: RequestInit) =>
Response.json({ message: "save rejected" }, { status: 409 })
);
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
await fireEvent.input(textarea, { target: { value: "Unsaved behavior draft" } });
const refreshed = { ...subject(), role: "Refreshed role", behavior_md: "Changed on server." };
await view.rerender({ data: subjectPageData(refreshed) } as never);
expect(screen.getByRole("heading", { name: "Refreshed role", level: 1 })).not.toBeNull();
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Unsaved behavior draft");
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull());
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
expected_behavior_md: "Prefer explicit evidence.",
behavior_md: "Unsaved behavior draft",
});
await view.rerender({ data: subjectPageData({ ...refreshed, behavior_md: "Changed again." }) } as never);
expect(screen.getByRole("alert")).not.toBeNull();
expect(textarea).toHaveProperty("value", "Unsaved behavior draft");
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).expected_behavior_md).toBe("Prefer explicit evidence.");
await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty("disabled", false));
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Changed again.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Changed again.");
});
test("same-identity refresh keeps an in-flight behavior save fenced and accepts its result", async () => {
let resolveRequest!: (response: Response) => void;
const fetchMock = vi.fn(() => new Promise<Response>((resolve) => { resolveRequest = resolve; }));
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(screen.getByRole("textbox", { name: "Subject behavior" }), {
target: { value: "Saved draft" },
});
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await view.rerender({ data: subjectPageData({ ...subject(), behavior_md: "Refreshed text" }) } as never);
expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty("disabled", true);
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Saved draft");
resolveRequest(Response.json({ ...subject(), behavior_md: "Saved draft" }));
await waitFor(() => expect(screen.getByText(/storage confirmed/)).not.toBeNull());
expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Saved draft");
});
test.each([
["subject-2", "workspace-1"],
["subject-1", "workspace-2"],
@@ -1,304 +1,13 @@
<script lang="ts">
import type { MemoryStagingEntry, MemoryStagingRecord } from '#lib/generated/memory-api.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const entries = $derived(data.staging.data?.items ?? []);
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function sourceLabel(record: MemoryStagingRecord): string {
const source = record.source;
return `${source.segment_id}:${source.range[0]}-${source.range[1]}`;
}
function evidenceCount(entry: MemoryStagingEntry): number {
return entry.record.evidence?.length ?? 0;
}
function sourceRefCount(entry: MemoryStagingEntry): number {
return entry.record.source_refs?.length ?? 0;
}
</script>
<svelte:head>
<title>Memory Staging · Yoi Workspace</title>
<meta name="description" content="Workspace Memory Staging" />
</svelte:head>
<section class="card memory-staging-card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace memory</p>
<h2>Memory Staging</h2>
</div>
{#if data.staging.data}
<span>{data.staging.data.returned_count} / {data.staging.data.total_valid_count} staged</span>
{/if}
</div>
<p class="section-note">
Pending Memory extraction candidates staged for consolidation. This view is read-only and uses the Workspace Server memory authority.
</p>
{#if data.staging.data}
<div class="staging-summary-grid" aria-label="Memory staging summary">
<div>
<span>Valid records</span>
<strong>{data.staging.data.total_valid_count}</strong>
</div>
<div>
<span>Invalid records</span>
<strong>{data.staging.data.invalid_count}</strong>
</div>
<div>
<span>Order</span>
<strong>{data.staging.data.order}</strong>
</div>
<div>
<span>Authority</span>
<strong>{data.staging.data.record_authority}</strong>
</div>
</div>
{#if data.staging.data.truncated}
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
{/if}
{#each data.staging.data.diagnostics as diagnostic (diagnostic.code)}
<p class:error={diagnostic.severity === 'error'} class="section-note">
{diagnostic.message}
</p>
{/each}
{#if entries.length === 0}
<p>No Memory Staging records are present.</p>
{:else}
<div class="staging-list">
{#each entries as entry (entry.id)}
<article class="staging-entry">
<header>
<div>
<span class="kind-pill">{kindLabel(entry.record.kind)}</span>
<h3>{entry.record.claim}</h3>
</div>
<code>{entry.id}</code>
</header>
<p>{entry.record.why_useful}</p>
{#if entry.record.staleness}
<p class="staleness">Staleness: {entry.record.staleness}</p>
{/if}
<dl class="staging-meta">
<div>
<dt>Extract run</dt>
<dd>{entry.record.extract_run_id}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{sourceLabel(entry.record)}</dd>
</div>
<div>
<dt>Evidence</dt>
<dd>{evidenceCount(entry)}</dd>
</div>
<div>
<dt>Source refs</dt>
<dd>{sourceRefCount(entry)}</dd>
</div>
<div>
<dt>Bytes</dt>
<dd>{entry.byte_len}</dd>
</div>
</dl>
{#if entry.record.evidence && entry.record.evidence.length > 0}
<details>
<summary>Evidence</summary>
<ul class="evidence-list">
{#each entry.record.evidence as evidence (evidence.id)}
<li>
<strong>{evidence.id}</strong>
<span>{evidence.kind}</span>
{#if evidence.summary}
<p>{evidence.summary}</p>
{/if}
{#if evidence.excerpt}
<blockquote>{evidence.excerpt}</blockquote>
{/if}
</li>
{/each}
</ul>
</details>
{/if}
</article>
{/each}
</div>
{/if}
{:else if data.staging.error}
<p class="error">{data.staging.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/memory/staging</code>…</p>
{/if}
</section>
<style>
.memory-staging-card {
overflow: hidden;
}
.staging-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.staging-summary-grid div {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
padding: 0.8rem;
}
.staging-summary-grid span,
.staging-meta dt {
color: var(--text-muted);
display: block;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.staging-summary-grid strong {
color: var(--text);
display: block;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.staging-list {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
}
.staging-entry {
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--bg-raised);
padding: 1rem;
}
.staging-entry header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.staging-entry h3 {
color: var(--text-strong);
font-size: var(--font-size-body);
margin: 0.35rem 0 0;
}
.staging-entry p {
color: var(--text);
margin: 0.75rem 0 0;
}
.staging-entry code,
.staging-meta dd {
overflow-wrap: anywhere;
}
.kind-pill {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--accent);
display: inline-flex;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.18rem 0.55rem;
text-transform: uppercase;
}
.staleness {
color: var(--warning);
}
.staging-meta {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0 0;
}
.staging-meta div {
min-width: 0;
}
.staging-meta dd {
color: var(--text);
margin: 0.25rem 0 0;
}
details {
border-top: 1px solid var(--line);
margin-top: 1rem;
padding-top: 0.75rem;
}
summary {
color: var(--text-muted);
cursor: pointer;
font-weight: 700;
}
.evidence-list {
display: grid;
gap: 0.75rem;
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.evidence-list li {
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--bg-subtle);
padding: 0.75rem;
}
.evidence-list span {
color: var(--text-muted);
margin-left: 0.5rem;
}
blockquote {
border-left: 3px solid var(--line-strong);
color: var(--text-muted);
margin: 0.75rem 0 0;
padding-left: 0.75rem;
}
@media (max-width: 900px) {
.staging-summary-grid,
.staging-meta {
grid-template-columns: 1fr;
}
.staging-entry header {
display: grid;
}
}
</style>
<DeferredPage {data} title="Memory Staging">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -1,9 +1,10 @@
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseMemoryStagingListResponse } from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
return {
workspaceId: params.workspaceId,
staging: await loadJson(
@@ -17,4 +18,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
},
),
};
};
});
@@ -0,0 +1,307 @@
<script lang="ts">
import type { MemoryStagingEntry, MemoryStagingRecord } from '#lib/generated/memory-api.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
const entries = $derived(data.staging.data?.items ?? []);
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function sourceLabel(record: MemoryStagingRecord): string {
const source = record.source;
return `${source.segment_id}:${source.range[0]}-${source.range[1]}`;
}
function evidenceCount(entry: MemoryStagingEntry): number {
return entry.record.evidence?.length ?? 0;
}
function sourceRefCount(entry: MemoryStagingEntry): number {
return entry.record.source_refs?.length ?? 0;
}
</script>
<svelte:head>
<title>Memory Staging · Yoi Workspace</title>
<meta name="description" content="Workspace Memory Staging" />
</svelte:head>
<section class="card memory-staging-card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace memory</p>
<h2>Memory Staging</h2>
</div>
{#if data.staging.data}
<span>{data.staging.data.returned_count} / {data.staging.data.total_valid_count} staged</span>
{/if}
</div>
<p class="section-note">
Pending Memory extraction candidates staged for consolidation. This view is read-only and uses the Workspace Server memory authority.
</p>
{#if data.staging.data}
<div class="staging-summary-grid" aria-label="Memory staging summary">
<div>
<span>Valid records</span>
<strong>{data.staging.data.total_valid_count}</strong>
</div>
<div>
<span>Invalid records</span>
<strong>{data.staging.data.invalid_count}</strong>
</div>
<div>
<span>Order</span>
<strong>{data.staging.data.order}</strong>
</div>
<div>
<span>Authority</span>
<strong>{data.staging.data.record_authority}</strong>
</div>
</div>
{#if data.staging.data.truncated}
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
{/if}
{#each data.staging.data.diagnostics as diagnostic (diagnostic.code)}
<p class:error={diagnostic.severity === 'error'} class="section-note">
{diagnostic.message}
</p>
{/each}
{#if entries.length === 0}
<p>No Memory Staging records are present.</p>
{:else}
<div class="staging-list">
{#each entries as entry (entry.id)}
<article class="staging-entry">
<header>
<div>
<span class="kind-pill">{kindLabel(entry.record.kind)}</span>
<h3>{entry.record.claim}</h3>
</div>
<code>{entry.id}</code>
</header>
<p>{entry.record.why_useful}</p>
{#if entry.record.staleness}
<p class="staleness">Staleness: {entry.record.staleness}</p>
{/if}
<dl class="staging-meta">
<div>
<dt>Extract run</dt>
<dd>{entry.record.extract_run_id}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{sourceLabel(entry.record)}</dd>
</div>
<div>
<dt>Evidence</dt>
<dd>{evidenceCount(entry)}</dd>
</div>
<div>
<dt>Source refs</dt>
<dd>{sourceRefCount(entry)}</dd>
</div>
<div>
<dt>Bytes</dt>
<dd>{entry.byte_len}</dd>
</div>
</dl>
{#if entry.record.evidence && entry.record.evidence.length > 0}
<details>
<summary>Evidence</summary>
<ul class="evidence-list">
{#each entry.record.evidence as evidence (evidence.id)}
<li>
<strong>{evidence.id}</strong>
<span>{evidence.kind}</span>
{#if evidence.summary}
<p>{evidence.summary}</p>
{/if}
{#if evidence.excerpt}
<blockquote>{evidence.excerpt}</blockquote>
{/if}
</li>
{/each}
</ul>
</details>
{/if}
</article>
{/each}
</div>
{/if}
{:else if data.staging.error}
<p class="error">{data.staging.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/memory/staging</code>…</p>
{/if}
</section>
<style>
.memory-staging-card {
overflow: hidden;
}
.staging-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.staging-summary-grid div {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
padding: 0.8rem;
}
.staging-summary-grid span,
.staging-meta dt {
color: var(--text-muted);
display: block;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.staging-summary-grid strong {
color: var(--text);
display: block;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.staging-list {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
}
.staging-entry {
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--bg-raised);
padding: 1rem;
}
.staging-entry header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.staging-entry h3 {
color: var(--text-strong);
font-size: var(--font-size-body);
margin: 0.35rem 0 0;
}
.staging-entry p {
color: var(--text);
margin: 0.75rem 0 0;
}
.staging-entry code,
.staging-meta dd {
overflow-wrap: anywhere;
}
.kind-pill {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--accent);
display: inline-flex;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.18rem 0.55rem;
text-transform: uppercase;
}
.staleness {
color: var(--warning);
}
.staging-meta {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0 0;
}
.staging-meta div {
min-width: 0;
}
.staging-meta dd {
color: var(--text);
margin: 0.25rem 0 0;
}
details {
border-top: 1px solid var(--line);
margin-top: 1rem;
padding-top: 0.75rem;
}
summary {
color: var(--text-muted);
cursor: pointer;
font-weight: 700;
}
.evidence-list {
display: grid;
gap: 0.75rem;
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.evidence-list li {
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--bg-subtle);
padding: 0.75rem;
}
.evidence-list span {
color: var(--text-muted);
margin-left: 0.5rem;
}
blockquote {
border-left: 3px solid var(--line-strong);
color: var(--text-muted);
margin: 0.75rem 0 0;
padding-left: 0.75rem;
}
@media (max-width: 900px) {
.staging-summary-grid,
.staging-meta {
grid-template-columns: 1fr;
}
.staging-entry header {
display: grid;
}
}
</style>
@@ -1,61 +1,13 @@
<script lang="ts">
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head><title>Merge Requests · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Workspace resources</p>
<h1>Merge Requests</h1>
</div>
<span class="workspace-count">{data.mergeRequests?.items.length ?? 0}</span>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else}
<div class="ticket-list" aria-label="Merge Requests">
{#each data.mergeRequests?.items ?? [] as item (item.summary.merge_request_id)}
{@const mergeRequest = item.summary}
<a
class="ticket-row"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>
<div class="ticket-main">
<div class="ticket-title-row">
<span class="ticket-key">{mergeRequest.merge_request_id}</span>
<strong class="ticket-title">
{mergeRequest.selector_from ?? "Source selector requires repair"}
→ {mergeRequest.selector_to}
</strong>
</div>
<p class="ticket-summary">
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"}
</p>
<p class="ticket-summary">{summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error">Integration evidence error: {mergeRequest.integration_evidence_error}</p>
{/if}
</div>
<div class="ticket-meta">
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
<time>{prettyDate(mergeRequest.updated_at)}</time>
</div>
</a>
{:else}
<p class="workspace-empty-copy">No Merge Requests.</p>
{/each}
</div>
{/if}
</div>
<DeferredPage {data} title="Merge Requests">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -6,7 +6,9 @@ import {
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
export const load: PageLoad = async ({ params, fetch }) => {
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = deferPage(async ({ params, fetch }: Parameters<PageLoad>[0]) => {
const result = await loadJson(
fetch,
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
@@ -19,4 +21,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
mergeRequests: result.data,
error: result.error,
};
};
});
@@ -0,0 +1,64 @@
<script lang="ts">
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<RouteData>;
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head><title>Merge Requests · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Workspace resources</p>
<h1>Merge Requests</h1>
</div>
<span class="workspace-count">{data.mergeRequests?.items.length ?? 0}</span>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else}
<div class="ticket-list" aria-label="Merge Requests">
{#each data.mergeRequests?.items ?? [] as item (item.summary.merge_request_id)}
{@const mergeRequest = item.summary}
<a
class="ticket-row"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>
<div class="ticket-main">
<div class="ticket-title-row">
<span class="ticket-key">{mergeRequest.merge_request_id}</span>
<strong class="ticket-title">
{mergeRequest.selector_from ?? "Source selector requires repair"}
→ {mergeRequest.selector_to}
</strong>
</div>
<p class="ticket-summary">
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"}
</p>
<p class="ticket-summary">{summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error">Integration evidence error: {mergeRequest.integration_evidence_error}</p>
{/if}
</div>
<div class="ticket-meta">
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
<time>{prettyDate(mergeRequest.updated_at)}</time>
</div>
</a>
{:else}
<p class="workspace-empty-copy">No Merge Requests.</p>
{/each}
</div>
{/if}
</div>
@@ -1,128 +1,13 @@
<script lang="ts">
import type { MergeRequestThreadEvent } from "#lib/generated/ticket-api.ts";
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import {
sourceReviewFreshness,
targetIntegrationStatus,
} from "#lib/workspace/merge-request-status.ts";
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(
event: MergeRequestThreadEvent,
key: "subject_ref" | "decision" | "reason" | "body",
): string | null {
let value: unknown;
if (key === "subject_ref" && "subject_ref" in event) value = event.subject_ref;
if (key === "decision" && "decision" in event) value = event.decision;
if (key === "reason" && "reason" in event) value = event.reason;
if (key === "body" && "body" in event) value = event.body;
return typeof value === "string" && value.length > 0 ? value : null;
}
</script>
<svelte:head><title>Merge Request · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Merge Request</p>
<h1>{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}</h1>
{#if data.mergeRequest}
<p class="workspace-page-lede">
{data.mergeRequest.selector_from ?? "Source selector requires repair"}
→ {data.mergeRequest.selector_to}
</p>
{/if}
</div>
<a class="workspace-secondary-button" href={mergeRequestPagePath(data.workspaceId)}>
All Merge Requests
</a>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else if data.mergeRequest}
{@const mergeRequest = data.mergeRequest}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Selectors</h2></div>
<dl class="ticket-facts">
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
<div><dt>Live source ref</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
{#if mergeRequest.source.diagnostic}
<div><dt>Source observation error</dt><dd>{mergeRequest.source.diagnostic.code}: {mergeRequest.source.diagnostic.message}</dd></div>
{/if}
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
<div><dt>Target ref</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Review and integration status</h2></div>
<dl class="ticket-facts">
<div><dt>{mergeRequest.state === "merged" ? "Recorded integration approval" : "Live source review freshness"}</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
<div><dt>Target integration</dt><dd>{targetIntegrationStatus(mergeRequest)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Thread</h2><span>{mergeRequest.thread.length}</span>
</div>
<div class="ticket-timeline">
{#each mergeRequest.thread as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.created_at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
{/if}
{#if textField(event, "decision")}<p>{textField(event, "decision")}</p>{/if}
{#if textField(event, "reason")}<p>{textField(event, "reason")}</p>{/if}
{#if textField(event, "body")}<p>{textField(event, "body")}</p>{/if}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No Merge Request events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Linked Tickets</h2></header>
{#each mergeRequest.linked_tickets as linkedTicket}
{#if linkedTicket.key}
<p>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(linkedTicket.key)}`}>
{linkedTicket.key}
</a>
</p>
{:else}
<p class="workspace-empty-copy">Linked Ticket key unavailable.</p>
{/if}
{:else}
<p class="workspace-empty-copy">No linked Tickets.</p>
{/each}
</section>
</aside>
</div>
{/if}
</div>
<DeferredPage {data} title="Merge Request">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -6,7 +6,9 @@ import {
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
export const load: PageLoad = async ({ params, fetch }) => {
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = deferPage(async ({ params, fetch }: Parameters<PageLoad>[0]) => {
const result = await loadJson(
fetch,
`${
@@ -22,4 +24,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
mergeRequest: result.data,
error: result.error,
};
};
});
@@ -0,0 +1,131 @@
<script lang="ts">
import type { MergeRequestThreadEvent } from "#lib/generated/ticket-api.ts";
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import {
sourceReviewFreshness,
targetIntegrationStatus,
} from "#lib/workspace/merge-request-status.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<RouteData>;
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(
event: MergeRequestThreadEvent,
key: "subject_ref" | "decision" | "reason" | "body",
): string | null {
let value: unknown;
if (key === "subject_ref" && "subject_ref" in event) value = event.subject_ref;
if (key === "decision" && "decision" in event) value = event.decision;
if (key === "reason" && "reason" in event) value = event.reason;
if (key === "body" && "body" in event) value = event.body;
return typeof value === "string" && value.length > 0 ? value : null;
}
</script>
<svelte:head><title>Merge Request · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Merge Request</p>
<h1>{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}</h1>
{#if data.mergeRequest}
<p class="workspace-page-lede">
{data.mergeRequest.selector_from ?? "Source selector requires repair"}
→ {data.mergeRequest.selector_to}
</p>
{/if}
</div>
<a class="workspace-secondary-button" href={mergeRequestPagePath(data.workspaceId)}>
All Merge Requests
</a>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else if data.mergeRequest}
{@const mergeRequest = data.mergeRequest}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Selectors</h2></div>
<dl class="ticket-facts">
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
<div><dt>Live source ref</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
{#if mergeRequest.source.diagnostic}
<div><dt>Source observation error</dt><dd>{mergeRequest.source.diagnostic.code}: {mergeRequest.source.diagnostic.message}</dd></div>
{/if}
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
<div><dt>Target ref</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Review and integration status</h2></div>
<dl class="ticket-facts">
<div><dt>{mergeRequest.state === "merged" ? "Recorded integration approval" : "Live source review freshness"}</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
<div><dt>Target integration</dt><dd>{targetIntegrationStatus(mergeRequest)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Thread</h2><span>{mergeRequest.thread.length}</span>
</div>
<div class="ticket-timeline">
{#each mergeRequest.thread as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.created_at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
{/if}
{#if textField(event, "decision")}<p>{textField(event, "decision")}</p>{/if}
{#if textField(event, "reason")}<p>{textField(event, "reason")}</p>{/if}
{#if textField(event, "body")}<p>{textField(event, "body")}</p>{/if}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No Merge Request events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Linked Tickets</h2></header>
{#each mergeRequest.linked_tickets as linkedTicket}
{#if linkedTicket.key}
<p>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(linkedTicket.key)}`}>
{linkedTicket.key}
</a>
</p>
{:else}
<p class="workspace-empty-copy">Linked Ticket key unavailable.</p>
{/if}
{:else}
<p class="workspace-empty-copy">No linked Tickets.</p>
{/each}
</section>
</aside>
</div>
{/if}
</div>
@@ -1,46 +1,13 @@
<script lang="ts">
import { formatDate } from '#lib/workspace/api/http.ts';
import { objectiveHref } from '#lib/workspace/resource-links.ts';
import type { PageProps } from './$types';
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
let { data }: PageProps = $props();
let { data }: { data: PageData } = $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={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
<span class="state-pill">{objective.state}</span>
</div>
</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.resource_key}</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>
<DeferredPage {data} title="Objectives">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -4,8 +4,9 @@ import {
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectives = await loadJson(
fetch,
@@ -20,4 +21,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
objectives: objectives.data,
objectivesError: objectives.error,
};
};
});
@@ -0,0 +1,50 @@
<script lang="ts">
import { formatDate } from '#lib/workspace/api/http.ts';
import { objectiveHref } from '#lib/workspace/resource-links.ts';
import type { PageData as RouteData, PageProps as RouteProps } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<RouteData>;
type PageProps = { data: PageData };
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={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
<span class="state-pill">{objective.state}</span>
</div>
</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.resource_key}</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>
@@ -1,66 +1,13 @@
<script lang="ts">
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
import { formatDate } from '#lib/workspace/api/http.ts';
import { ticketHref } from '#lib/workspace/resource-links.ts';
import type { PageProps } from './$types';
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
let { data }: PageProps = $props();
const objectivesHref = (workspaceId: string) =>
`/w/${encodeURIComponent(workspaceId)}/objectives`;
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
</svelte:head>
<section class="card objective-detail-card">
<a class="objective-back-link" href={objectivesHref(data.workspaceId)}>← All Objectives</a>
<h2>Objective detail</h2>
{#if data.objective}
<div class="objective-title-row detail">
<h3>{data.objective.title}</h3>
<span class="state-pill">{data.objective.state}</span>
</div>
<dl class="objective-detail-metadata">
<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>
{#if data.objective.linked_ticket_summaries.length}
{#each data.objective.linked_ticket_summaries as ticket, index}
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.resource_key}</a>
{/each}
{:else}
none
{/if}
</dd>
</div>
</dl>
<div class="objective-body" aria-label="Objective body">
{#if data.objective.body}
<RichMarkdown text={data.objective.body} />
{:else}
<p class="section-note">No Objective body is available.</p>
{/if}
</div>
{#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>
<DeferredPage {data} title="Objective">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -9,8 +9,9 @@ import {
resourceKey,
} from "#lib/workspace/resource-links.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = resourceKey(params.objectiveId);
const objective = await loadJson(
@@ -42,4 +43,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
objective: objective.data,
objectiveError: objective.error,
};
};
});
@@ -0,0 +1,70 @@
<script lang="ts">
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
import { formatDate } from '#lib/workspace/api/http.ts';
import { ticketHref } from '#lib/workspace/resource-links.ts';
import type { PageData as RouteData, PageProps as RouteProps } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<RouteData>;
type PageProps = { data: PageData };
let { data }: PageProps = $props();
const objectivesHref = (workspaceId: string) =>
`/w/${encodeURIComponent(workspaceId)}/objectives`;
</script>
<svelte:head>
<title>{data.objective?.title ?? data.objectiveId} · Objective</title>
</svelte:head>
<section class="card objective-detail-card">
<a class="objective-back-link" href={objectivesHref(data.workspaceId)}>← All Objectives</a>
<h2>Objective detail</h2>
{#if data.objective}
<div class="objective-title-row detail">
<h3>{data.objective.title}</h3>
<span class="state-pill">{data.objective.state}</span>
</div>
<dl class="objective-detail-metadata">
<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>
{#if data.objective.linked_ticket_summaries.length}
{#each data.objective.linked_ticket_summaries as ticket, index}
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.resource_key}</a>
{/each}
{:else}
none
{/if}
</dd>
</div>
</dl>
<div class="objective-body" aria-label="Objective body">
{#if data.objective.body}
<RichMarkdown text={data.objective.body} />
{:else}
<p class="section-note">No Objective body is available.</p>
{/if}
</div>
{#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>
@@ -1,85 +1,13 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
let response = $state(untrack(() => data.tickets));
let busy = $state(false);
$effect(() => { const latest = data.tickets; untrack(() => { response = latest; }); });
async function applyFilters(event: SubmitEvent) {
event.preventDefault();
const search = new URLSearchParams();
for (const [key, value] of new FormData(event.currentTarget as HTMLFormElement)) if (typeof value === "string" && value) search.append(key, value);
busy = true;
try { await goto(`?${search}`); } finally { busy = false; }
}
async function loadMore() {
if (busy || (response.data && !response.data.page.has_more)) return;
busy = true;
const observedData = data;
const previous = response.data;
const result = await loadJson(fetch, workspaceApiPath(data.workspaceId, "/tickets/query"), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(ticketQueryRequest(data.filters, data.filters.caseState === "all" ? [] : [data.filters.caseState], previous?.page.next_cursor ?? null, data.handling)),
}, parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY);
if (data !== observedData) { busy = false; return; }
if (result.data && previous) {
const items = new Map(previous.items.map((item) => [item.id, item]));
for (const item of result.data.items) items.set(item.id, item);
response = { data: { ...result.data, items: [...items.values()] }, error: null };
} else response = { data: result.data ?? previous, error: result.error };
busy = false;
}
</script>
<svelte:head><title>Orchestrator · {data.workspaceId}</title></svelte:head>
<div class="workspace-page orchestrator-page">
<header class="workspace-page-header"><h1>Orchestrator</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}</p>
{#if data.orchestrator.error}<p class="workspace-callout is-error" role="alert">Worker availability: {data.orchestrator.error}</p>{/if}
<form class="orchestrator-filters" aria-label="Orchestrator filters" onsubmit={applyFilters}>
<label>Search<input name="q" type="search" value={data.filters.query} /></label>
<label>Handling (self-reported)<select name="handling" value={data.handling ?? ""}><option value="">All including unknown</option><option value="waiting">Waiting</option><option value="inprogress">In progress</option><option value="done">Done</option></select></label>
<label>Ticket case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
<button class="workspace-secondary-button" disabled={busy}>{busy ? "Loading…" : "Apply filters"}</button>
</form>
<section class="orchestrator-assignments" aria-label="Current Orchestrator assignments">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const row = currentOrchestratorAssignment(ticket)}
<a class="orchestrator-assignment" href={ticketHref(data.workspaceId, ticket)}>
<div class="assignment-heading"><span>{ticket.resource_key}</span><strong>{ticket.title}</strong></div>
<div class="assignment-meta"><span>Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})</span><span>Ticket case: {ticket.state}</span></div>
<p>{handlingSummary(row)}</p>
{#if row?.tracking?.next_action}<p>Next action: {row.tracking.next_action}</p>{/if}
{#if ticket.current_closure}<p>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<span>Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
{#if row?.updated_at}<time datetime={row.updated_at}>Handling updated: {new Date(row.updated_at).toLocaleString()}</time>{/if}
<span class="assignment-detail-link">View Ticket, handling history and delivery evidence →</span>
</a>
{:else}
{#if !response.error}<p class="workspace-empty-copy">No current assignments match these filters.</p>{/if}
{/each}
{#if response.error}<div class="workspace-callout is-error" role="alert"><p>{response.error}</p><button class="workspace-secondary-button" type="button" onclick={loadMore} disabled={busy}>Retry query</button></div>{/if}
{#if busy}<p aria-live="polite">Loading assignments…</p>{:else if response.data?.page.has_more && !response.error}<button class="workspace-secondary-button" type="button" onclick={loadMore}>Load more assignments</button>{/if}
</section>
</div>
<style>
.orchestrator-page { max-width: 72rem; }
.workspace-page-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.orchestrator-filters { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
.orchestrator-filters label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 12rem; }
input, select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.orchestrator-assignments { display: grid; }
.orchestrator-assignment { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.orchestrator-assignment:hover { background: var(--interactive-hover); }
.assignment-heading, .assignment-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.assignment-heading strong { color: var(--text-strong); }
.assignment-meta, time { color: var(--text-muted); font-size: var(--font-size-compact); }
.orchestrator-assignment p { margin: 0; white-space: pre-wrap; }
.assignment-detail-link { color: var(--accent); }
</style>
<DeferredPage {data} title="Orchestrator">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -11,8 +11,9 @@ import {
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { TicketAssignmentTrackingStatus } from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params, url }) => {
export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const workspaceId = params.workspaceId;
const filters = {
...readTicketFilters(url.searchParams),
@@ -52,4 +53,4 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
),
]);
return { workspaceId, filters, handling, tickets, orchestrator };
};
});
@@ -0,0 +1,88 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<RouteData>;
let { data }: { data: PageData } = $props();
let response = $state(untrack(() => data.tickets));
let busy = $state(false);
$effect(() => { const latest = data.tickets; untrack(() => { response = latest; }); });
async function applyFilters(event: SubmitEvent) {
event.preventDefault();
const search = new URLSearchParams();
for (const [key, value] of new FormData(event.currentTarget as HTMLFormElement)) if (typeof value === "string" && value) search.append(key, value);
busy = true;
try { await goto(`?${search}`); } finally { busy = false; }
}
async function loadMore() {
if (busy || (response.data && !response.data.page.has_more)) return;
busy = true;
const observedData = data;
const previous = response.data;
const result = await loadJson(fetch, workspaceApiPath(data.workspaceId, "/tickets/query"), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(ticketQueryRequest(data.filters, data.filters.caseState === "all" ? [] : [data.filters.caseState], previous?.page.next_cursor ?? null, data.handling)),
}, parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY);
if (data !== observedData) { busy = false; return; }
if (result.data && previous) {
const items = new Map(previous.items.map((item) => [item.id, item]));
for (const item of result.data.items) items.set(item.id, item);
response = { data: { ...result.data, items: [...items.values()] }, error: null };
} else response = { data: result.data ?? previous, error: result.error };
busy = false;
}
</script>
<svelte:head><title>Orchestrator · {data.workspaceId}</title></svelte:head>
<div class="workspace-page orchestrator-page">
<header class="workspace-page-header"><h1>Orchestrator</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}</p>
{#if data.orchestrator.error}<p class="workspace-callout is-error" role="alert">Worker availability: {data.orchestrator.error}</p>{/if}
<form class="orchestrator-filters" aria-label="Orchestrator filters" onsubmit={applyFilters}>
<label>Search<input name="q" type="search" value={data.filters.query} /></label>
<label>Handling (self-reported)<select name="handling" value={data.handling ?? ""}><option value="">All including unknown</option><option value="waiting">Waiting</option><option value="inprogress">In progress</option><option value="done">Done</option></select></label>
<label>Ticket case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
<button class="workspace-secondary-button" disabled={busy}>{busy ? "Loading…" : "Apply filters"}</button>
</form>
<section class="orchestrator-assignments" aria-label="Current Orchestrator assignments">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const row = currentOrchestratorAssignment(ticket)}
<a class="orchestrator-assignment" href={ticketHref(data.workspaceId, ticket)}>
<div class="assignment-heading"><span>{ticket.resource_key}</span><strong>{ticket.title}</strong></div>
<div class="assignment-meta"><span>Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})</span><span>Ticket case: {ticket.state}</span></div>
<p>{handlingSummary(row)}</p>
{#if row?.tracking?.next_action}<p>Next action: {row.tracking.next_action}</p>{/if}
{#if ticket.current_closure}<p>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<span>Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
{#if row?.updated_at}<time datetime={row.updated_at}>Handling updated: {new Date(row.updated_at).toLocaleString()}</time>{/if}
<span class="assignment-detail-link">View Ticket, handling history and delivery evidence →</span>
</a>
{:else}
{#if !response.error}<p class="workspace-empty-copy">No current assignments match these filters.</p>{/if}
{/each}
{#if response.error}<div class="workspace-callout is-error" role="alert"><p>{response.error}</p><button class="workspace-secondary-button" type="button" onclick={loadMore} disabled={busy}>Retry query</button></div>{/if}
{#if busy}<p aria-live="polite">Loading assignments…</p>{:else if response.data?.page.has_more && !response.error}<button class="workspace-secondary-button" type="button" onclick={loadMore}>Load more assignments</button>{/if}
</section>
</div>
<style>
.orchestrator-page { max-width: 72rem; }
.workspace-page-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.orchestrator-filters { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
.orchestrator-filters label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 12rem; }
input, select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.orchestrator-assignments { display: grid; }
.orchestrator-assignment { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.orchestrator-assignment:hover { background: var(--interactive-hover); }
.assignment-heading, .assignment-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.assignment-heading strong { color: var(--text-strong); }
.assignment-meta, time { color: var(--text-muted); font-size: var(--font-size-compact); }
.orchestrator-assignment p { margin: 0; white-space: pre-wrap; }
.assignment-detail-link { color: var(--accent); }
</style>
@@ -1,260 +1,13 @@
<script lang="ts">
import type {
ConfirmRepositorySshHostTrustRequest,
RepositorySshConnectionProbeRequest,
RepositorySshConnectionProbeResponse
} from '#lib/generated/repository-api.ts';
import { parseRepositorySshHostTrust } from '#lib/workspace/api/repository-access.ts';
import { formatDate, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRepositorySshConnectionProbeResponse } from '#lib/workspace/api/workspace-model.ts';
import {
changeRepositorySshProbeRuntime,
RepositorySshProbeFence,
repositorySshProbeRuntimes
} from '#lib/workspace/repositories/ssh-connection.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let selectedRuntimeId = $state('');
let probe = $state<RepositorySshConnectionProbeResponse | null>(null);
let selectedHostKey = $state('');
let pending = $state(false);
let connectionMessage = $state<string | null>(null);
const probeFence = new RepositorySshProbeFence();
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
function selectRuntime(runtimeId: string) {
const selection = changeRepositorySshProbeRuntime(
selectedRuntimeId,
runtimeId,
probe,
selectedHostKey
);
if (!selection.changed) return;
selectedRuntimeId = selection.runtimeId;
probe = selection.probe;
selectedHostKey = selection.selectedHostKey;
probeFence.enter(runtimeId);
connectionMessage = null;
pending = false;
}
$effect(() => {
if (!selectedRuntimeId) {
selectRuntime(probeRuntimes[0]?.runtime_id ?? '');
}
});
async function requestConnectionTest(method: 'POST' | 'PUT', body: unknown): Promise<unknown> {
const response = await fetch(
workspaceApiPath(
data.repository?.workspace_id ?? '',
`/repositories/${encodeURIComponent(data.repositoryKey)}/ssh-connection-test`
),
{
method,
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(body)
}
);
const value = await response.json();
if (!response.ok) {
const record = value && typeof value === 'object' ? value as Record<string, unknown> : null;
throw new Error(typeof record?.message === 'string' ? record.message : `SSH connection test failed with status ${response.status}`);
}
return value;
}
async function runConnectionTest() {
const operation = probeFence.capture(selectedRuntimeId);
pending = true;
connectionMessage = null;
probe = null;
selectedHostKey = '';
try {
const body: RepositorySshConnectionProbeRequest = { runtime_id: operation.runtimeId };
const nextProbe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
if (nextProbe.runtime_id !== operation.runtimeId) {
throw new Error('SSH connection test returned a different Runtime');
}
probe = nextProbe;
selectedHostKey = probe.candidates[0]?.host_key ?? '';
connectionMessage = probe.trust_state === 'verified'
? 'The observed SSH host key matches the Workspace trust record.'
: 'Review the observed fingerprint before trusting this SSH host.';
} catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
} finally {
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
}
}
async function confirmHostTrust() {
if (!probe || !selectedHostKey || probe.runtime_id !== selectedRuntimeId) return;
const operation = probeFence.capture(selectedRuntimeId);
pending = true;
connectionMessage = null;
try {
const body: ConfirmRepositorySshHostTrustRequest = {
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
runtime_id: operation.runtimeId,
host_key: selectedHostKey,
expected_host_key_fingerprint: probe.expected_host_key_fingerprint
};
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
probe = { ...probe, trust_state: 'verified' };
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
} catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
} finally {
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
}
}
</script>
<svelte:head>
<title>{data.repository?.item.repository_key ?? data.repositoryKey} · 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.repository_key}</h3>
</div>
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</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>Source</dt>
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
</div>
<div>
<dt>Repository access</dt>
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
</div>
<div>
<dt>Source fingerprint</dt>
<dd>{data.repository.item.source_fingerprint}</dd>
</div>
<div>
<dt>Observed</dt>
<dd>{data.repository.item.observed_at ?? 'not observed'}</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>
{#if data.repository?.item.source.kind === 'ssh'}
<section class="card repository-detail-card">
<h2>SSH connection test</h2>
<p>Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.</p>
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
{:else if data.runtimes}
<label>
<span>Runtime</span>
<select value={selectedRuntimeId} onchange={(event) => selectRuntime(event.currentTarget.value)}>
{#each probeRuntimes as runtime}
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
{/each}
</select>
</label>
{#if probeRuntimes.length === 0}
<p class="section-state error">No configured remote Runtime is available for this connection test.</p>
{/if}
<button type="button" disabled={pending || !selectedRuntimeId} onclick={() => void runConnectionTest()}>
{pending ? 'Checking…' : 'Check SSH connection'}
</button>
{/if}
{#if probe}
<p><strong>{probe.hostname}:{probe.port}</strong> · {probe.trust_state}</p>
{#each probe.candidates as candidate}
<label class="repository-host-key-candidate">
<input type="radio" name="repository-host-key" bind:group={selectedHostKey} value={candidate.host_key} />
<span><code>{candidate.algorithm}</code> <code>{candidate.fingerprint}</code></span>
</label>
{/each}
{#if probe.trust_state !== 'verified'}
<button type="button" class="danger" disabled={pending || !selectedHostKey} onclick={() => void confirmHostTrust()}>
Confirm and trust selected host key
</button>
{/if}
{/if}
{#if connectionMessage}<p class="section-state" class:error={probe === null}>{connectionMessage}</p>{/if}
</section>
{/if}
<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>
<DeferredPage {data} title="Repository">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -3,8 +3,9 @@ import { loadWorkspaceRepositoryDetail } from "#lib/workspace/api/repositories.t
import { parseWorkspaceRuntimeList } from "#lib/workspace/api/runtime-management.ts";
import { parseRepositoryLogResponse } from "#lib/workspace/api/workspace-model.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const workspaceId = params.workspaceId;
const repositoryKey = params.repositoryKey;
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
@@ -58,4 +59,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
runtimes,
runtimesError,
};
};
});
@@ -0,0 +1,262 @@
<script lang="ts">
import type {
ConfirmRepositorySshHostTrustRequest,
RepositorySshConnectionProbeRequest,
RepositorySshConnectionProbeResponse
} from '#lib/generated/repository-api.ts';
import { parseRepositorySshHostTrust } from '#lib/workspace/api/repository-access.ts';
import { formatDate, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRepositorySshConnectionProbeResponse } from '#lib/workspace/api/workspace-model.ts';
import {
changeRepositorySshProbeRuntime,
RepositorySshProbeFence,
repositorySshProbeRuntimes
} from '#lib/workspace/repositories/ssh-connection.ts';
import type { PageData as GeneratedPageData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<GeneratedPageData>;
let { data }: { data: PageData } = $props();
let selectedRuntimeId = $state('');
let probe = $state<RepositorySshConnectionProbeResponse | null>(null);
let selectedHostKey = $state('');
let pending = $state(false);
let connectionMessage = $state<string | null>(null);
const probeFence = new RepositorySshProbeFence();
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
function selectRuntime(runtimeId: string) {
const selection = changeRepositorySshProbeRuntime(
selectedRuntimeId,
runtimeId,
probe,
selectedHostKey
);
if (!selection.changed) return;
selectedRuntimeId = selection.runtimeId;
probe = selection.probe;
selectedHostKey = selection.selectedHostKey;
probeFence.enter(runtimeId);
connectionMessage = null;
pending = false;
}
$effect(() => {
if (!selectedRuntimeId) {
selectRuntime(probeRuntimes[0]?.runtime_id ?? '');
}
});
async function requestConnectionTest(method: 'POST' | 'PUT', body: unknown): Promise<unknown> {
const response = await fetch(
workspaceApiPath(
data.repository?.workspace_id ?? '',
`/repositories/${encodeURIComponent(data.repositoryKey)}/ssh-connection-test`
),
{
method,
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(body)
}
);
const value = await response.json();
if (!response.ok) {
const record = value && typeof value === 'object' ? value as Record<string, unknown> : null;
throw new Error(typeof record?.message === 'string' ? record.message : `SSH connection test failed with status ${response.status}`);
}
return value;
}
async function runConnectionTest() {
const operation = probeFence.capture(selectedRuntimeId);
pending = true;
connectionMessage = null;
probe = null;
selectedHostKey = '';
try {
const body: RepositorySshConnectionProbeRequest = { runtime_id: operation.runtimeId };
const nextProbe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
if (nextProbe.runtime_id !== operation.runtimeId) {
throw new Error('SSH connection test returned a different Runtime');
}
probe = nextProbe;
selectedHostKey = probe.candidates[0]?.host_key ?? '';
connectionMessage = probe.trust_state === 'verified'
? 'The observed SSH host key matches the Workspace trust record.'
: 'Review the observed fingerprint before trusting this SSH host.';
} catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
} finally {
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
}
}
async function confirmHostTrust() {
if (!probe || !selectedHostKey || probe.runtime_id !== selectedRuntimeId) return;
const operation = probeFence.capture(selectedRuntimeId);
pending = true;
connectionMessage = null;
try {
const body: ConfirmRepositorySshHostTrustRequest = {
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
runtime_id: operation.runtimeId,
host_key: selectedHostKey,
expected_host_key_fingerprint: probe.expected_host_key_fingerprint
};
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
probe = { ...probe, trust_state: 'verified' };
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
} catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
} finally {
if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
}
}
</script>
<svelte:head>
<title>{data.repository?.item.repository_key ?? data.repositoryKey} · 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.repository_key}</h3>
</div>
<span class="status-pill" class:warn={data.repository.item.observed_status !== 'ready'}>{data.repository.item.observed_status}</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>Source</dt>
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
</div>
<div>
<dt>Repository access</dt>
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
</div>
<div>
<dt>Source fingerprint</dt>
<dd>{data.repository.item.source_fingerprint}</dd>
</div>
<div>
<dt>Observed</dt>
<dd>{data.repository.item.observed_at ?? 'not observed'}</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>
{#if data.repository?.item.source.kind === 'ssh'}
<section class="card repository-detail-card">
<h2>SSH connection test</h2>
<p>Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.</p>
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
{:else if data.runtimes}
<label>
<span>Runtime</span>
<select value={selectedRuntimeId} onchange={(event) => selectRuntime(event.currentTarget.value)}>
{#each probeRuntimes as runtime}
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
{/each}
</select>
</label>
{#if probeRuntimes.length === 0}
<p class="section-state error">No configured remote Runtime is available for this connection test.</p>
{/if}
<button type="button" disabled={pending || !selectedRuntimeId} onclick={() => void runConnectionTest()}>
{pending ? 'Checking…' : 'Check SSH connection'}
</button>
{/if}
{#if probe}
<p><strong>{probe.hostname}:{probe.port}</strong> · {probe.trust_state}</p>
{#each probe.candidates as candidate}
<label class="repository-host-key-candidate">
<input type="radio" name="repository-host-key" bind:group={selectedHostKey} value={candidate.host_key} />
<span><code>{candidate.algorithm}</code> <code>{candidate.fingerprint}</code></span>
</label>
{/each}
{#if probe.trust_state !== 'verified'}
<button type="button" class="danger" disabled={pending || !selectedHostKey} onclick={() => void confirmHostTrust()}>
Confirm and trust selected host key
</button>
{/if}
{/if}
{#if connectionMessage}<p class="section-state" class:error={probe === null}>{connectionMessage}</p>{/if}
</section>
{/if}
<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>
@@ -17,7 +17,7 @@
import '#lib/workspace/styles/settings.css';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
@@ -28,7 +28,7 @@
registerSidebar: sidebarContentOverrides.register,
});
const workspaceId = $derived(data.workspace?.workspace_id ?? page.params.workspaceId ?? '');
const workspaceId = $derived(page.params.workspaceId ?? '');
const ownsCurrentRoute = $derived(
workspaceId !== '' &&
ownsRoutePath(workspaceRoute(workspaceId, '/settings'), page.url.pathname),
@@ -1,17 +1,13 @@
<script lang="ts">
import WorkspaceIdentitySettings from '#lib/workspace/settings/WorkspaceIdentitySettings.svelte';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head><title>Workspace Identity · Settings · Yoi</title></svelte:head>
{#if data.workspace}
{#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`}
<WorkspaceIdentitySettings
workspaceId={data.workspace.workspace_id}
workspaceName={data.workspace.display_name}
canDelete={data.workspace.permissions.delete_workspace}
/>
{/key}
{/if}
<DeferredPage {data} title="Workspace Identity">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -0,0 +1,19 @@
<script lang="ts">
import WorkspaceIdentitySettings from '#lib/workspace/settings/WorkspaceIdentitySettings.svelte';
import type { PageData as GeneratedPageData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<GeneratedPageData>;
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>Workspace Identity · Settings · Yoi</title></svelte:head>
{#if data.workspace}
{#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`}
<WorkspaceIdentitySettings
workspaceId={data.workspace.workspace_id}
workspaceName={data.workspace.display_name}
canDelete={data.workspace.permissions.delete_workspace}
/>
{/key}
{/if}
@@ -1,27 +1,13 @@
<script lang="ts">
import ConfigSourceEditor from "#lib/workspace/config-source/ConfigSourceEditor.svelte";
import type { PageProps } from "./$types";
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
let workspaceName = $derived(data.workspace?.display_name ?? "Workspace");
</script>
<svelte:head>
<title>Configuration | {workspaceName}</title>
</svelte:head>
<section class="settings-page">
<header class="page-header">
<div>
<p class="eyebrow">Workspace settings</p>
<h2>Configuration</h2>
<p>
Edit the Server-owned virtual Decodal source tree. Drafts stay in this browser;
the Server persists only a fully evaluated candidate.
</p>
</div>
</header>
<ConfigSourceEditor {workspaceId} />
</section>
<DeferredPage {data} title="Configuration">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -0,0 +1,29 @@
<script lang="ts">
import ConfigSourceEditor from "#lib/workspace/config-source/ConfigSourceEditor.svelte";
import type { PageData as GeneratedPageData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<GeneratedPageData>;
let { data }: { data: PageData } = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
let workspaceName = $derived(data.workspace?.display_name ?? "Workspace");
</script>
<svelte:head>
<title>Configuration | {workspaceName}</title>
</svelte:head>
<section class="settings-page">
<header class="page-header">
<div>
<p class="eyebrow">Workspace settings</p>
<h2>Configuration</h2>
<p>
Edit the Server-owned virtual Decodal source tree. Drafts stay in this browser;
the Server persists only a fully evaluated candidate.
</p>
</div>
</header>
<ConfigSourceEditor {workspaceId} />
</section>
@@ -1,77 +1,13 @@
<script lang="ts">
import DiagnosticsList from "#lib/workspace/settings/DiagnosticsList.svelte";
import { settingsSectionHref } from "#lib/workspace/settings/model.ts";
import type { ProfileSettingsResponse } from "#lib/generated/legacy-server-api.ts";
import { fetchProfileSettings } from "#lib/workspace/settings/profile-api.ts";
import type { PageProps } from "./$types";
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
let profileSettings = $state<ProfileSettingsResponse | null>(null);
let loading = $state(true);
let message = $state<string | null>(null);
$effect(() => {
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
if (!cancelled) profileSettings = response;
} catch (error) {
if (!cancelled) {
message = error instanceof Error ? error.message : "profile settings request failed";
}
} finally {
if (!cancelled) loading = false;
}
}
if (workspaceId) load();
else loading = false;
return () => {
cancelled = true;
};
});
</script>
<svelte:head>
<title>Profiles · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="profiles-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">Workspace configuration projection</p>
<h2 id="profiles-title">Profiles</h2>
</div>
<a class="badge" href={`/w/${encodeURIComponent(workspaceId)}${settingsSectionHref("configuration-sources")}`}>
Edit configuration
</a>
</header>
<p>
These launch profiles are derived from the active Workspace configuration. Edit Profile declarations and Decodal sources in the shared configuration editor.
</p>
{#if loading}
<p class="status-message">Loading profiles…</p>
{:else if message}
<p class="status-message error">{message}</p>
{:else if profileSettings}
<p class="settings-note">
Tree {profileSettings.tree_digest ?? "unknown"} · projection {profileSettings.projection_digest ?? "unknown"}
</p>
<ul class="settings-profile-list">
{#each profileSettings.profiles as profile (profile.profile_id)}
<li>
<strong>{profile.selector}</strong>
<span>{profile.label}</span>
<small>{profile.source_kind}{profile.is_default ? " · default" : ""}</small>
{#if profile.description}<p>{profile.description}</p>{/if}
<DiagnosticsList diagnostics={profile.diagnostics} />
</li>
{/each}
</ul>
<DiagnosticsList diagnostics={profileSettings.diagnostics} />
{/if}
</section>
<DeferredPage {data} title="Profiles">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -0,0 +1,79 @@
<script lang="ts">
import DiagnosticsList from "#lib/workspace/settings/DiagnosticsList.svelte";
import { settingsSectionHref } from "#lib/workspace/settings/model.ts";
import type { ProfileSettingsResponse } from "#lib/generated/legacy-server-api.ts";
import { fetchProfileSettings } from "#lib/workspace/settings/profile-api.ts";
import type { PageData as GeneratedPageData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<GeneratedPageData>;
let { data }: { data: PageData } = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
let profileSettings = $state<ProfileSettingsResponse | null>(null);
let loading = $state(true);
let message = $state<string | null>(null);
$effect(() => {
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
if (!cancelled) profileSettings = response;
} catch (error) {
if (!cancelled) {
message = error instanceof Error ? error.message : "profile settings request failed";
}
} finally {
if (!cancelled) loading = false;
}
}
if (workspaceId) load();
else loading = false;
return () => {
cancelled = true;
};
});
</script>
<svelte:head>
<title>Profiles · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="profiles-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">Workspace configuration projection</p>
<h2 id="profiles-title">Profiles</h2>
</div>
<a class="badge" href={`/w/${encodeURIComponent(workspaceId)}${settingsSectionHref("configuration-sources")}`}>
Edit configuration
</a>
</header>
<p>
These launch profiles are derived from the active Workspace configuration. Edit Profile declarations and Decodal sources in the shared configuration editor.
</p>
{#if loading}
<p class="status-message">Loading profiles…</p>
{:else if message}
<p class="status-message error">{message}</p>
{:else if profileSettings}
<p class="settings-note">
Tree {profileSettings.tree_digest ?? "unknown"} · projection {profileSettings.projection_digest ?? "unknown"}
</p>
<ul class="settings-profile-list">
{#each profileSettings.profiles as profile (profile.profile_id)}
<li>
<strong>{profile.selector}</strong>
<span>{profile.label}</span>
<small>{profile.source_kind}{profile.is_default ? " · default" : ""}</small>
{#if profile.description}<p>{profile.description}</p>{/if}
<DiagnosticsList diagnostics={profile.diagnostics} />
</li>
{/each}
</ul>
<DiagnosticsList diagnostics={profileSettings.diagnostics} />
{/if}
</section>
@@ -1,149 +1,13 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
import { createWorkspaceRepository } from '#lib/workspace/api/repositories.ts';
import type {
CreateWorkspaceRepositoryRequest,
RepositorySourceKind,
} from '#lib/generated/repository-api.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function sourceLabel(kind: RepositorySourceKind): string {
if (kind === 'local_path' || kind === 'file') return 'Local';
if (kind === 'invalid') return 'Invalid';
return 'Remote Git';
}
function supportsRepositoryAccess(kind: RepositorySourceKind): boolean {
return kind === 'ssh' || kind === 'https';
}
let showAddRepository = $state(false);
let repositoryKey = $state('');
let source = $state('');
let defaultRef = $state('');
let pending = $state(false);
let requestError = $state<string | null>(null);
async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault();
pending = true;
requestError = null;
const request: CreateWorkspaceRepositoryRequest = {
repository_key: repositoryKey,
source,
default_ref: defaultRef || null,
};
const result = await createWorkspaceRepository(fetch, data.workspaceId, request);
if (result.data === null) {
requestError = result.error ?? 'Repository creation failed';
pending = false;
return;
}
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
pending = false;
}
</script>
<svelte:head>
<title>Repositories · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Repository resources" />
</svelte:head>
<section class="repositories-page" aria-labelledby="repositories-heading">
<header class="page-header-row">
<div>
<p class="eyebrow">owner only</p>
<h1 id="repositories-heading">Repositories</h1>
<p>Register the local and remote Git repositories available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRepository = !showAddRepository}>
{showAddRepository ? 'Close' : 'Add Repository'}
</button>
</header>
{#if showAddRepository}
<form class="settings-repository-form" onsubmit={addRepository}>
<h2>Add Repository</h2>
<div class="settings-form-grid">
<label>
Repository key
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9]" maxlength="64" autocomplete="off" />
<small>1–64 lowercase letters, digits, or hyphens; no leading or trailing hyphen.</small>
</label>
<label class="settings-form-field-wide">
Source
<input bind:value={source} required autocomplete="off" placeholder="/absolute/path or ssh://git@example.test/org/repository.git" />
<small>Registration validates the source without accessing the filesystem or network.</small>
</label>
<label>
Default ref
<input bind:value={defaultRef} maxlength="512" autocomplete="off" placeholder="main" />
</label>
</div>
<p class="status-message">
SSH credentials and pinned host keys are managed separately in
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Repository Access</a>.
</p>
<div class="settings-action-row">
<button type="submit" disabled={pending}>{pending ? 'Adding…' : 'Add Repository'}</button>
<button type="button" disabled={pending} onclick={() => showAddRepository = false}>Cancel</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if data.repositoriesError}
<p class="section-state error">{data.repositoriesError}</p>
{:else if !data.repositories}
<p class="section-state">Loading Repositories…</p>
{:else if data.repositories.items.length === 0}
<p class="section-state">No Repositories are registered.</p>
{:else}
<div class="settings-repository-table-wrap">
<table class="settings-repository-table">
<thead>
<tr>
<th>Repository</th>
<th>Source</th>
<th>Default ref</th>
<th>Status</th>
<th>Access</th>
</tr>
</thead>
<tbody>
{#each data.repositories.items as repository (repository.repository_key)}
<tr>
<td>
<a class="inline-link" href={workspaceRoute(data.workspaceId, `/repositories/${encodeURIComponent(repository.repository_key)}`)}>
<strong><code>{repository.repository_key}</code></strong>
</a>
</td>
<td>
<span>{sourceLabel(repository.source.kind)}</span>
<small><code>{repository.source.uri}</code></small>
</td>
<td>{repository.default_selector ?? '—'}</td>
<td>{repository.observed_status}</td>
<td>
{#if supportsRepositoryAccess(repository.source.kind)}
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Configure access</a>
{:else}
<span class="settings-muted-action">Not required</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<DeferredPage {data} title="Repositories">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -1,7 +1,8 @@
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const repositories = await loadWorkspaceRepositoryList(
fetch,
params.workspaceId,
@@ -11,4 +12,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
repositories: repositories.data,
repositoriesError: repositories.error,
};
};
});
@@ -0,0 +1,151 @@
<script lang="ts">
import { refreshPageData } from '#lib/workspace/navigation/refresh.ts';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
import { createWorkspaceRepository } from '#lib/workspace/api/repositories.ts';
import type {
CreateWorkspaceRepositoryRequest,
RepositorySourceKind,
} from '#lib/generated/repository-api.ts';
import type { PageData as GeneratedPageData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<GeneratedPageData>;
let { data }: { data: PageData } = $props();
function sourceLabel(kind: RepositorySourceKind): string {
if (kind === 'local_path' || kind === 'file') return 'Local';
if (kind === 'invalid') return 'Invalid';
return 'Remote Git';
}
function supportsRepositoryAccess(kind: RepositorySourceKind): boolean {
return kind === 'ssh' || kind === 'https';
}
let showAddRepository = $state(false);
let repositoryKey = $state('');
let source = $state('');
let defaultRef = $state('');
let pending = $state(false);
let requestError = $state<string | null>(null);
async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault();
pending = true;
requestError = null;
const request: CreateWorkspaceRepositoryRequest = {
repository_key: repositoryKey,
source,
default_ref: defaultRef || null,
};
const result = await createWorkspaceRepository(fetch, data.workspaceId, request);
if (result.data === null) {
requestError = result.error ?? 'Repository creation failed';
pending = false;
return;
}
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await refreshPageData();
pending = false;
}
</script>
<svelte:head>
<title>Repositories · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Repository resources" />
</svelte:head>
<section class="repositories-page" aria-labelledby="repositories-heading">
<header class="page-header-row">
<div>
<p class="eyebrow">owner only</p>
<h1 id="repositories-heading">Repositories</h1>
<p>Register the local and remote Git repositories available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRepository = !showAddRepository}>
{showAddRepository ? 'Close' : 'Add Repository'}
</button>
</header>
{#if showAddRepository}
<form class="settings-repository-form" onsubmit={addRepository}>
<h2>Add Repository</h2>
<div class="settings-form-grid">
<label>
Repository key
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9]" maxlength="64" autocomplete="off" />
<small>1–64 lowercase letters, digits, or hyphens; no leading or trailing hyphen.</small>
</label>
<label class="settings-form-field-wide">
Source
<input bind:value={source} required autocomplete="off" placeholder="/absolute/path or ssh://git@example.test/org/repository.git" />
<small>Registration validates the source without accessing the filesystem or network.</small>
</label>
<label>
Default ref
<input bind:value={defaultRef} maxlength="512" autocomplete="off" placeholder="main" />
</label>
</div>
<p class="status-message">
SSH credentials and pinned host keys are managed separately in
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Repository Access</a>.
</p>
<div class="settings-action-row">
<button type="submit" disabled={pending}>{pending ? 'Adding…' : 'Add Repository'}</button>
<button type="button" disabled={pending} onclick={() => showAddRepository = false}>Cancel</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if data.repositoriesError}
<p class="section-state error">{data.repositoriesError}</p>
{:else if !data.repositories}
<p class="section-state">Loading Repositories…</p>
{:else if data.repositories.items.length === 0}
<p class="section-state">No Repositories are registered.</p>
{:else}
<div class="settings-repository-table-wrap">
<table class="settings-repository-table">
<thead>
<tr>
<th>Repository</th>
<th>Source</th>
<th>Default ref</th>
<th>Status</th>
<th>Access</th>
</tr>
</thead>
<tbody>
{#each data.repositories.items as repository (repository.repository_key)}
<tr>
<td>
<a class="inline-link" href={workspaceRoute(data.workspaceId, `/repositories/${encodeURIComponent(repository.repository_key)}`)}>
<strong><code>{repository.repository_key}</code></strong>
</a>
</td>
<td>
<span>{sourceLabel(repository.source.kind)}</span>
<small><code>{repository.source.uri}</code></small>
</td>
<td>{repository.default_selector ?? '—'}</td>
<td>{repository.observed_status}</td>
<td>
{#if supportsRepositoryAccess(repository.source.kind)}
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Configure access</a>
{:else}
<span class="settings-muted-action">Not required</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
@@ -1,878 +1,13 @@
<script lang="ts">
import { tick, untrack } from 'svelte';
import type {
CreateRepositorySshCredentialRequest,
DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest,
GenerateRepositorySshCredentialRequest,
PutRepositorySshHostTrustRequest,
RepositorySshCredential,
RepositorySshHostTrust,
RepositorySshPublicKey,
RotateRepositorySshCredentialRequest,
} from '#lib/generated/repository-access-api.ts';
import {
REPOSITORY_ACCESS_MAX_RESPONSE_BYTES,
parseRepositorySshCredential,
parseRepositorySshHostTrust,
parseRepositorySshPublicKey,
} from '#lib/workspace/api/repository-access.ts';
import { readBoundedJson } from '#lib/workspace/api/http.ts';
import {
repositoryAccessRequestError,
validateCredentialRotation,
validateGeneratedCredential,
validateHostTrust,
validateImportedCredential,
type RepositoryAccessFieldErrors,
} from '#lib/workspace/repository-access/model.ts';
import type { PageProps } from './$types';
type Notice = { tone: 'success' | 'error'; text: string };
type CredentialForm = 'generate' | 'import' | null;
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
let { data }: PageProps = $props();
const workspaceDefaultCredentialId = 'workspace-default';
function sortCredentials(items: RepositorySshCredential[]): RepositorySshCredential[] {
return [...items].sort((a, b) => {
if (a.credential_id === workspaceDefaultCredentialId) return -1;
if (b.credential_id === workspaceDefaultCredentialId) return 1;
return a.credential_id.localeCompare(b.credential_id);
});
}
let credentials = $state<RepositorySshCredential[]>(sortCredentials(untrack(() => data.credentials)));
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
);
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
let accessProjection = $state(untrack(() => data.accessProjection));
let pageEpoch = 0;
let pendingOperations = $state<string[]>([]);
let rotateCredentialId = $state<string | null>(null);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
const selectedCredential = $derived(
rotateCredentialId ? credentials.find((credential) => credential.credential_id === rotateCredentialId) ?? null : null
);
const sshRepository = $derived(data.repositories?.items.find((repository) => repository.source.kind === 'ssh') ?? null);
const connectionTestHref = $derived(
sshRepository
? `/w/${encodeURIComponent(data.workspaceId)}/repositories/${encodeURIComponent(sshRepository.repository_key)}`
: `/w/${encodeURIComponent(data.workspaceId)}/settings/repositories`
);
let copiedCredentialId = $state<string | null>(null);
let credentialNotice = $state<Notice | null>(null);
let credentialFormNotice = $state<Notice | null>(null);
let credentialRowNotices = $state<Record<string, Notice>>({});
let hostNotice = $state<Notice | null>(null);
let hostFormNotice = $state<Notice | null>(null);
let hostRowNotices = $state<Record<string, Notice>>({});
let publicKeyNotices = $state<Record<string, Notice>>({});
let credentialForm = $state<CredentialForm>(null);
let generateCredentialId = $state('');
let generateCredentialName = $state('');
let generateErrors = $state<RepositoryAccessFieldErrors>({});
let credentialId = $state('');
let credentialName = $state('');
let privateKey = $state('');
let passphrase = $state('');
let importErrors = $state<RepositoryAccessFieldErrors>({});
let rotatePrivateKey = $state('');
let rotatePassphrase = $state('');
let rotateErrors = $state<RepositoryAccessFieldErrors>({});
let generateCredentialIdInput = $state<HTMLInputElement>();
let importCredentialIdInput = $state<HTMLInputElement>();
let rotatePrivateKeyInput = $state<HTMLTextAreaElement>();
let credentialFormElement = $state<HTMLFormElement>();
let rotationFormElement = $state<HTMLFormElement>();
let credentialReturnFocus: HTMLElement | null = null;
let hostEditorOpen = $state(false);
let hostTrustId = $state('');
let hostname = $state('');
let port = $state(22);
let hostKey = $state('');
let hostExpectedFingerprint = $state<string | null>(null);
let hostErrors = $state<RepositoryAccessFieldErrors>({});
let hostTrustIdInput = $state<HTMLInputElement>();
let hostKeyInput = $state<HTMLTextAreaElement>();
let hostFormElement = $state<HTMLFormElement>();
let hostReturnFocus: HTMLElement | null = null;
const editingHostTrust = $derived(
hostExpectedFingerprint === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
);
function resetPageState(next: PageProps['data']): void {
pageEpoch += 1;
credentials = sortCredentials(next.credentials);
publicKeys = Object.fromEntries(next.publicKeys.map((key) => [key.credential_id, key]));
hostTrusts = next.hostTrusts;
accessProjection = next.accessProjection;
pendingOperations = [];
rotateCredentialId = null;
copiedCredentialId = null;
credentialNotice = null;
credentialFormNotice = null;
credentialRowNotices = {};
hostNotice = null;
hostFormNotice = null;
hostRowNotices = {};
publicKeyNotices = {};
credentialForm = null;
generateCredentialId = '';
generateCredentialName = '';
generateErrors = {};
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
importErrors = {};
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
credentialReturnFocus = null;
hostEditorOpen = false;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostReturnFocus = null;
}
$effect(() => {
const next = data;
untrack(() => resetPageState(next));
});
class StaleRepositoryAccessRequestError extends Error {}
function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
}
function operationError(error: unknown, fallback: string): Notice {
return { tone: 'error', text: error instanceof Error ? error.message : fallback };
}
function pendingOperationKey(operation: string): string {
return `${pageEpoch}:${operation}`;
}
function isPending(operation: string): boolean {
return pendingOperations.includes(pendingOperationKey(operation));
}
function startOperation(operation: string): string | null {
const key = pendingOperationKey(operation);
if (pendingOperations.includes(key)) return null;
pendingOperations = [...pendingOperations, key];
return key;
}
function finishOperation(operationKey: string): void {
pendingOperations = pendingOperations.filter((entry) => entry !== operationKey);
}
function isCurrentOperation(operationKey: string): boolean {
return operationKey.startsWith(`${pageEpoch}:`);
}
function requireCurrentOperation(operationKey: string): void {
if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError();
}
function credentialLifecyclePending(credentialId: string): boolean {
return isPending(`copy-${credentialId}`) || isPending(`rotate-${credentialId}`) || isPending(`delete-${credentialId}`);
}
function credentialEditorPending(): boolean {
const credentialRotationPrefix = `${pageEpoch}:rotate-`;
const hostRotationPrefix = `${pageEpoch}:rotate-host-`;
return isPending('generate-credential') || isPending('import-credential') || pendingOperations.some((entry) => entry.startsWith(credentialRotationPrefix) && !entry.startsWith(hostRotationPrefix));
}
function hostEditorPending(): boolean {
return isPending('add-host-trust') || pendingOperations.some((entry) => entry.startsWith(`${pageEpoch}:rotate-host-`));
}
function currentFocus(): HTMLElement | null {
return document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
function focusIsInside(element: HTMLElement | undefined): boolean {
return element?.contains(document.activeElement) ?? false;
}
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
await tick();
const fallback = fallbackId ? document.getElementById(fallbackId) : null;
const destination = fallback ?? (target?.isConnected ? target : null);
destination?.focus();
}
async function focusFirstInvalid(form: HTMLFormElement | undefined): Promise<void> {
await tick();
form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
}
function request<T>(
path: string,
method: string,
body: unknown,
parse: (value: unknown) => T,
operation: string
): Promise<T>;
function request(path: string, method: string, body: unknown, parse: null, operation: string): Promise<void>;
async function request<T>(
path: string,
method: string,
body: unknown,
parse: ((value: unknown) => T) | null,
operation: string
): Promise<T | undefined> {
const epoch = pageEpoch;
const response = await fetch(`${base}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) {
await response.body?.cancel();
throw repositoryAccessRequestError(response.status, operation);
}
if (parse === null) {
if (response.status !== 204) {
await response.body?.cancel();
throw new Error('Repository Access returned an unexpected response body.');
}
await response.body?.cancel();
return undefined;
}
if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.');
const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parse(payload);
}
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
const epoch = pageEpoch;
const response = await fetch(
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
{ headers: { accept: 'application/json' } }
);
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) {
await response.body?.cancel();
throw repositoryAccessRequestError(response.status, 'load this public key');
}
const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parseRepositorySshPublicKey(body);
}
async function refreshPublicKey(credentialId: string): Promise<void> {
const epoch = pageEpoch;
try {
const publicKey = await loadPublicKey(credentialId);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
publicKeys = { ...publicKeys, [credentialId]: publicKey };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError || epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credentialId];
publicKeys = remainingPublicKeys;
publicKeyNotices = {
...publicKeyNotices,
[credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.')
};
}
}
async function openCredentialForm(form: Exclude<CredentialForm, null>) {
credentialReturnFocus = currentFocus();
credentialForm = form;
rotateCredentialId = null;
credentialNotice = null;
credentialFormNotice = null;
generateErrors = {};
importErrors = {};
await tick();
(form === 'generate' ? generateCredentialIdInput : importCredentialIdInput)?.focus();
}
async function closeCredentialForms(restore = true) {
const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(credentialFormElement);
credentialForm = null;
generateCredentialId = '';
generateCredentialName = '';
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
generateErrors = {};
importErrors = {};
credentialFormNotice = null;
credentialReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus);
}
async function generateCredential() {
generateErrors = validateGeneratedCredential({ credentialId: generateCredentialId, name: generateCredentialName });
if (Object.keys(generateErrors).length > 0) {
await focusFirstInvalid(credentialFormElement);
return;
}
const operation = startOperation('generate-credential');
if (!operation) return;
credentialFormNotice = null;
try {
const body: GenerateRepositorySshCredentialRequest = {
operation_id: operationId('credential-generate'),
credential_id: generateCredentialId.trim(),
name: generateCredentialName.trim()
};
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
} finally {
finishOperation(operation);
}
}
async function copyPublicKey(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
const operation = startOperation(`copy-${credential.credential_id}`);
if (!operation) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
try {
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
requireCurrentOperation(operation);
publicKeys = { ...publicKeys, [credential.credential_id]: publicKey };
await navigator.clipboard.writeText(publicKey.public_key);
requireCurrentOperation(operation);
copiedCredentialId = credential.credential_id;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } };
window.setTimeout(() => {
if (isCurrentOperation(operation) && copiedCredentialId === credential.credential_id) copiedCredentialId = null;
}, 1500);
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
} finally {
finishOperation(operation);
}
}
async function createCredential() {
importErrors = validateImportedCredential({ credentialId, name: credentialName, privateKey });
if (Object.keys(importErrors).length > 0) {
await focusFirstInvalid(credentialFormElement);
return;
}
const operation = startOperation('import-credential');
if (!operation) return;
credentialFormNotice = null;
try {
const body: CreateRepositorySshCredentialRequest = {
operation_id: operationId('credential-create'),
credential_id: credentialId.trim(),
name: credentialName.trim(),
private_key: privateKey,
passphrase: passphrase || null
};
const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials, created]);
await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential import failed.');
} finally {
if (isCurrentOperation(operation)) {
privateKey = '';
passphrase = '';
}
finishOperation(operation);
}
}
async function openCredentialRotation(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
credentialReturnFocus = currentFocus();
credentialForm = null;
credentialFormNotice = null;
rotateCredentialId = credential.credential_id;
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
await tick();
rotatePrivateKeyInput?.focus();
}
async function closeCredentialRotation(restore = true) {
const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(rotationFormElement);
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
rotateCredentialId = null;
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
credentialFormNotice = null;
credentialReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
}
async function rotateCredential(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
rotateErrors = validateCredentialRotation(rotatePrivateKey);
if (Object.keys(rotateErrors).length > 0) {
await focusFirstInvalid(rotationFormElement);
return;
}
const operation = startOperation(`rotate-${credential.credential_id}`);
if (!operation) return;
credentialFormNotice = null;
try {
const body: RotateRepositorySshCredentialRequest = {
operation_id: operationId('credential-rotate'),
expected_public_key_fingerprint: credential.public_key_fingerprint,
private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null
};
const rotated = await request(
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
body,
parseRepositorySshCredential,
`rotate ${credential.name}`
);
requireCurrentOperation(operation);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
await refreshPublicKey(rotated.credential_id);
requireCurrentOperation(operation);
finishOperation(operation);
await closeCredentialRotation();
requireCurrentOperation(operation);
credentialRowNotices = {
...credentialRowNotices,
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
};
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential rotation failed.');
} finally {
if (isCurrentOperation(operation)) {
rotatePrivateKey = '';
rotatePassphrase = '';
}
finishOperation(operation);
}
}
async function deleteCredential(credential: RepositorySshCredential) {
if (credential.referenced_repositories.length > 0 || credentialLifecyclePending(credential.credential_id)) return;
const operationName = `delete-${credential.credential_id}`;
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
const operation = startOperation(operationName);
if (!operation) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
try {
const body: DeleteRepositorySshCredentialRequest = {
operation_id: operationId('credential-delete'),
expected_public_key_fingerprint: credential.public_key_fingerprint
};
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
requireCurrentOperation(operation);
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credential.credential_id];
publicKeys = remainingPublicKeys;
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
} finally {
finishOperation(operation);
}
}
async function openNewHostTrust() {
hostReturnFocus = currentFocus();
hostEditorOpen = true;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostFormNotice = null;
await tick();
hostTrustIdInput?.focus();
}
async function editHostTrust(hostTrust: RepositorySshHostTrust) {
hostReturnFocus = currentFocus();
hostEditorOpen = true;
hostTrustId = hostTrust.host_trust_id;
hostname = hostTrust.hostname;
port = hostTrust.port;
hostKey = hostTrust.host_key;
hostExpectedFingerprint = hostTrust.fingerprint;
hostErrors = {};
hostFormNotice = null;
await tick();
hostKeyInput?.focus();
}
async function closeHostEditor(restore = true) {
const returnFocus = hostReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(hostFormElement);
const fallbackId = hostExpectedFingerprint !== null ? `rotate-host-${hostTrustId}` : undefined;
hostEditorOpen = false;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostFormNotice = null;
hostReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
}
async function saveHostTrust() {
hostErrors = validateHostTrust({ hostTrustId, hostname, port, hostKey });
if (Object.keys(hostErrors).length > 0) {
await focusFirstInvalid(hostFormElement);
return;
}
const rotating = hostExpectedFingerprint !== null;
const rotationTarget = editingHostTrust;
if (rotating && !rotationTarget) {
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
return;
}
const operation = startOperation(rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust');
if (!operation) return;
hostFormNotice = null;
try {
const body: PutRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-save'),
host_trust_id: hostTrustId.trim(),
hostname: rotationTarget?.hostname ?? hostname.trim(),
port: rotationTarget?.port ?? port,
host_key: hostKey.trim(),
expected_fingerprint: hostExpectedFingerprint
};
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
requireCurrentOperation(operation);
hostTrusts = rotating
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry)
: [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
finishOperation(operation);
await closeHostEditor();
requireCurrentOperation(operation);
const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` };
if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
else hostNotice = notice;
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
} finally {
finishOperation(operation);
}
}
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
if (hostTrust.referenced_repositories.length > 0) return;
const operationName = `delete-host-${hostTrust.host_trust_id}`;
if (isPending(operationName)) return;
if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return;
const operation = startOperation(operationName);
if (!operation) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
try {
const body: DeleteRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-delete'),
expected_fingerprint: hostTrust.fingerprint
};
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
requireCurrentOperation(operation);
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
} finally {
finishOperation(operation);
}
}
</script>
<svelte:head><title>Repository Access · Yoi Workspace</title></svelte:head>
<div class="repository-access-page" data-testid="repository-access-page">
<section class="repository-access-section" aria-labelledby="repository-bindings-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="repository-bindings-heading">Repository bindings</h2>
<p>Explicit overrides for Repository authentication and host identity.</p>
</div>
</div>
{#if data.accessProjectionError}
<div class="repository-access-empty error" role="alert">
<strong>Repository bindings unavailable</strong>
<p>{data.accessProjectionError}</p>
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry bindings</button>
</div>
{:else if accessProjection && accessProjection.bindings.length === 0}
<div class="repository-access-empty">
<strong>No explicit bindings</strong>
<p>SSH Repositories can still use the Workspace default credential with normal read/write Git access and a unique pinned key that matches their host and port.</p>
</div>
{:else if accessProjection}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Explicit Repository access bindings">
<table class="repository-access-table repository-access-binding-table">
<thead><tr><th>Repository</th><th>Access</th><th>Authentication</th><th>Host identity</th></tr></thead>
<tbody>
{#each accessProjection.bindings as binding (binding.repository_key)}
<tr>
<td><strong>{binding.repository_key}</strong><small>Explicit binding</small></td>
<td>{binding.access === 'read_write' ? 'Read and write' : 'Read only'}</td>
<td><code>{workspaceDefaultCredentialId}</code><small>plus <code>{binding.credential_id}</code></small></td>
<td><code>{binding.host_trust_id}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if accessProjection}
<details class="repository-access-technical">
<summary>Technical details</summary>
<dl>
<div><dt>Projection digest</dt><dd><code>{accessProjection.projection_digest}</code></dd></div>
</dl>
</details>
{/if}
</section>
<section class="repository-access-section" aria-labelledby="credentials-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="credentials-heading">SSH credentials</h2>
<p>Keys this Workspace can offer when it authenticates to an SSH Repository.</p>
</div>
{#if !data.credentialsError}
<div class="repository-access-actions" aria-label="Add SSH credential">
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('generate')}>Generate credential</button>
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('import')}>Import credential</button>
</div>
{/if}
</div>
{#if credentialNotice}
<p class="repository-access-notice {credentialNotice.tone}" role={credentialNotice.tone === 'error' ? 'alert' : 'status'}>{credentialNotice.text}</p>
{/if}
{#if data.credentialsError}
<div class="repository-access-empty error" role="alert">
<strong>SSH credentials unavailable</strong>
<p>{data.credentialsError}</p>
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry credentials</button>
</div>
{:else if credentials.length === 0}
<div class="repository-access-empty">
<strong>No SSH credentials are available</strong>
<p>Generate a credential to get a new public key, or import an existing Ed25519 private key.</p>
</div>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="SSH credentials">
<table class="repository-access-table repository-access-credential-table">
<thead><tr><th>Credential</th><th>Role</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
{#each credentials as credential (credential.credential_id)}
<tr aria-busy={credentialLifecyclePending(credential.credential_id)}>
<td><strong>{credential.name}</strong><code>{credential.credential_id}</code></td>
<td>{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}</td>
<td><code>{credential.public_key_algorithm}</code><code>{credential.public_key_fingerprint}</code></td>
<td>
{#if credential.credential_id === workspaceDefaultCredentialId}
Automatic for all SSH Repositories
{:else if credential.referenced_repositories.length > 0}
Explicit: {credential.referenced_repositories.join(', ')}
{:else}
No explicit references
{/if}
</td>
<td>
<div class="repository-access-row-actions">
<button type="button" class="secondary" disabled={credentialLifecyclePending(credential.credential_id)} onclick={() => void copyPublicKey(credential)}>
{isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
</button>
{#if credential.credential_id !== workspaceDefaultCredentialId}
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={credentialEditorPending() || credentialLifecyclePending(credential.credential_id)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
{#if credential.referenced_repositories.length === 0}
<button type="button" class="danger" disabled={credentialLifecyclePending(credential.credential_id)} onclick={() => void deleteCredential(credential)}>Delete</button>
{/if}
{/if}
</div>
{#if credentialRowNotices[credential.credential_id]}
<small class:field-error={credentialRowNotices[credential.credential_id].tone === 'error'} role={credentialRowNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{credentialRowNotices[credential.credential_id].text}</small>
{/if}
{#if publicKeyNotices[credential.credential_id]}
<small class:field-error={publicKeyNotices[credential.credential_id].tone === 'error'} role={publicKeyNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{publicKeyNotices[credential.credential_id].text}</small>
{:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError}
<small class="field-error" role="alert">{data.defaultPublicKeyError} Use Copy public key to retry.</small>
{:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId}
<small>Remove Repository references before deletion.</small>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if credentialForm === 'generate'}
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('generate-credential')} novalidate onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
<div class="repository-access-form-heading"><div><h3>Generate SSH credential</h3><p>Creates an Ed25519 key. Copy the public key to the Repository provider after saving.</p></div></div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Credential id</span><input bind:this={generateCredentialIdInput} bind:value={generateCredentialId} aria-invalid={Boolean(generateErrors.credentialId)} aria-describedby={generateErrors.credentialId ? 'generate-id-error' : undefined} autocomplete="off" />{#if generateErrors.credentialId}<small id="generate-id-error" class="field-error">{generateErrors.credentialId}</small>{/if}</label>
<label><span>Name</span><input bind:value={generateCredentialName} aria-invalid={Boolean(generateErrors.name)} aria-describedby={generateErrors.name ? 'generate-name-error' : undefined} autocomplete="off" />{#if generateErrors.name}<small id="generate-name-error" class="field-error">{generateErrors.name}</small>{/if}</label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('generate-credential')}>{isPending('generate-credential') ? 'Generating…' : 'Generate credential'}</button><button type="button" class="secondary" disabled={isPending('generate-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
</form>
{:else if credentialForm === 'import'}
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('import-credential')} novalidate onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
<div class="repository-access-form-heading"><div><h3>Import SSH credential</h3><p>The private key and optional passphrase are write-only and cleared after every save attempt.</p></div></div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Credential id</span><input bind:this={importCredentialIdInput} bind:value={credentialId} aria-invalid={Boolean(importErrors.credentialId)} aria-describedby={importErrors.credentialId ? 'import-id-error' : undefined} autocomplete="off" />{#if importErrors.credentialId}<small id="import-id-error" class="field-error">{importErrors.credentialId}</small>{/if}</label>
<label><span>Name</span><input bind:value={credentialName} aria-invalid={Boolean(importErrors.name)} aria-describedby={importErrors.name ? 'import-name-error' : undefined} autocomplete="off" />{#if importErrors.name}<small id="import-name-error" class="field-error">{importErrors.name}</small>{/if}</label>
<label class="wide"><span>OpenSSH private key (Ed25519)</span><textarea bind:value={privateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(importErrors.privateKey)} aria-describedby={importErrors.privateKey ? 'import-key-error' : 'import-key-help'}></textarea><small id="import-key-help">Secret value; it cannot be read back after import.</small>{#if importErrors.privateKey}<small id="import-key-error" class="field-error">{importErrors.privateKey}</small>{/if}</label>
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={passphrase} autocomplete="new-password" data-web-ux-redact /><small>Enter only when the private key is encrypted.</small></label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('import-credential')}>{isPending('import-credential') ? 'Importing…' : 'Import credential'}</button><button type="button" class="secondary" disabled={isPending('import-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
</form>
{/if}
{#if selectedCredential}
<form bind:this={rotationFormElement} class="repository-access-form" aria-busy={isPending(`rotate-${selectedCredential.credential_id}`)} novalidate onsubmit={(event) => { event.preventDefault(); void rotateCredential(selectedCredential); }}>
<div class="repository-access-form-heading">
<div><h3>Rotate {selectedCredential.name}</h3><p>{selectedCredential.referenced_repositories.length > 0 ? `Explicitly referenced by ${selectedCredential.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this credential. '}Every external provider where the current public key is installed must be updated after rotation.</p></div>
<code>{selectedCredential.public_key_fingerprint}</code>
</div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label class="wide"><span>New OpenSSH private key (Ed25519)</span><textarea bind:this={rotatePrivateKeyInput} bind:value={rotatePrivateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(rotateErrors.privateKey)} aria-describedby={rotateErrors.privateKey ? 'rotate-key-error' : 'rotate-key-help'}></textarea><small id="rotate-key-help">Secret value; it cannot be read back after rotation.</small>{#if rotateErrors.privateKey}<small id="rotate-key-error" class="field-error">{rotateErrors.privateKey}</small>{/if}</label>
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" data-web-ux-redact /></label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={credentialLifecyclePending(selectedCredential.credential_id)}>{isPending(`rotate-${selectedCredential.credential_id}`) ? 'Rotating…' : 'Rotate credential'}</button><button type="button" class="secondary" disabled={isPending(`rotate-${selectedCredential.credential_id}`)} onclick={() => void closeCredentialRotation()}>Cancel</button></div>
</form>
{/if}
</section>
<section class="repository-access-section" aria-labelledby="host-trust-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="host-trust-heading">Pinned SSH host keys</h2>
<p>Keys used to verify the identity of a specific SSH host and port.</p>
</div>
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={hostEditorPending()} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if}
</div>
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
{#if data.hostTrustsError}
<div class="repository-access-empty error" role="alert"><strong>Pinned host keys unavailable</strong><p>{data.hostTrustsError}</p><button type="button" class="secondary" onclick={() => window.location.reload()}>Retry pinned keys</button></div>
{:else if hostTrusts.length === 0}
<div class="repository-access-empty">
<strong>No SSH host keys are pinned</strong>
<p>Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.</p>
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'}</a>
</div>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Pinned SSH host keys">
<table class="repository-access-table repository-access-host-table">
<thead><tr><th>Host and port</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
<tr aria-busy={isPending(`rotate-host-${hostTrust.host_trust_id}`) || isPending(`delete-host-${hostTrust.host_trust_id}`)}>
<td><strong>{hostTrust.hostname}:{hostTrust.port}</strong><code>{hostTrust.host_trust_id}</code></td>
<td><code>{hostTrust.key_algorithm}</code><code>{hostTrust.fingerprint}</code></td>
<td>{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}</td>
<td>
<div class="repository-access-row-actions">
<button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={hostEditorPending() || isPending(`delete-host-${hostTrust.host_trust_id}`)} onclick={() => void editHostTrust(hostTrust)}>Rotate key</button>
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`) || isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
</div>
{#if hostRowNotices[hostTrust.host_trust_id]}
<small class:field-error={hostRowNotices[hostTrust.host_trust_id].tone === 'error'} role={hostRowNotices[hostTrust.host_trust_id].tone === 'error' ? 'alert' : 'status'}>{hostRowNotices[hostTrust.host_trust_id].text}</small>
{:else if hostTrust.referenced_repositories.length > 0}
<small>Remove explicit Repository references before deletion.</small>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="repository-access-guidance">A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.</p>
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'}</a>
{#if hostEditorOpen}
<form bind:this={hostFormElement} class="repository-access-form" aria-busy={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} novalidate onsubmit={(event) => { event.preventDefault(); void saveHostTrust(); }}>
<div class="repository-access-form-heading">
<div>
<h3>{hostExpectedFingerprint === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}</h3>
<p>{hostExpectedFingerprint === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}</p>
</div>
{#if hostExpectedFingerprint !== null}<div><small>Current fingerprint</small><code>{editingHostTrust?.fingerprint}</code></div>{/if}
</div>
{#if hostFormNotice}<p class="repository-access-notice {hostFormNotice.tone}" role={hostFormNotice.tone === 'error' ? 'alert' : 'status'}>{hostFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Host trust id</span><input bind:this={hostTrustIdInput} bind:value={hostTrustId} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostTrustId)} aria-describedby={hostErrors.hostTrustId ? 'host-id-error' : undefined} />{#if hostErrors.hostTrustId}<small id="host-id-error" class="field-error">{hostErrors.hostTrustId}</small>{/if}</label>
<label><span>Hostname</span><input bind:value={hostname} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostname)} aria-describedby={hostErrors.hostname ? 'hostname-error' : undefined} />{#if hostErrors.hostname}<small id="hostname-error" class="field-error">{hostErrors.hostname}</small>{/if}</label>
<label><span>Port</span><input type="number" bind:value={port} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.port)} aria-describedby={hostErrors.port ? 'host-port-error' : undefined} />{#if hostErrors.port}<small id="host-port-error" class="field-error">{hostErrors.port}</small>{/if}</label>
<label class="wide"><span>OpenSSH public host key (Ed25519)</span><textarea bind:this={hostKeyInput} bind:value={hostKey} rows="4" aria-invalid={Boolean(hostErrors.hostKey)} aria-describedby={hostErrors.hostKey ? 'host-key-error host-key-help' : 'host-key-help'}></textarea><small id="host-key-help">The new fingerprint is derived from this key after save; compare the key with a trusted source first.</small>{#if hostErrors.hostKey}<small id="host-key-error" class="field-error">{hostErrors.hostKey}</small>{/if}</label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)}>{hostExpectedFingerprint === null ? (isPending('add-host-trust') ? 'Saving…' : 'Pin host key') : (isPending(`rotate-host-${hostTrustId}`) ? 'Saving…' : 'Save new key')}</button><button type="button" class="secondary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} onclick={() => void closeHostEditor()}>Cancel</button></div>
</form>
{/if}
</section>
</div>
<DeferredPage {data} title="Repository Access">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -1,3 +1,4 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseRepositoryAccessProjection,
@@ -13,7 +14,7 @@ import type { Load } from "@sveltejs/kit";
const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default";
export const load = (async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<Load<{workspaceId: string}>>[0]) => {
const workspaceId = params.workspaceId;
const accessProjectionResult = await loadRepositoryAccessPermissionGate(
fetch,
@@ -68,4 +69,4 @@ export const load = (async ({ fetch, params }) => {
accessProjection: accessProjectionResult.data,
accessProjectionError: accessProjectionResult.error,
};
}) satisfies Load<{ workspaceId: string }>;
});
@@ -0,0 +1,895 @@
<script lang="ts">
import { tick, untrack } from 'svelte';
import type {
CreateRepositorySshCredentialRequest,
DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest,
GenerateRepositorySshCredentialRequest,
PutRepositorySshHostTrustRequest,
RepositorySshCredential,
RepositorySshHostTrust,
RepositorySshPublicKey,
RotateRepositorySshCredentialRequest,
} from '#lib/generated/repository-access-api.ts';
import {
REPOSITORY_ACCESS_MAX_RESPONSE_BYTES,
parseRepositorySshCredential,
parseRepositorySshHostTrust,
parseRepositorySshPublicKey,
} from '#lib/workspace/api/repository-access.ts';
import { readBoundedJson } from '#lib/workspace/api/http.ts';
import {
repositoryAccessRequestError,
validateCredentialRotation,
validateGeneratedCredential,
validateHostTrust,
validateImportedCredential,
type RepositoryAccessFieldErrors,
} from '#lib/workspace/repository-access/model.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as GeneratedPageData } from './$types';
type PageProps = { data: ResolvedPageData<GeneratedPageData> };
type Notice = { tone: 'success' | 'error'; text: string };
type CredentialForm = 'generate' | 'import' | null;
let { data }: PageProps = $props();
const workspaceDefaultCredentialId = 'workspace-default';
function sortCredentials(items: RepositorySshCredential[]): RepositorySshCredential[] {
return [...items].sort((a, b) => {
if (a.credential_id === workspaceDefaultCredentialId) return -1;
if (b.credential_id === workspaceDefaultCredentialId) return 1;
return a.credential_id.localeCompare(b.credential_id);
});
}
let credentials = $state<RepositorySshCredential[]>(sortCredentials(untrack(() => data.credentials)));
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
);
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
let accessProjection = $state(untrack(() => data.accessProjection));
let pageEpoch = 0;
let pendingOperations = $state<string[]>([]);
let rotateCredentialId = $state<string | null>(null);
let rotationCredential = $state<RepositorySshCredential | null>(null);
let editorWorkspaceId = untrack(() => data.workspaceId);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
// A rotation owns the credential observed when its editor opened, including CAS metadata.
const selectedCredential = $derived(rotateCredentialId ? rotationCredential : null);
const sshRepository = $derived(data.repositories?.items.find((repository) => repository.source.kind === 'ssh') ?? null);
const connectionTestHref = $derived(
sshRepository
? `/w/${encodeURIComponent(data.workspaceId)}/repositories/${encodeURIComponent(sshRepository.repository_key)}`
: `/w/${encodeURIComponent(data.workspaceId)}/settings/repositories`
);
let copiedCredentialId = $state<string | null>(null);
let credentialNotice = $state<Notice | null>(null);
let credentialFormNotice = $state<Notice | null>(null);
let credentialRowNotices = $state<Record<string, Notice>>({});
let hostNotice = $state<Notice | null>(null);
let hostFormNotice = $state<Notice | null>(null);
let hostRowNotices = $state<Record<string, Notice>>({});
let publicKeyNotices = $state<Record<string, Notice>>({});
let credentialForm = $state<CredentialForm>(null);
let generateCredentialId = $state('');
let generateCredentialName = $state('');
let generateErrors = $state<RepositoryAccessFieldErrors>({});
let credentialId = $state('');
let credentialName = $state('');
let privateKey = $state('');
let passphrase = $state('');
let importErrors = $state<RepositoryAccessFieldErrors>({});
let rotatePrivateKey = $state('');
let rotatePassphrase = $state('');
let rotateErrors = $state<RepositoryAccessFieldErrors>({});
let generateCredentialIdInput = $state<HTMLInputElement>();
let importCredentialIdInput = $state<HTMLInputElement>();
let rotatePrivateKeyInput = $state<HTMLTextAreaElement>();
let credentialFormElement = $state<HTMLFormElement>();
let rotationFormElement = $state<HTMLFormElement>();
let credentialReturnFocus: HTMLElement | null = null;
let hostEditorOpen = $state(false);
let hostTrustId = $state('');
let hostname = $state('');
let port = $state(22);
let hostKey = $state('');
let hostExpectedFingerprint = $state<string | null>(null);
let hostErrors = $state<RepositoryAccessFieldErrors>({});
let hostTrustIdInput = $state<HTMLInputElement>();
let hostKeyInput = $state<HTMLTextAreaElement>();
let hostFormElement = $state<HTMLFormElement>();
let hostReturnFocus: HTMLElement | null = null;
const editingHostTrust = $derived(
hostExpectedFingerprint === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
);
function updateProjections(next: PageProps['data']): void {
credentials = sortCredentials(next.credentials);
publicKeys = Object.fromEntries(next.publicKeys.map((key) => [key.credential_id, key]));
hostTrusts = next.hostTrusts;
accessProjection = next.accessProjection;
}
function resetPageState(): void {
pageEpoch += 1;
pendingOperations = [];
rotateCredentialId = null;
rotationCredential = null;
copiedCredentialId = null;
credentialNotice = null;
credentialFormNotice = null;
credentialRowNotices = {};
hostNotice = null;
hostFormNotice = null;
hostRowNotices = {};
publicKeyNotices = {};
credentialForm = null;
generateCredentialId = '';
generateCredentialName = '';
generateErrors = {};
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
importErrors = {};
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
credentialReturnFocus = null;
hostEditorOpen = false;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostReturnFocus = null;
}
$effect(() => {
const next = data;
const workspaceId = next.workspaceId;
untrack(() => {
updateProjections(next);
if (workspaceId !== editorWorkspaceId) {
editorWorkspaceId = workspaceId;
resetPageState();
}
});
});
class StaleRepositoryAccessRequestError extends Error {}
function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
}
function operationError(error: unknown, fallback: string): Notice {
return { tone: 'error', text: error instanceof Error ? error.message : fallback };
}
function pendingOperationKey(operation: string): string {
return `${pageEpoch}:${operation}`;
}
function isPending(operation: string): boolean {
return pendingOperations.includes(pendingOperationKey(operation));
}
function startOperation(operation: string): string | null {
const key = pendingOperationKey(operation);
if (pendingOperations.includes(key)) return null;
pendingOperations = [...pendingOperations, key];
return key;
}
function finishOperation(operationKey: string): void {
pendingOperations = pendingOperations.filter((entry) => entry !== operationKey);
}
function isCurrentOperation(operationKey: string): boolean {
return operationKey.startsWith(`${pageEpoch}:`);
}
function requireCurrentOperation(operationKey: string): void {
if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError();
}
function credentialLifecyclePending(credentialId: string): boolean {
return isPending(`copy-${credentialId}`) || isPending(`rotate-${credentialId}`) || isPending(`delete-${credentialId}`);
}
function credentialEditorPending(): boolean {
const credentialRotationPrefix = `${pageEpoch}:rotate-`;
const hostRotationPrefix = `${pageEpoch}:rotate-host-`;
return isPending('generate-credential') || isPending('import-credential') || pendingOperations.some((entry) => entry.startsWith(credentialRotationPrefix) && !entry.startsWith(hostRotationPrefix));
}
function hostEditorPending(): boolean {
return isPending('add-host-trust') || pendingOperations.some((entry) => entry.startsWith(`${pageEpoch}:rotate-host-`));
}
function currentFocus(): HTMLElement | null {
return document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
function focusIsInside(element: HTMLElement | undefined): boolean {
return element?.contains(document.activeElement) ?? false;
}
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
await tick();
const fallback = fallbackId ? document.getElementById(fallbackId) : null;
const destination = fallback ?? (target?.isConnected ? target : null);
destination?.focus();
}
async function focusFirstInvalid(form: HTMLFormElement | undefined): Promise<void> {
await tick();
form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
}
function request<T>(
path: string,
method: string,
body: unknown,
parse: (value: unknown) => T,
operation: string
): Promise<T>;
function request(path: string, method: string, body: unknown, parse: null, operation: string): Promise<void>;
async function request<T>(
path: string,
method: string,
body: unknown,
parse: ((value: unknown) => T) | null,
operation: string
): Promise<T | undefined> {
const epoch = pageEpoch;
const response = await fetch(`${base}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) {
await response.body?.cancel();
throw repositoryAccessRequestError(response.status, operation);
}
if (parse === null) {
if (response.status !== 204) {
await response.body?.cancel();
throw new Error('Repository Access returned an unexpected response body.');
}
await response.body?.cancel();
return undefined;
}
if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.');
const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parse(payload);
}
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
const epoch = pageEpoch;
const response = await fetch(
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
{ headers: { accept: 'application/json' } }
);
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) {
await response.body?.cancel();
throw repositoryAccessRequestError(response.status, 'load this public key');
}
const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parseRepositorySshPublicKey(body);
}
async function refreshPublicKey(credentialId: string): Promise<void> {
const epoch = pageEpoch;
try {
const publicKey = await loadPublicKey(credentialId);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
publicKeys = { ...publicKeys, [credentialId]: publicKey };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError || epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credentialId];
publicKeys = remainingPublicKeys;
publicKeyNotices = {
...publicKeyNotices,
[credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.')
};
}
}
async function openCredentialForm(form: Exclude<CredentialForm, null>) {
credentialReturnFocus = currentFocus();
credentialForm = form;
rotateCredentialId = null;
rotationCredential = null;
credentialNotice = null;
credentialFormNotice = null;
generateErrors = {};
importErrors = {};
await tick();
(form === 'generate' ? generateCredentialIdInput : importCredentialIdInput)?.focus();
}
async function closeCredentialForms(restore = true) {
const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(credentialFormElement);
credentialForm = null;
generateCredentialId = '';
generateCredentialName = '';
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
generateErrors = {};
importErrors = {};
credentialFormNotice = null;
credentialReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus);
}
async function generateCredential() {
generateErrors = validateGeneratedCredential({ credentialId: generateCredentialId, name: generateCredentialName });
if (Object.keys(generateErrors).length > 0) {
await focusFirstInvalid(credentialFormElement);
return;
}
const operation = startOperation('generate-credential');
if (!operation) return;
credentialFormNotice = null;
try {
const body: GenerateRepositorySshCredentialRequest = {
operation_id: operationId('credential-generate'),
credential_id: generateCredentialId.trim(),
name: generateCredentialName.trim()
};
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
} finally {
finishOperation(operation);
}
}
async function copyPublicKey(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
const operation = startOperation(`copy-${credential.credential_id}`);
if (!operation) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
try {
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
requireCurrentOperation(operation);
publicKeys = { ...publicKeys, [credential.credential_id]: publicKey };
await navigator.clipboard.writeText(publicKey.public_key);
requireCurrentOperation(operation);
copiedCredentialId = credential.credential_id;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } };
window.setTimeout(() => {
if (isCurrentOperation(operation) && copiedCredentialId === credential.credential_id) copiedCredentialId = null;
}, 1500);
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
} finally {
finishOperation(operation);
}
}
async function createCredential() {
importErrors = validateImportedCredential({ credentialId, name: credentialName, privateKey });
if (Object.keys(importErrors).length > 0) {
await focusFirstInvalid(credentialFormElement);
return;
}
const operation = startOperation('import-credential');
if (!operation) return;
credentialFormNotice = null;
try {
const body: CreateRepositorySshCredentialRequest = {
operation_id: operationId('credential-create'),
credential_id: credentialId.trim(),
name: credentialName.trim(),
private_key: privateKey,
passphrase: passphrase || null
};
const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials, created]);
await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential import failed.');
} finally {
if (isCurrentOperation(operation)) {
privateKey = '';
passphrase = '';
}
finishOperation(operation);
}
}
async function openCredentialRotation(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
credentialReturnFocus = currentFocus();
credentialForm = null;
credentialFormNotice = null;
rotateCredentialId = credential.credential_id;
rotationCredential = { ...credential };
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
await tick();
rotatePrivateKeyInput?.focus();
}
async function closeCredentialRotation(restore = true) {
const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(rotationFormElement);
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
rotateCredentialId = null;
rotationCredential = null;
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
credentialFormNotice = null;
credentialReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
}
async function rotateCredential(credential: RepositorySshCredential) {
if (credentialLifecyclePending(credential.credential_id)) return;
rotateErrors = validateCredentialRotation(rotatePrivateKey);
if (Object.keys(rotateErrors).length > 0) {
await focusFirstInvalid(rotationFormElement);
return;
}
const operation = startOperation(`rotate-${credential.credential_id}`);
if (!operation) return;
credentialFormNotice = null;
try {
const body: RotateRepositorySshCredentialRequest = {
operation_id: operationId('credential-rotate'),
expected_public_key_fingerprint: credential.public_key_fingerprint,
private_key: rotatePrivateKey,
passphrase: rotatePassphrase || null
};
const rotated = await request(
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
'POST',
body,
parseRepositorySshCredential,
`rotate ${credential.name}`
);
requireCurrentOperation(operation);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
await refreshPublicKey(rotated.credential_id);
requireCurrentOperation(operation);
finishOperation(operation);
await closeCredentialRotation();
requireCurrentOperation(operation);
credentialRowNotices = {
...credentialRowNotices,
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
};
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential rotation failed.');
} finally {
if (isCurrentOperation(operation)) {
rotatePrivateKey = '';
rotatePassphrase = '';
}
finishOperation(operation);
}
}
async function deleteCredential(credential: RepositorySshCredential) {
if (credential.referenced_repositories.length > 0 || credentialLifecyclePending(credential.credential_id)) return;
const operationName = `delete-${credential.credential_id}`;
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
const operation = startOperation(operationName);
if (!operation) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
try {
const body: DeleteRepositorySshCredentialRequest = {
operation_id: operationId('credential-delete'),
expected_public_key_fingerprint: credential.public_key_fingerprint
};
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
requireCurrentOperation(operation);
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credential.credential_id];
publicKeys = remainingPublicKeys;
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
} finally {
finishOperation(operation);
}
}
async function openNewHostTrust() {
hostReturnFocus = currentFocus();
hostEditorOpen = true;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostFormNotice = null;
await tick();
hostTrustIdInput?.focus();
}
async function editHostTrust(hostTrust: RepositorySshHostTrust) {
hostReturnFocus = currentFocus();
hostEditorOpen = true;
hostTrustId = hostTrust.host_trust_id;
hostname = hostTrust.hostname;
port = hostTrust.port;
hostKey = hostTrust.host_key;
hostExpectedFingerprint = hostTrust.fingerprint;
hostErrors = {};
hostFormNotice = null;
await tick();
hostKeyInput?.focus();
}
async function closeHostEditor(restore = true) {
const returnFocus = hostReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(hostFormElement);
const fallbackId = hostExpectedFingerprint !== null ? `rotate-host-${hostTrustId}` : undefined;
hostEditorOpen = false;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedFingerprint = null;
hostErrors = {};
hostFormNotice = null;
hostReturnFocus = null;
if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
}
async function saveHostTrust() {
hostErrors = validateHostTrust({ hostTrustId, hostname, port, hostKey });
if (Object.keys(hostErrors).length > 0) {
await focusFirstInvalid(hostFormElement);
return;
}
const rotating = hostExpectedFingerprint !== null;
const rotationTarget = editingHostTrust;
if (rotating && !rotationTarget) {
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
return;
}
const operation = startOperation(rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust');
if (!operation) return;
hostFormNotice = null;
try {
const body: PutRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-save'),
host_trust_id: hostTrustId.trim(),
hostname: hostname.trim(),
port,
host_key: hostKey.trim(),
expected_fingerprint: hostExpectedFingerprint
};
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
requireCurrentOperation(operation);
hostTrusts = rotating
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry)
: [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
finishOperation(operation);
await closeHostEditor();
requireCurrentOperation(operation);
const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` };
if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
else hostNotice = notice;
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
} finally {
finishOperation(operation);
}
}
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
if (hostTrust.referenced_repositories.length > 0) return;
const operationName = `delete-host-${hostTrust.host_trust_id}`;
if (isPending(operationName)) return;
if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return;
const operation = startOperation(operationName);
if (!operation) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
try {
const body: DeleteRepositorySshHostTrustRequest = {
operation_id: operationId('host-trust-delete'),
expected_fingerprint: hostTrust.fingerprint
};
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
requireCurrentOperation(operation);
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
} catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
} finally {
finishOperation(operation);
}
}
</script>
<svelte:head><title>Repository Access · Yoi Workspace</title></svelte:head>
<div class="repository-access-page" data-testid="repository-access-page">
<section class="repository-access-section" aria-labelledby="repository-bindings-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="repository-bindings-heading">Repository bindings</h2>
<p>Explicit overrides for Repository authentication and host identity.</p>
</div>
</div>
{#if data.accessProjectionError}
<div class="repository-access-empty error" role="alert">
<strong>Repository bindings unavailable</strong>
<p>{data.accessProjectionError}</p>
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry bindings</button>
</div>
{:else if accessProjection && accessProjection.bindings.length === 0}
<div class="repository-access-empty">
<strong>No explicit bindings</strong>
<p>SSH Repositories can still use the Workspace default credential with normal read/write Git access and a unique pinned key that matches their host and port.</p>
</div>
{:else if accessProjection}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Explicit Repository access bindings">
<table class="repository-access-table repository-access-binding-table">
<thead><tr><th>Repository</th><th>Access</th><th>Authentication</th><th>Host identity</th></tr></thead>
<tbody>
{#each accessProjection.bindings as binding (binding.repository_key)}
<tr>
<td><strong>{binding.repository_key}</strong><small>Explicit binding</small></td>
<td>{binding.access === 'read_write' ? 'Read and write' : 'Read only'}</td>
<td><code>{workspaceDefaultCredentialId}</code><small>plus <code>{binding.credential_id}</code></small></td>
<td><code>{binding.host_trust_id}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if accessProjection}
<details class="repository-access-technical">
<summary>Technical details</summary>
<dl>
<div><dt>Projection digest</dt><dd><code>{accessProjection.projection_digest}</code></dd></div>
</dl>
</details>
{/if}
</section>
<section class="repository-access-section" aria-labelledby="credentials-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="credentials-heading">SSH credentials</h2>
<p>Keys this Workspace can offer when it authenticates to an SSH Repository.</p>
</div>
{#if !data.credentialsError}
<div class="repository-access-actions" aria-label="Add SSH credential">
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('generate')}>Generate credential</button>
<button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('import')}>Import credential</button>
</div>
{/if}
</div>
{#if credentialNotice}
<p class="repository-access-notice {credentialNotice.tone}" role={credentialNotice.tone === 'error' ? 'alert' : 'status'}>{credentialNotice.text}</p>
{/if}
{#if data.credentialsError}
<div class="repository-access-empty error" role="alert">
<strong>SSH credentials unavailable</strong>
<p>{data.credentialsError}</p>
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry credentials</button>
</div>
{:else if credentials.length === 0}
<div class="repository-access-empty">
<strong>No SSH credentials are available</strong>
<p>Generate a credential to get a new public key, or import an existing Ed25519 private key.</p>
</div>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="SSH credentials">
<table class="repository-access-table repository-access-credential-table">
<thead><tr><th>Credential</th><th>Role</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
{#each credentials as credential (credential.credential_id)}
<tr aria-busy={credentialLifecyclePending(credential.credential_id)}>
<td><strong>{credential.name}</strong><code>{credential.credential_id}</code></td>
<td>{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}</td>
<td><code>{credential.public_key_algorithm}</code><code>{credential.public_key_fingerprint}</code></td>
<td>
{#if credential.credential_id === workspaceDefaultCredentialId}
Automatic for all SSH Repositories
{:else if credential.referenced_repositories.length > 0}
Explicit: {credential.referenced_repositories.join(', ')}
{:else}
No explicit references
{/if}
</td>
<td>
<div class="repository-access-row-actions">
<button type="button" class="secondary" disabled={credentialLifecyclePending(credential.credential_id)} onclick={() => void copyPublicKey(credential)}>
{isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
</button>
{#if credential.credential_id !== workspaceDefaultCredentialId}
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={credentialEditorPending() || credentialLifecyclePending(credential.credential_id)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
{#if credential.referenced_repositories.length === 0}
<button type="button" class="danger" disabled={credentialLifecyclePending(credential.credential_id)} onclick={() => void deleteCredential(credential)}>Delete</button>
{/if}
{/if}
</div>
{#if credentialRowNotices[credential.credential_id]}
<small class:field-error={credentialRowNotices[credential.credential_id].tone === 'error'} role={credentialRowNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{credentialRowNotices[credential.credential_id].text}</small>
{/if}
{#if publicKeyNotices[credential.credential_id]}
<small class:field-error={publicKeyNotices[credential.credential_id].tone === 'error'} role={publicKeyNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{publicKeyNotices[credential.credential_id].text}</small>
{:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError}
<small class="field-error" role="alert">{data.defaultPublicKeyError} Use Copy public key to retry.</small>
{:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId}
<small>Remove Repository references before deletion.</small>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if credentialForm === 'generate'}
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('generate-credential')} novalidate onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
<div class="repository-access-form-heading"><div><h3>Generate SSH credential</h3><p>Creates an Ed25519 key. Copy the public key to the Repository provider after saving.</p></div></div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Credential id</span><input bind:this={generateCredentialIdInput} bind:value={generateCredentialId} aria-invalid={Boolean(generateErrors.credentialId)} aria-describedby={generateErrors.credentialId ? 'generate-id-error' : undefined} autocomplete="off" />{#if generateErrors.credentialId}<small id="generate-id-error" class="field-error">{generateErrors.credentialId}</small>{/if}</label>
<label><span>Name</span><input bind:value={generateCredentialName} aria-invalid={Boolean(generateErrors.name)} aria-describedby={generateErrors.name ? 'generate-name-error' : undefined} autocomplete="off" />{#if generateErrors.name}<small id="generate-name-error" class="field-error">{generateErrors.name}</small>{/if}</label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('generate-credential')}>{isPending('generate-credential') ? 'Generating…' : 'Generate credential'}</button><button type="button" class="secondary" disabled={isPending('generate-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
</form>
{:else if credentialForm === 'import'}
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('import-credential')} novalidate onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
<div class="repository-access-form-heading"><div><h3>Import SSH credential</h3><p>The private key and optional passphrase are write-only and cleared after every save attempt.</p></div></div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Credential id</span><input bind:this={importCredentialIdInput} bind:value={credentialId} aria-invalid={Boolean(importErrors.credentialId)} aria-describedby={importErrors.credentialId ? 'import-id-error' : undefined} autocomplete="off" />{#if importErrors.credentialId}<small id="import-id-error" class="field-error">{importErrors.credentialId}</small>{/if}</label>
<label><span>Name</span><input bind:value={credentialName} aria-invalid={Boolean(importErrors.name)} aria-describedby={importErrors.name ? 'import-name-error' : undefined} autocomplete="off" />{#if importErrors.name}<small id="import-name-error" class="field-error">{importErrors.name}</small>{/if}</label>
<label class="wide"><span>OpenSSH private key (Ed25519)</span><textarea bind:value={privateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(importErrors.privateKey)} aria-describedby={importErrors.privateKey ? 'import-key-error' : 'import-key-help'}></textarea><small id="import-key-help">Secret value; it cannot be read back after import.</small>{#if importErrors.privateKey}<small id="import-key-error" class="field-error">{importErrors.privateKey}</small>{/if}</label>
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={passphrase} autocomplete="new-password" data-web-ux-redact /><small>Enter only when the private key is encrypted.</small></label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('import-credential')}>{isPending('import-credential') ? 'Importing…' : 'Import credential'}</button><button type="button" class="secondary" disabled={isPending('import-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
</form>
{/if}
{#if selectedCredential}
<form bind:this={rotationFormElement} class="repository-access-form" aria-busy={isPending(`rotate-${selectedCredential.credential_id}`)} novalidate onsubmit={(event) => { event.preventDefault(); void rotateCredential(selectedCredential); }}>
<div class="repository-access-form-heading">
<div><h3>Rotate {selectedCredential.name}</h3><p>{selectedCredential.referenced_repositories.length > 0 ? `Explicitly referenced by ${selectedCredential.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this credential. '}Every external provider where the current public key is installed must be updated after rotation.</p></div>
<code>{selectedCredential.public_key_fingerprint}</code>
</div>
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label class="wide"><span>New OpenSSH private key (Ed25519)</span><textarea bind:this={rotatePrivateKeyInput} bind:value={rotatePrivateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(rotateErrors.privateKey)} aria-describedby={rotateErrors.privateKey ? 'rotate-key-error' : 'rotate-key-help'}></textarea><small id="rotate-key-help">Secret value; it cannot be read back after rotation.</small>{#if rotateErrors.privateKey}<small id="rotate-key-error" class="field-error">{rotateErrors.privateKey}</small>{/if}</label>
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" data-web-ux-redact /></label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={credentialLifecyclePending(selectedCredential.credential_id)}>{isPending(`rotate-${selectedCredential.credential_id}`) ? 'Rotating…' : 'Rotate credential'}</button><button type="button" class="secondary" disabled={isPending(`rotate-${selectedCredential.credential_id}`)} onclick={() => void closeCredentialRotation()}>Cancel</button></div>
</form>
{/if}
</section>
<section class="repository-access-section" aria-labelledby="host-trust-heading">
<div class="repository-access-section-heading">
<div>
<h2 id="host-trust-heading">Pinned SSH host keys</h2>
<p>Keys used to verify the identity of a specific SSH host and port.</p>
</div>
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={hostEditorPending()} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if}
</div>
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
{#if data.hostTrustsError}
<div class="repository-access-empty error" role="alert"><strong>Pinned host keys unavailable</strong><p>{data.hostTrustsError}</p><button type="button" class="secondary" onclick={() => window.location.reload()}>Retry pinned keys</button></div>
{:else if hostTrusts.length === 0}
<div class="repository-access-empty">
<strong>No SSH host keys are pinned</strong>
<p>Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.</p>
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'}</a>
</div>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Pinned SSH host keys">
<table class="repository-access-table repository-access-host-table">
<thead><tr><th>Host and port</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
<tr aria-busy={isPending(`rotate-host-${hostTrust.host_trust_id}`) || isPending(`delete-host-${hostTrust.host_trust_id}`)}>
<td><strong>{hostTrust.hostname}:{hostTrust.port}</strong><code>{hostTrust.host_trust_id}</code></td>
<td><code>{hostTrust.key_algorithm}</code><code>{hostTrust.fingerprint}</code></td>
<td>{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}</td>
<td>
<div class="repository-access-row-actions">
<button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={hostEditorPending() || isPending(`delete-host-${hostTrust.host_trust_id}`)} onclick={() => void editHostTrust(hostTrust)}>Rotate key</button>
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`) || isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
</div>
{#if hostRowNotices[hostTrust.host_trust_id]}
<small class:field-error={hostRowNotices[hostTrust.host_trust_id].tone === 'error'} role={hostRowNotices[hostTrust.host_trust_id].tone === 'error' ? 'alert' : 'status'}>{hostRowNotices[hostTrust.host_trust_id].text}</small>
{:else if hostTrust.referenced_repositories.length > 0}
<small>Remove explicit Repository references before deletion.</small>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="repository-access-guidance">A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.</p>
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'}</a>
{#if hostEditorOpen}
<form bind:this={hostFormElement} class="repository-access-form" aria-busy={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} novalidate onsubmit={(event) => { event.preventDefault(); void saveHostTrust(); }}>
<div class="repository-access-form-heading">
<div>
<h3>{hostExpectedFingerprint === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}</h3>
<p>{hostExpectedFingerprint === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}</p>
</div>
{#if hostExpectedFingerprint !== null}<div><small>Current fingerprint</small><code>{hostExpectedFingerprint}</code></div>{/if}
</div>
{#if hostFormNotice}<p class="repository-access-notice {hostFormNotice.tone}" role={hostFormNotice.tone === 'error' ? 'alert' : 'status'}>{hostFormNotice.text}</p>{/if}
<div class="repository-access-form-grid">
<label><span>Host trust id</span><input bind:this={hostTrustIdInput} bind:value={hostTrustId} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostTrustId)} aria-describedby={hostErrors.hostTrustId ? 'host-id-error' : undefined} />{#if hostErrors.hostTrustId}<small id="host-id-error" class="field-error">{hostErrors.hostTrustId}</small>{/if}</label>
<label><span>Hostname</span><input bind:value={hostname} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.hostname)} aria-describedby={hostErrors.hostname ? 'hostname-error' : undefined} />{#if hostErrors.hostname}<small id="hostname-error" class="field-error">{hostErrors.hostname}</small>{/if}</label>
<label><span>Port</span><input type="number" bind:value={port} disabled={hostExpectedFingerprint !== null} aria-invalid={Boolean(hostErrors.port)} aria-describedby={hostErrors.port ? 'host-port-error' : undefined} />{#if hostErrors.port}<small id="host-port-error" class="field-error">{hostErrors.port}</small>{/if}</label>
<label class="wide"><span>OpenSSH public host key (Ed25519)</span><textarea bind:this={hostKeyInput} bind:value={hostKey} rows="4" aria-invalid={Boolean(hostErrors.hostKey)} aria-describedby={hostErrors.hostKey ? 'host-key-error host-key-help' : 'host-key-help'}></textarea><small id="host-key-help">The new fingerprint is derived from this key after save; compare the key with a trusted source first.</small>{#if hostErrors.hostKey}<small id="host-key-error" class="field-error">{hostErrors.hostKey}</small>{/if}</label>
</div>
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)}>{hostExpectedFingerprint === null ? (isPending('add-host-trust') ? 'Saving…' : 'Pin host key') : (isPending(`rotate-host-${hostTrustId}`) ? 'Saving…' : 'Save new key')}</button><button type="button" class="secondary" disabled={hostExpectedFingerprint === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} onclick={() => void closeHostEditor()}>Cancel</button></div>
</form>
{/if}
</section>
</div>
@@ -9,8 +9,10 @@ import {
within,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import RepositoryAccessPage from "./+page.svelte";
import type { PageProps } from "./$types";
import RepositoryAccessPage from "./PageContent.svelte";
import type { ComponentProps } from "svelte";
type PageProps = ComponentProps<typeof RepositoryAccessPage>;
const defaultCredential = {
credential_id: "workspace-default",
@@ -92,7 +94,6 @@ function deferred<T>() {
function pageProps(data = pageData()): PageProps {
return {
data,
params: { workspaceId: data.workspaceId },
} as unknown as PageProps;
}
@@ -106,6 +107,106 @@ afterEach(() => {
vi.restoreAllMocks();
});
test.each(["Generate credential", "Import credential"])(
"%s and new host drafts survive same-Workspace refresh and reset on Workspace switch",
async (action) => {
const { rerender } = renderPage();
await fireEvent.click(screen.getByRole("button", { name: action }));
await fireEvent.input(screen.getByRole("textbox", { name: "Credential id" }), {
target: { value: "draft-credential" },
});
await fireEvent.input(screen.getByRole("textbox", { name: "Name" }), {
target: { value: "Draft name" },
});
if (action === "Import credential") {
await fireEvent.input(screen.getByRole("textbox", { name: /^OpenSSH private key/ }), {
target: { value: "draft-private-key" },
});
await fireEvent.input(screen.getByLabelText(/^Passphrase \(optional\)/), {
target: { value: "draft-passphrase" },
});
}
await fireEvent.click(screen.getByRole("button", { name: "Add pinned key" }));
await fireEvent.input(screen.getByRole("textbox", { name: "Host trust id" }), {
target: { value: "draft-host" },
});
const refreshed = pageData();
refreshed.credentials[1].name = "Refreshed deploy name";
refreshed.accessProjection.projection_digest = "sha256:refreshed";
await rerender(pageProps(refreshed));
expect(screen.getByText("Refreshed deploy name")).not.toBeNull();
expect(screen.getByText("sha256:refreshed")).not.toBeNull();
expect(screen.getByRole("textbox", { name: "Credential id" })).toHaveProperty("value", "draft-credential");
expect(screen.getByRole("textbox", { name: "Name" })).toHaveProperty("value", "Draft name");
expect(screen.getByRole("textbox", { name: "Host trust id" })).toHaveProperty("value", "draft-host");
if (action === "Import credential") {
expect(screen.getByRole("textbox", { name: /^OpenSSH private key/ })).toHaveProperty("value", "draft-private-key");
expect(screen.getByLabelText(/^Passphrase \(optional\)/)).toHaveProperty("value", "draft-passphrase");
}
await rerender(pageProps(pageData("workspace-2")));
expect(screen.queryByRole("textbox", { name: "Credential id" })).toBeNull();
expect(screen.queryByRole("textbox", { name: "Host trust id" })).toBeNull();
await fireEvent.click(screen.getByRole("button", { name: action }));
expect(screen.getByRole("textbox", { name: "Credential id" })).toHaveProperty("value", "");
expect(screen.getByRole("textbox", { name: "Name" })).toHaveProperty("value", "");
if (action === "Import credential") {
expect(screen.getByRole("textbox", { name: /^OpenSSH private key/ })).toHaveProperty("value", "");
expect(screen.getByLabelText(/^Passphrase \(optional\)/)).toHaveProperty("value", "");
}
},
);
test("same-Workspace refresh preserves rotation drafts and their observed CAS fingerprints", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(null, { status: 409 })
);
vi.stubGlobal("fetch", fetchMock);
const { rerender } = renderPage();
const credentialRow = screen.getByText("Deploy key").closest("tr")!;
await fireEvent.click(within(credentialRow).getByRole("button", { name: "Rotate" }));
await fireEvent.input(screen.getByRole("textbox", { name: /^New OpenSSH private key/ }), {
target: { value: "-----BEGIN OPENSSH PRIVATE KEY-----" },
});
await fireEvent.input(screen.getByLabelText(/^Passphrase \(optional\)/), {
target: { value: "rotation-passphrase" },
});
await fireEvent.click(screen.getByRole("button", { name: "Rotate key" }));
await fireEvent.input(screen.getByRole("textbox", { name: /^OpenSSH public host key/ }), {
target: { value: "ssh-ed25519 AAAA-draft" },
});
const refreshed = pageData();
refreshed.credentials[1].public_key_fingerprint = "SHA256:deploy-refreshed";
refreshed.hostTrusts[0].fingerprint = "SHA256:host-refreshed";
refreshed.hostTrusts[0].hostname = "changed.example.test";
refreshed.hostTrusts[0].port = 2222;
await rerender(pageProps(refreshed));
expect(within(credentialRow).getByText("SHA256:deploy-refreshed")).not.toBeNull();
expect(screen.getByText("changed.example.test:2222")).not.toBeNull();
expect(screen.getByRole("textbox", { name: /^New OpenSSH private key/ })).toHaveProperty("value", "-----BEGIN OPENSSH PRIVATE KEY-----");
expect(screen.getByLabelText(/^Passphrase \(optional\)/)).toHaveProperty("value", "rotation-passphrase");
expect(screen.getByRole("textbox", { name: /^OpenSSH public host key/ })).toHaveProperty("value", "ssh-ed25519 AAAA-draft");
expect(screen.getByText("Current fingerprint").parentElement?.textContent).toContain("SHA256:host-current");
await fireEvent.click(screen.getByRole("button", { name: "Rotate credential" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
expected_public_key_fingerprint: "SHA256:deploy",
passphrase: "rotation-passphrase",
});
await waitFor(() => expect(screen.getByRole("button", { name: "Rotate credential" })).toHaveProperty("disabled", false));
await fireEvent.click(screen.getByRole("button", { name: "Save new key" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
expected_fingerprint: "SHA256:host-current",
hostname: "github.com",
port: 22,
host_key: "ssh-ed25519 AAAA-draft",
});
await waitFor(() => expect(screen.getByRole("button", { name: "Save new key" })).toHaveProperty("disabled", false));
await rerender(pageProps(pageData("workspace-2")));
expect(screen.queryByRole("button", { name: "Rotate credential" })).toBeNull();
expect(screen.queryByRole("button", { name: "Save new key" })).toBeNull();
});
test("opens disclosures with focus, focuses invalid fields, and restores trigger focus on cancel", async () => {
renderPage();
const trigger = screen.getByRole("button", { name: "Generate credential" });
@@ -147,7 +248,7 @@ test("fences a deferred editor while an unrelated host draft remains active afte
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
renderPage();
const { rerender } = renderPage();
const trigger = screen.getByRole("button", { name: "Generate credential" });
trigger.focus();
@@ -191,6 +292,12 @@ test("fences a deferred editor while an unrelated host draft remains active afte
await fireEvent.input(hostDraft, { target: { value: "draft-host" } });
hostDraft.focus();
await rerender(pageProps(pageData()));
expect(screen.getByRole("button", { name: "Generating…" })).toHaveProperty("disabled", true);
expect(screen.getByRole("textbox", { name: "Credential id" })).toHaveProperty("value", "generated");
expect(hostDraft).toHaveProperty("value", "draft-host");
expect(fetchMock).toHaveBeenCalledTimes(1);
createResponse.resolve(Response.json({
...deployCredential,
credential_id: "generated",
@@ -1,433 +1,10 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type {
RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource,
} from '#lib/generated/runtime-api.ts';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '#lib/workspace/api/runtime-management.ts';
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
import { provisionWorkspaceSigningIdentity } from '#lib/workspace/settings/profile-api.ts';
import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props();
let runtimePublicBundle = $state('');
let displayName = $state('');
let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
}
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') {
return `Signed ping succeeded · protocol v${result.actual_protocol_version}`;
}
switch (result.failure_kind) {
case 'authentication': return 'Authentication failed';
case 'authorization': return 'Permission or Workspace scope rejected';
case 'network_unreachable': return 'Runtime unreachable';
case 'timeout': return 'Connection timed out';
case 'tls_or_transport': return 'TLS or transport failed';
case 'malformed_response': return 'Runtime returned an invalid ping response';
case 'protocol_version_mismatch':
return `Incompatible protocol · expected v${result.expected_protocol_version}, received v${result.actual_protocol_version ?? 'unknown'}`;
case 'runtime_identity_mismatch': return 'Runtime identity mismatch';
case 'configuration': return 'Runtime connection test is not configured';
default: return 'Connection test failed';
}
}
function managementLabel(runtime: WorkspaceRuntimeResource): string {
if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote';
return 'Observed';
}
function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
}
function workspacePublicBundle(): string {
return data.signingIdentity?.public_bundle
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
: '';
}
function workspaceBundleFilename(): string {
return `workspace-${data.workspaceId}-public-bundle.json`;
}
async function provisionSigningIdentity(): Promise<void> {
requestError = null;
requestNotice = null;
busyRuntimeId = 'provision-workspace-identity';
try {
await provisionWorkspaceSigningIdentity(data.workspaceId);
await invalidateAll();
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function copyWorkspaceBundle(): Promise<void> {
requestError = null;
try {
await navigator.clipboard.writeText(workspacePublicBundle());
} catch {
requestError = 'Workspace public bundle could not be copied';
}
}
async function previewRuntimeFingerprint(): Promise<void> {
requestError = null;
runtimeFingerprint = null;
busyRuntimeId = 'preview';
try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key);
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
requestNotice = null;
busyRuntimeId = 'create';
try {
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
if (runtimeFingerprint !== currentFingerprint) {
throw new Error('Preview the Runtime public key fingerprint before registration');
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null,
endpoint,
expected_binding_id: null,
});
runtimePublicBundle = '';
runtimeFingerprint = null;
displayName = '';
endpoint = '';
showAddRuntime = false;
requestNotice = 'Runtime registered for this Workspace. Run Test to observe a signed ping.';
await invalidateAll();
} catch (error) {
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally {
busyRuntimeId = null;
}
}
function currentTestResult(
runtime: WorkspaceRuntimeResource,
): RuntimeConnectionTestResponse | undefined {
// 登録し直したRuntimeへ古い接続テストを流用しない。key一致やchecked_atではなく
// 現在のWorkspaceとbinding identityを表示条件にする。
const result = testResults[runtime.runtime_id];
return result?.workspace_id === data.workspaceId &&
result?.binding_id === runtime.management?.binding?.binding_id
? result
: undefined;
}
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
const bindingId = runtime.management?.binding?.binding_id;
if (typeof bindingId !== 'string') return;
const workspaceId = data.workspaceId;
const generation = ++connectionTestGeneration;
requestError = null;
const { [runtime.runtime_id]: _previousResult, ...remainingResults } = testResults;
testResults = remainingResults;
busyRuntimeId = runtime.runtime_id;
try {
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
if (
workspaceId !== data.workspaceId ||
generation !== connectionTestGeneration ||
result.binding_id !== bindingId
) {
await invalidateAll();
return;
}
testResults = { ...testResults, [runtime.runtime_id]: result };
await invalidateAll();
} catch (error) {
if (generation === connectionTestGeneration) {
requestError = error instanceof Error ? error.message : String(error);
}
} finally {
if (generation === connectionTestGeneration) {
busyRuntimeId = null;
}
}
}
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Runtimes · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime resources" />
</svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row">
<div>
<h1 id="runtimes-heading">Runtimes</h1>
<p>Register and inspect the execution backends available to this Workspace.</p>
</div>
{#if data.workspace.permissions.manage_runtimes}
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{/if}
</header>
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<header>
<h2>Connect a remote Runtime</h2>
<p>
This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
their trust entries are not replaced.
</p>
</header>
<section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
<h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
<p>
Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
when starting the Runtime.
</p>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.identity.state === 'pending_provisioning'}
<p>This Workspace does not have an active signing identity yet.</p>
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => void provisionSigningIdentity()}
>
{busyRuntimeId === 'provision-workspace-identity' ? 'Provisioning…' : 'Provision Workspace identity'}
</button>
{:else if data.signingIdentity?.public_bundle}
<p>
Save the bundle as <code>{workspaceBundleFilename()}</code> on the Runtime host. It contains no
private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" disabled={busyRuntimeId !== null} onclick={copyWorkspaceBundle}>
Copy Workspace public bundle
</button>
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
<small>
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
service. Existing Workspace trust entries are preserved.
</small>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-to-workspace-heading">
<h3 id="runtime-to-workspace-heading">2. Verify the Runtime identity</h3>
<p>
On the Runtime host, run <code>yoi-runtime identity show --json</code> with the same Runtime storage
options, then paste the public bundle below.
</p>
<div class="settings-form-grid">
<label class="settings-form-wide">
Runtime public bundle
<textarea
bind:value={runtimePublicBundle}
oninput={() => {
runtimeFingerprint = null;
}}
required
rows="5"
spellcheck="false"
placeholder={runtimeBundlePlaceholder}
></textarea>
</label>
<button class="settings-form-wide" type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
Preview fingerprint
</button>
{#if runtimeFingerprint}
<dl class="runtime-facts">
<div>
<dt>Runtime fingerprint</dt>
<dd><code>{runtimeFingerprint}</code></dd>
</div>
</dl>
{/if}
</div>
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-connection-heading">
<h3 id="runtime-connection-heading">3. Register the connection</h3>
<div class="settings-form-grid">
<label>
Display name
<input bind:value={displayName} autocomplete="off" />
</label>
<label>
Endpoint
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<p>
Registration stores this Workspace-scoped binding. After it appears in the list, run
<strong>Test</strong> to observe a signed ping. Each request authenticates independently.
</p>
</section>
<div class="settings-action-row">
<button
type="submit"
disabled={busyRuntimeId !== null || !runtimeFingerprint}
>Register Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel
</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
{:else if !data.runtimes}
<p class="section-state">Loading Runtimes…</p>
{:else if data.runtimes.items.length === 0}
<p class="section-state">No Runtimes are visible.</p>
{:else}
<div class="settings-runtime-table-wrap">
<table class="settings-runtime-table">
<thead>
<tr>
<th>Runtime</th>
<th>Kind</th>
<th>Status</th>
<th>Platform</th>
<th>Management</th>
<th>Workdirs</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each data.runtimes.items as runtime}
<tr class:inactive={Boolean(runtime.management?.binding?.revoked_at) || currentTestResult(runtime)?.status === 'failed'}>
<td>
<strong>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
{runtime.label}
</a>
</strong>
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
<td>
{runtime.management?.binding?.revoked_at ? 'Revoked' : currentTestResult(runtime) ? connectionTestSummary(currentTestResult(runtime)!) : 'Not tested'}
</td>
<td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
</a>
</td>
<td>
<div class="settings-action-row">
{#if runtime.management?.config_managed && !runtime.management.binding?.revoked_at}
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
</td>
</tr>
{@const currentResult = currentTestResult(runtime)}
{#if (runtime.diagnostics ?? []).length > 0 || currentResult}
<tr class="settings-runtime-detail-row">
<td colspan="7">
{#if (runtime.diagnostics ?? []).length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics ?? [] as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{#if currentResult}
<div class:failed={currentResult.status === 'failed'} class="settings-test-result">
<strong>Connection test: {connectionTestSummary(currentResult)}</strong>
{#if (currentResult.diagnostics ?? [])[0]}
<span>{(currentResult.diagnostics ?? [])[0].message}</span>
{/if}
<small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
</div>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<DeferredPage {data} title="Runtimes">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -1,9 +1,10 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkspaceRuntimeList } from "#lib/workspace/api/runtime-management.ts";
import { parseWorkspaceSigningIdentityResponse } from "#lib/workspace/settings/profile-api.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const runtimes = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/runtimes"),
@@ -37,4 +38,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
};
};
});
@@ -0,0 +1,436 @@
<script lang="ts">
import { refreshPageData } from '#lib/workspace/navigation/refresh.ts';
import type {
RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource,
} from '#lib/generated/runtime-api.ts';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '#lib/workspace/api/runtime-management.ts';
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
import { provisionWorkspaceSigningIdentity } from '#lib/workspace/settings/profile-api.ts';
import type { PageData as GeneratedPageData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<GeneratedPageData>;
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: { data: PageData } = $props();
let runtimePublicBundle = $state('');
let displayName = $state('');
let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
}
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') {
return `Signed ping succeeded · protocol v${result.actual_protocol_version}`;
}
switch (result.failure_kind) {
case 'authentication': return 'Authentication failed';
case 'authorization': return 'Permission or Workspace scope rejected';
case 'network_unreachable': return 'Runtime unreachable';
case 'timeout': return 'Connection timed out';
case 'tls_or_transport': return 'TLS or transport failed';
case 'malformed_response': return 'Runtime returned an invalid ping response';
case 'protocol_version_mismatch':
return `Incompatible protocol · expected v${result.expected_protocol_version}, received v${result.actual_protocol_version ?? 'unknown'}`;
case 'runtime_identity_mismatch': return 'Runtime identity mismatch';
case 'configuration': return 'Runtime connection test is not configured';
default: return 'Connection test failed';
}
}
function managementLabel(runtime: WorkspaceRuntimeResource): string {
if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote';
return 'Observed';
}
function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
}
function workspacePublicBundle(): string {
return data.signingIdentity?.public_bundle
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
: '';
}
function workspaceBundleFilename(): string {
return `workspace-${data.workspaceId}-public-bundle.json`;
}
async function provisionSigningIdentity(): Promise<void> {
requestError = null;
requestNotice = null;
busyRuntimeId = 'provision-workspace-identity';
try {
await provisionWorkspaceSigningIdentity(data.workspaceId);
await refreshPageData();
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function copyWorkspaceBundle(): Promise<void> {
requestError = null;
try {
await navigator.clipboard.writeText(workspacePublicBundle());
} catch {
requestError = 'Workspace public bundle could not be copied';
}
}
async function previewRuntimeFingerprint(): Promise<void> {
requestError = null;
runtimeFingerprint = null;
busyRuntimeId = 'preview';
try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key);
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
requestNotice = null;
busyRuntimeId = 'create';
try {
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
if (runtimeFingerprint !== currentFingerprint) {
throw new Error('Preview the Runtime public key fingerprint before registration');
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null,
endpoint,
expected_binding_id: null,
});
runtimePublicBundle = '';
runtimeFingerprint = null;
displayName = '';
endpoint = '';
showAddRuntime = false;
requestNotice = 'Runtime registered for this Workspace. Run Test to observe a signed ping.';
await refreshPageData();
} catch (error) {
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally {
busyRuntimeId = null;
}
}
function currentTestResult(
runtime: WorkspaceRuntimeResource,
): RuntimeConnectionTestResponse | undefined {
// 登録し直したRuntimeへ古い接続テストを流用しない。key一致やchecked_atではなく
// 現在のWorkspaceとbinding identityを表示条件にする。
const result = testResults[runtime.runtime_id];
return result?.workspace_id === data.workspaceId &&
result?.binding_id === runtime.management?.binding?.binding_id
? result
: undefined;
}
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
const bindingId = runtime.management?.binding?.binding_id;
if (typeof bindingId !== 'string') return;
const workspaceId = data.workspaceId;
const generation = ++connectionTestGeneration;
requestError = null;
const { [runtime.runtime_id]: _previousResult, ...remainingResults } = testResults;
testResults = remainingResults;
busyRuntimeId = runtime.runtime_id;
try {
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
if (
workspaceId !== data.workspaceId ||
generation !== connectionTestGeneration ||
result.binding_id !== bindingId
) {
await refreshPageData();
return;
}
testResults = { ...testResults, [runtime.runtime_id]: result };
await refreshPageData();
} catch (error) {
if (generation === connectionTestGeneration) {
requestError = error instanceof Error ? error.message : String(error);
}
} finally {
if (generation === connectionTestGeneration) {
busyRuntimeId = null;
}
}
}
</script>
<svelte:head>
<title>Runtimes · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime resources" />
</svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row">
<div>
<h1 id="runtimes-heading">Runtimes</h1>
<p>Register and inspect the execution backends available to this Workspace.</p>
</div>
{#if data.workspace.permissions.manage_runtimes}
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
{showAddRuntime ? 'Close' : 'Add Runtime'}
</button>
{/if}
</header>
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<header>
<h2>Connect a remote Runtime</h2>
<p>
This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
their trust entries are not replaced.
</p>
</header>
<section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
<h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
<p>
Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
when starting the Runtime.
</p>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.identity.state === 'pending_provisioning'}
<p>This Workspace does not have an active signing identity yet.</p>
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => void provisionSigningIdentity()}
>
{busyRuntimeId === 'provision-workspace-identity' ? 'Provisioning…' : 'Provision Workspace identity'}
</button>
{:else if data.signingIdentity?.public_bundle}
<p>
Save the bundle as <code>{workspaceBundleFilename()}</code> on the Runtime host. It contains no
private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" disabled={busyRuntimeId !== null} onclick={copyWorkspaceBundle}>
Copy Workspace public bundle
</button>
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
<small>
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
service. Existing Workspace trust entries are preserved.
</small>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-to-workspace-heading">
<h3 id="runtime-to-workspace-heading">2. Verify the Runtime identity</h3>
<p>
On the Runtime host, run <code>yoi-runtime identity show --json</code> with the same Runtime storage
options, then paste the public bundle below.
</p>
<div class="settings-form-grid">
<label class="settings-form-wide">
Runtime public bundle
<textarea
bind:value={runtimePublicBundle}
oninput={() => {
runtimeFingerprint = null;
}}
required
rows="5"
spellcheck="false"
placeholder={runtimeBundlePlaceholder}
></textarea>
</label>
<button class="settings-form-wide" type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
Preview fingerprint
</button>
{#if runtimeFingerprint}
<dl class="runtime-facts">
<div>
<dt>Runtime fingerprint</dt>
<dd><code>{runtimeFingerprint}</code></dd>
</div>
</dl>
{/if}
</div>
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-connection-heading">
<h3 id="runtime-connection-heading">3. Register the connection</h3>
<div class="settings-form-grid">
<label>
Display name
<input bind:value={displayName} autocomplete="off" />
</label>
<label>
Endpoint
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<p>
Registration stores this Workspace-scoped binding. After it appears in the list, run
<strong>Test</strong> to observe a signed ping. Each request authenticates independently.
</p>
</section>
<div class="settings-action-row">
<button
type="submit"
disabled={busyRuntimeId !== null || !runtimeFingerprint}
>Register Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel
</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
{:else if !data.runtimes}
<p class="section-state">Loading Runtimes…</p>
{:else if data.runtimes.items.length === 0}
<p class="section-state">No Runtimes are visible.</p>
{:else}
<div class="settings-runtime-table-wrap">
<table class="settings-runtime-table">
<thead>
<tr>
<th>Runtime</th>
<th>Kind</th>
<th>Status</th>
<th>Platform</th>
<th>Management</th>
<th>Workdirs</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each data.runtimes.items as runtime}
<tr class:inactive={Boolean(runtime.management?.binding?.revoked_at) || currentTestResult(runtime)?.status === 'failed'}>
<td>
<strong>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
{runtime.label}
</a>
</strong>
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
<td>
{runtime.management?.binding?.revoked_at ? 'Revoked' : currentTestResult(runtime) ? connectionTestSummary(currentTestResult(runtime)!) : 'Not tested'}
</td>
<td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
</a>
</td>
<td>
<div class="settings-action-row">
{#if runtime.management?.config_managed && !runtime.management.binding?.revoked_at}
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
</td>
</tr>
{@const currentResult = currentTestResult(runtime)}
{#if (runtime.diagnostics ?? []).length > 0 || currentResult}
<tr class="settings-runtime-detail-row">
<td colspan="7">
{#if (runtime.diagnostics ?? []).length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics ?? [] as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
{#if currentResult}
<div class:failed={currentResult.status === 'failed'} class="settings-test-result">
<strong>Connection test: {connectionTestSummary(currentResult)}</strong>
{#if (currentResult.diagnostics ?? [])[0]}
<span>{(currentResult.diagnostics ?? [])[0].message}</span>
{/if}
<small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
</div>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
@@ -1,659 +1,10 @@
<script lang="ts">
// 接続テストはWorkspace/Runtime/現在bindingが一致する結果だけを表示する。
// 同じpublic keyでも再登録したbindingへ古い成功を適用しない。route世代は遅延応答の
// 除外用であり、binding identityやBackendの変更受理条件を代替しない。
import { goto, invalidateAll } from '$app/navigation';
import type {
RevokeRuntimeTrustKeyRequest,
RuntimeConnectionTestResponse,
RuntimeTrustKeyStatus,
} from '#lib/generated/runtime-api.ts';
import {
createRemoteRuntime,
removeRemoteRuntime,
previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
updateRemoteRuntime,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
RuntimeRemovalAttempt,
RuntimeTrustRequestError,
type RuntimeTrustRouteOperation,
} from '#lib/workspace/api/runtime-management.ts';
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
import type { PageProps } from './$types';
type TrustAction = 'create' | 'replace' | 'reactivate';
let { data }: PageProps = $props();
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let displayName = $state('');
let endpoint = $state('');
let editingMetadata = $state(false);
let deleteRuntimeConfirmation = $state('');
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | 'test' | null>(null);
let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
let replacementFingerprintError = $state<string | null>(null);
let connectionResult = $state<RuntimeConnectionTestResponse | null>(null);
let connectionError = $state<string | null>(null);
let fingerprintGeneration = 0;
const routeFence = new RuntimeTrustRouteFence();
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
let routeGeneration = 0;
$effect(() => {
const nextGeneration = routeFence.enter(data.runtimeId);
if (nextGeneration === routeGeneration) return;
routeGeneration = nextGeneration;
fingerprintGeneration += 1;
connectionResult = null;
connectionError = null;
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
deleteRuntimeConfirmation = '';
runtimeRemovalAttempt.reset();
busyAction = null;
fieldError = null;
deleteRuntimeError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
replacementFingerprintError = null;
});
$effect(() => {
const key = publicKey.trim();
const generation = ++fingerprintGeneration;
replacementFingerprint = null;
replacementFingerprintError = null;
if (!key) return;
void previewRuntimePublicKeyFingerprint(key).then(
(fingerprint) => {
if (generation === fingerprintGeneration) replacementFingerprint = fingerprint;
},
(error) => {
if (generation === fingerprintGeneration) {
replacementFingerprintError = error instanceof Error
? error.message
: String(error);
}
},
);
});
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
if (status === 'unconfigured') return 'create';
if (status === 'revoked') return 'reactivate';
return 'replace';
}
function actionLabel(action: TrustAction): string {
switch (action) {
case 'create': return 'Create Workspace trust';
case 'replace': return 'Replace trusted key';
case 'reactivate': return 'Reactivate with this key';
}
}
function formatTimestamp(value: string | null | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
async function testConnection(): Promise<void> {
const bindingId = data.runtimeDetail?.runtime.management.binding?.binding_id;
if (busyAction !== null || !bindingId) return;
const operation = routeFence.capture(data.runtimeId);
const workspaceId = data.workspaceId;
busyAction = 'test';
connectionResult = null;
connectionError = null;
try {
const result = await testRuntimeConnection(workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation) || workspaceId !== data.workspaceId ||
result.binding_id !== data.runtimeDetail?.runtime.management.binding?.binding_id) return;
connectionResult = result;
} catch (error) {
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) {
connectionError = error instanceof Error ? error.message : String(error);
}
} finally {
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) busyAction = null;
}
}
async function reloadAuthority(): Promise<void> {
await invalidateAll();
}
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
return routeFence.isCurrent(operation, data.runtimeId);
}
function cancelRuntimeMetadataEdit(): void {
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
requestError = null;
}
async function saveRuntimeMetadata(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
requestError = null;
successMessage = null;
const normalizedDisplayName = displayName.trim();
const normalizedEndpoint = endpoint.trim();
if (!normalizedDisplayName) {
requestError = 'Enter a Runtime label.';
return;
}
if (!normalizedEndpoint) {
requestError = 'Enter the Runtime endpoint.';
return;
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'metadata';
try {
await updateRemoteRuntime(data.workspaceId, operation.runtimeId, {
display_name: normalizedDisplayName,
endpoint: normalizedEndpoint,
});
if (!isCurrentRoute(operation)) return;
successMessage = 'Runtime settings were updated.';
editingMetadata = false;
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Runtime settings update failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
fieldError = null;
requestError = null;
successMessage = null;
const key = publicKey.trim();
if (!key) {
fieldError = 'Enter the Runtime public key.';
return;
}
if (utf8Bytes(key) > 16 * 1024) {
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
return;
}
if (replacementFingerprintError) {
fieldError = replacementFingerprintError;
return;
}
if (!replacementFingerprint) {
fieldError = 'Wait for the replacement fingerprint preview before saving.';
return;
}
const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status);
const operation = routeFence.capture(data.runtimeId);
busyAction = 'trust';
try {
const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Runtime binding and authoritative endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
identity_id: operation.runtimeId,
public_key: key,
},
display_name: data.runtimeDetail.runtime.label,
endpoint: data.runtimeDetail.endpoint,
expected_binding_id: binding.binding_id,
});
if (!isCurrentRoute(operation)) return;
publicKey = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create'
? 'Workspace trust was created.'
: action === 'replace'
? 'The trusted Runtime key was replaced.'
: 'Workspace trust was reactivated.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
fieldError = error.message;
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function revokeTrust(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const trust = data.runtimeDetail.trust_key;
if (trust.binding_id == null || trust.status !== 'active') {
requestError = 'Only active Workspace trust can be revoked.';
return;
}
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
return;
}
fieldError = null;
requestError = null;
successMessage = null;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'revoke';
const request: RevokeRuntimeTrustKeyRequest = {
expected_binding_id: trust.binding_id,
};
try {
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
request,
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function deleteRegistration(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const runtime = data.runtimeDetail.runtime;
if (runtime.management.built_in) return;
if (deleteRuntimeConfirmation.trim() !== data.runtimeId) {
deleteRuntimeError = 'Enter the Runtime ID exactly to confirm deletion.';
return;
}
const routeOperation = routeFence.capture(data.runtimeId);
const trust = data.runtimeDetail.trust_key;
if (trust.binding_id == null) {
deleteRuntimeError = 'The authoritative Runtime binding identity is unavailable. Reload before removal.';
return;
}
const operationId = runtimeRemovalAttempt.operationId();
busyAction = 'delete';
deleteRuntimeError = null;
try {
await removeRemoteRuntime(
data.workspaceId,
routeOperation.runtimeId,
{
operation_id: operationId,
expected_binding_id: trust.binding_id,
},
);
if (!isCurrentRoute(routeOperation)) return;
runtimeRemovalAttempt.complete(operationId);
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replace: true,
});
} catch (error) {
if (!isCurrentRoute(routeOperation)) return;
deleteRuntimeError = error instanceof Error
? error.message
: 'Runtime registration deletion failed.';
} finally {
if (isCurrentRoute(routeOperation)) busyAction = null;
}
}
async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) {
showPublicKey = false;
revealedPublicKey = null;
return;
}
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'reveal';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
revealedPublicKey = response.public_key;
showPublicKey = true;
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function copyPublicKey(): Promise<void> {
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'copy';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await navigator.clipboard.writeText(response.public_key);
if (!isCurrentRoute(operation)) return;
successMessage = 'Public key copied.';
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error
? error.message
: 'The browser could not copy the public key.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
<meta name="description" content="Runtime identity and Workspace trust settings" />
</svelte:head>
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
<header class="page-header-row">
<div>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
<p><code>{data.runtimeId}</code></p>
</div>
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
Workdirs
</a>
</header>
{#if data.runtimeDetailError}
<p class="section-state error">{data.runtimeDetailError}</p>
{:else if !data.runtimeDetail}
<p class="section-state">Loading Runtime…</p>
{:else}
{@const detail = data.runtimeDetail}
{@const runtime = detail.runtime}
{@const trust = detail.trust_key}
{@const currentAction = trustAction(trust.status)}
{@const latestResult = connectionResult?.workspace_id === data.workspaceId && connectionResult?.runtime_id === data.runtimeId && connectionResult?.binding_id === runtime.management.binding?.binding_id ? connectionResult : null}
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
<h2 id="runtime-identity-heading">Identity and binding</h2>
<dl class="runtime-detail-grid">
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Connection test</dt><dd>{runtime.management.binding?.revoked_at ? 'Revoked' : latestResult ? latestResult.status === 'compatible' ? 'Signed ping succeeded' : `Signed ping failed · ${latestResult.failure_kind}` : 'Not tested'}</dd></div>
<div><dt>Binding ID</dt><dd><code>{runtime.management.binding?.binding_id ?? '—'}</code></dd></div>
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
</dl>
{#if runtime.management.config_managed && !runtime.management.binding?.revoked_at}
<div class="settings-action-row">
<button type="button" disabled={busyAction !== null} onclick={testConnection}>
{busyAction === 'test' ? 'Testing…' : 'Test'}
</button>
</div>
{/if}
{#if latestResult}
<p class="section-state" class:error={latestResult.status === 'failed'}>
Checked {formatTimestamp(latestResult.checked_at)}
{#each latestResult.diagnostics ?? [] as diagnostic}
<span>{diagnostic.message}</span>
{/each}
</p>
{/if}
{#if connectionError}
<p class="section-state error" role="alert">{connectionError}</p>
{/if}
{#if (runtime.diagnostics ?? []).length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics ?? [] as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section" aria-labelledby="runtime-settings-heading">
<h2 id="runtime-settings-heading">Runtime settings</h2>
{#if editingMetadata}
<form class="runtime-trust-form" onsubmit={saveRuntimeMetadata}>
<label for="runtime-display-name-input">Label</label>
<input
id="runtime-display-name-input"
bind:value={displayName}
autocomplete="off"
disabled={busyAction !== null}
/>
<label for="runtime-endpoint-input">Endpoint</label>
<input
id="runtime-endpoint-input"
bind:value={endpoint}
inputmode="url"
autocomplete="url"
spellcheck="false"
disabled={busyAction !== null}
/>
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'metadata' ? 'Saving…' : 'Save Runtime settings'}
</button>
<button
class="settings-secondary-action"
type="button"
onclick={cancelRuntimeMetadataEdit}
disabled={busyAction !== null}
>Cancel</button>
</div>
</form>
{:else}
<div class="settings-action-row">
<button
class="settings-secondary-action"
type="button"
onclick={() => (editingMetadata = true)}
disabled={busyAction !== null}
>Edit Runtime</button>
</div>
{/if}
</section>
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2>
{#if trust.status !== 'unconfigured'}
<div class="runtime-public-key-actions">
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={togglePublicKeyReveal}
>
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
</button>
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
</button>
</div>
{#if showPublicKey && revealedPublicKey}
<pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
{/if}
{/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
<label for="runtime-public-key-input">Runtime public key</label>
<textarea
id="runtime-public-key-input"
bind:value={publicKey}
rows="5"
autocomplete="off"
spellcheck="false"
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
aria-invalid={fieldError ? 'true' : undefined}
placeholder="yoi-ed25519-pub:v1:…"
></textarea>
<dl class="runtime-trust-comparison">
<div>
<dt>Current fingerprint</dt>
<dd><code>{trust.fingerprint ?? 'Not configured'}</code></dd>
</div>
<div>
<dt>Replacement fingerprint</dt>
<dd><code>{replacementFingerprint ?? 'Enter a valid public key'}</code></dd>
</div>
</dl>
{#if replacementFingerprintError}
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if}
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
</button>
</div>
</form>
<div class="runtime-revoke-row">
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
</div>
<button
type="button"
class="danger"
disabled={busyAction !== null || trust.status !== 'active'}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
{#if requestError}
<p class="section-state error" role="alert">{requestError}</p>
{/if}
{#if successMessage}
<p class="section-state success" role="status">{successMessage}</p>
{/if}
</section>
{/if}
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
<h2 id="runtime-audit-heading">Recent trust audit</h2>
{#if detail.recent_audit.length === 0}
<p class="section-state">No trust changes are recorded.</p>
{:else}
<div class="runtime-audit-table-wrap">
<table class="runtime-audit-table">
<thead>
<tr><th>Action</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
</thead>
<tbody>
{#each detail.recent_audit as entry}
<tr>
<td>{entry.action}</td>
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
<td><code>{entry.actor_account_id}</code></td>
<td>{formatTimestamp(entry.at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p>
The Backend removes Workspace trust and this Runtime registration as one guarded operation.
Active Workers, Workdirs, assignments, pending create or removal work, configuration references,
or another Workspace binding block the operation without changing trust.
</p>
<p class="section-state warning">
This does not stop the Runtime process or delete its Workers or Workdirs.
</p>
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input
id="runtime-delete-confirmation"
bind:value={deleteRuntimeConfirmation}
autocomplete="off"
spellcheck="false"
disabled={busyAction !== null}
placeholder={data.runtimeId}
/>
<small>Enter <code>{data.runtimeId}</code> exactly.</small>
{#if deleteRuntimeError}
<p class="section-state error" role="alert">{deleteRuntimeError}</p>
{/if}
<div class="settings-action-row">
<button
type="button"
class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration}
>{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
</div>
</section>
{/if}
{/if}
</section>
<DeferredPage {data} title="Runtime">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -1,8 +1,9 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkspaceRuntimeDetail } from "#lib/workspace/api/runtime-management.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const detail = await loadJson(
fetch,
workspaceApiPath(
@@ -28,4 +29,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
runtimeDetail: detail.data,
runtimeDetailError: detail.error,
};
};
});
@@ -0,0 +1,662 @@
<script lang="ts">
// 接続テストはWorkspace/Runtime/現在bindingが一致する結果だけを表示する。
// 同じpublic keyでも再登録したbindingへ古い成功を適用しない。route世代は遅延応答の
// 除外用であり、binding identityやBackendの変更受理条件を代替しない。
import { goto } from '$app/navigation';
import { refreshPageData } from '#lib/workspace/navigation/refresh.ts';
import type {
RevokeRuntimeTrustKeyRequest,
RuntimeConnectionTestResponse,
RuntimeTrustKeyStatus,
} from '#lib/generated/runtime-api.ts';
import {
createRemoteRuntime,
removeRemoteRuntime,
previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
updateRemoteRuntime,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
RuntimeRemovalAttempt,
RuntimeTrustRequestError,
type RuntimeTrustRouteOperation,
} from '#lib/workspace/api/runtime-management.ts';
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
import type { PageData as GeneratedPageData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageData = ResolvedPageData<GeneratedPageData>;
type TrustAction = 'create' | 'replace' | 'reactivate';
let { data }: { data: PageData } = $props();
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let displayName = $state('');
let endpoint = $state('');
let editingMetadata = $state(false);
let deleteRuntimeConfirmation = $state('');
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | 'test' | null>(null);
let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
let replacementFingerprintError = $state<string | null>(null);
let connectionResult = $state<RuntimeConnectionTestResponse | null>(null);
let connectionError = $state<string | null>(null);
let fingerprintGeneration = 0;
const routeFence = new RuntimeTrustRouteFence();
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
let routeGeneration = 0;
$effect(() => {
const nextGeneration = routeFence.enter(data.runtimeId);
if (nextGeneration === routeGeneration) return;
routeGeneration = nextGeneration;
fingerprintGeneration += 1;
connectionResult = null;
connectionError = null;
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
deleteRuntimeConfirmation = '';
runtimeRemovalAttempt.reset();
busyAction = null;
fieldError = null;
deleteRuntimeError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
replacementFingerprintError = null;
});
$effect(() => {
const key = publicKey.trim();
const generation = ++fingerprintGeneration;
replacementFingerprint = null;
replacementFingerprintError = null;
if (!key) return;
void previewRuntimePublicKeyFingerprint(key).then(
(fingerprint) => {
if (generation === fingerprintGeneration) replacementFingerprint = fingerprint;
},
(error) => {
if (generation === fingerprintGeneration) {
replacementFingerprintError = error instanceof Error
? error.message
: String(error);
}
},
);
});
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
if (status === 'unconfigured') return 'create';
if (status === 'revoked') return 'reactivate';
return 'replace';
}
function actionLabel(action: TrustAction): string {
switch (action) {
case 'create': return 'Create Workspace trust';
case 'replace': return 'Replace trusted key';
case 'reactivate': return 'Reactivate with this key';
}
}
function formatTimestamp(value: string | null | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
async function testConnection(): Promise<void> {
const bindingId = data.runtimeDetail?.runtime.management.binding?.binding_id;
if (busyAction !== null || !bindingId) return;
const operation = routeFence.capture(data.runtimeId);
const workspaceId = data.workspaceId;
busyAction = 'test';
connectionResult = null;
connectionError = null;
try {
const result = await testRuntimeConnection(workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation) || workspaceId !== data.workspaceId ||
result.binding_id !== data.runtimeDetail?.runtime.management.binding?.binding_id) return;
connectionResult = result;
} catch (error) {
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) {
connectionError = error instanceof Error ? error.message : String(error);
}
} finally {
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) busyAction = null;
}
}
async function reloadAuthority(): Promise<void> {
await refreshPageData();
}
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
return routeFence.isCurrent(operation, data.runtimeId);
}
function cancelRuntimeMetadataEdit(): void {
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
requestError = null;
}
async function saveRuntimeMetadata(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
requestError = null;
successMessage = null;
const normalizedDisplayName = displayName.trim();
const normalizedEndpoint = endpoint.trim();
if (!normalizedDisplayName) {
requestError = 'Enter a Runtime label.';
return;
}
if (!normalizedEndpoint) {
requestError = 'Enter the Runtime endpoint.';
return;
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'metadata';
try {
await updateRemoteRuntime(data.workspaceId, operation.runtimeId, {
display_name: normalizedDisplayName,
endpoint: normalizedEndpoint,
});
if (!isCurrentRoute(operation)) return;
successMessage = 'Runtime settings were updated.';
editingMetadata = false;
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Runtime settings update failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
fieldError = null;
requestError = null;
successMessage = null;
const key = publicKey.trim();
if (!key) {
fieldError = 'Enter the Runtime public key.';
return;
}
if (utf8Bytes(key) > 16 * 1024) {
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
return;
}
if (replacementFingerprintError) {
fieldError = replacementFingerprintError;
return;
}
if (!replacementFingerprint) {
fieldError = 'Wait for the replacement fingerprint preview before saving.';
return;
}
const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status);
const operation = routeFence.capture(data.runtimeId);
busyAction = 'trust';
try {
const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Runtime binding and authoritative endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
identity_id: operation.runtimeId,
public_key: key,
},
display_name: data.runtimeDetail.runtime.label,
endpoint: data.runtimeDetail.endpoint,
expected_binding_id: binding.binding_id,
});
if (!isCurrentRoute(operation)) return;
publicKey = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create'
? 'Workspace trust was created.'
: action === 'replace'
? 'The trusted Runtime key was replaced.'
: 'Workspace trust was reactivated.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
fieldError = error.message;
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function revokeTrust(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const trust = data.runtimeDetail.trust_key;
if (trust.binding_id == null || trust.status !== 'active') {
requestError = 'Only active Workspace trust can be revoked.';
return;
}
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
return;
}
fieldError = null;
requestError = null;
successMessage = null;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'revoke';
const request: RevokeRuntimeTrustKeyRequest = {
expected_binding_id: trust.binding_id,
};
try {
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
request,
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.';
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
} else {
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
}
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function deleteRegistration(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const runtime = data.runtimeDetail.runtime;
if (runtime.management.built_in) return;
if (deleteRuntimeConfirmation.trim() !== data.runtimeId) {
deleteRuntimeError = 'Enter the Runtime ID exactly to confirm deletion.';
return;
}
const routeOperation = routeFence.capture(data.runtimeId);
const trust = data.runtimeDetail.trust_key;
if (trust.binding_id == null) {
deleteRuntimeError = 'The authoritative Runtime binding identity is unavailable. Reload before removal.';
return;
}
const operationId = runtimeRemovalAttempt.operationId();
busyAction = 'delete';
deleteRuntimeError = null;
try {
await removeRemoteRuntime(
data.workspaceId,
routeOperation.runtimeId,
{
operation_id: operationId,
expected_binding_id: trust.binding_id,
},
);
if (!isCurrentRoute(routeOperation)) return;
runtimeRemovalAttempt.complete(operationId);
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replace: true,
});
} catch (error) {
if (!isCurrentRoute(routeOperation)) return;
deleteRuntimeError = error instanceof Error
? error.message
: 'Runtime registration deletion failed.';
} finally {
if (isCurrentRoute(routeOperation)) busyAction = null;
}
}
async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) {
showPublicKey = false;
revealedPublicKey = null;
return;
}
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'reveal';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
revealedPublicKey = response.public_key;
showPublicKey = true;
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function copyPublicKey(): Promise<void> {
if (busyAction !== null) return;
const operation = routeFence.capture(data.runtimeId);
busyAction = 'copy';
requestError = null;
successMessage = null;
try {
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await navigator.clipboard.writeText(response.public_key);
if (!isCurrentRoute(operation)) return;
successMessage = 'Public key copied.';
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error
? error.message
: 'The browser could not copy the public key.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
</script>
<svelte:head>
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
<meta name="description" content="Runtime identity and Workspace trust settings" />
</svelte:head>
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
<header class="page-header-row">
<div>
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
<p><code>{data.runtimeId}</code></p>
</div>
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
Workdirs
</a>
</header>
{#if data.runtimeDetailError}
<p class="section-state error">{data.runtimeDetailError}</p>
{:else if !data.runtimeDetail}
<p class="section-state">Loading Runtime…</p>
{:else}
{@const detail = data.runtimeDetail}
{@const runtime = detail.runtime}
{@const trust = detail.trust_key}
{@const currentAction = trustAction(trust.status)}
{@const latestResult = connectionResult?.workspace_id === data.workspaceId && connectionResult?.runtime_id === data.runtimeId && connectionResult?.binding_id === runtime.management.binding?.binding_id ? connectionResult : null}
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
<h2 id="runtime-identity-heading">Identity and binding</h2>
<dl class="runtime-detail-grid">
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Connection test</dt><dd>{runtime.management.binding?.revoked_at ? 'Revoked' : latestResult ? latestResult.status === 'compatible' ? 'Signed ping succeeded' : `Signed ping failed · ${latestResult.failure_kind}` : 'Not tested'}</dd></div>
<div><dt>Binding ID</dt><dd><code>{runtime.management.binding?.binding_id ?? '—'}</code></dd></div>
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
</dl>
{#if runtime.management.config_managed && !runtime.management.binding?.revoked_at}
<div class="settings-action-row">
<button type="button" disabled={busyAction !== null} onclick={testConnection}>
{busyAction === 'test' ? 'Testing…' : 'Test'}
</button>
</div>
{/if}
{#if latestResult}
<p class="section-state" class:error={latestResult.status === 'failed'}>
Checked {formatTimestamp(latestResult.checked_at)}
{#each latestResult.diagnostics ?? [] as diagnostic}
<span>{diagnostic.message}</span>
{/each}
</p>
{/if}
{#if connectionError}
<p class="section-state error" role="alert">{connectionError}</p>
{/if}
{#if (runtime.diagnostics ?? []).length > 0}
<ul class="settings-diagnostics-list">
{#each runtime.diagnostics ?? [] as diagnostic}
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section" aria-labelledby="runtime-settings-heading">
<h2 id="runtime-settings-heading">Runtime settings</h2>
{#if editingMetadata}
<form class="runtime-trust-form" onsubmit={saveRuntimeMetadata}>
<label for="runtime-display-name-input">Label</label>
<input
id="runtime-display-name-input"
bind:value={displayName}
autocomplete="off"
disabled={busyAction !== null}
/>
<label for="runtime-endpoint-input">Endpoint</label>
<input
id="runtime-endpoint-input"
bind:value={endpoint}
inputmode="url"
autocomplete="url"
spellcheck="false"
disabled={busyAction !== null}
/>
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'metadata' ? 'Saving…' : 'Save Runtime settings'}
</button>
<button
class="settings-secondary-action"
type="button"
onclick={cancelRuntimeMetadataEdit}
disabled={busyAction !== null}
>Cancel</button>
</div>
</form>
{:else}
<div class="settings-action-row">
<button
class="settings-secondary-action"
type="button"
onclick={() => (editingMetadata = true)}
disabled={busyAction !== null}
>Edit Runtime</button>
</div>
{/if}
</section>
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2>
{#if trust.status !== 'unconfigured'}
<div class="runtime-public-key-actions">
<button
type="button"
class="secondary"
disabled={busyAction !== null}
onclick={togglePublicKeyReveal}
>
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
</button>
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
</button>
</div>
{#if showPublicKey && revealedPublicKey}
<pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
{/if}
{/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
<label for="runtime-public-key-input">Runtime public key</label>
<textarea
id="runtime-public-key-input"
bind:value={publicKey}
rows="5"
autocomplete="off"
spellcheck="false"
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
aria-invalid={fieldError ? 'true' : undefined}
placeholder="yoi-ed25519-pub:v1:…"
></textarea>
<dl class="runtime-trust-comparison">
<div>
<dt>Current fingerprint</dt>
<dd><code>{trust.fingerprint ?? 'Not configured'}</code></dd>
</div>
<div>
<dt>Replacement fingerprint</dt>
<dd><code>{replacementFingerprint ?? 'Enter a valid public key'}</code></dd>
</div>
</dl>
{#if replacementFingerprintError}
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if}
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
</button>
</div>
</form>
<div class="runtime-revoke-row">
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
</div>
<button
type="button"
class="danger"
disabled={busyAction !== null || trust.status !== 'active'}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
{#if requestError}
<p class="section-state error" role="alert">{requestError}</p>
{/if}
{#if successMessage}
<p class="section-state success" role="status">{successMessage}</p>
{/if}
</section>
{/if}
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
<h2 id="runtime-audit-heading">Recent trust audit</h2>
{#if detail.recent_audit.length === 0}
<p class="section-state">No trust changes are recorded.</p>
{:else}
<div class="runtime-audit-table-wrap">
<table class="runtime-audit-table">
<thead>
<tr><th>Action</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
</thead>
<tbody>
{#each detail.recent_audit as entry}
<tr>
<td>{entry.action}</td>
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
<td><code>{entry.actor_account_id}</code></td>
<td>{formatTimestamp(entry.at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p>
The Backend removes Workspace trust and this Runtime registration as one guarded operation.
Active Workers, Workdirs, assignments, pending create or removal work, configuration references,
or another Workspace binding block the operation without changing trust.
</p>
<p class="section-state warning">
This does not stop the Runtime process or delete its Workers or Workdirs.
</p>
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input
id="runtime-delete-confirmation"
bind:value={deleteRuntimeConfirmation}
autocomplete="off"
spellcheck="false"
disabled={busyAction !== null}
placeholder={data.runtimeId}
/>
<small>Enter <code>{data.runtimeId}</code> exactly.</small>
{#if deleteRuntimeError}
<p class="section-state error" role="alert">{deleteRuntimeError}</p>
{/if}
<div class="settings-action-row">
<button
type="button"
class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration}
>{busyAction === 'delete' ? 'Removing…' : 'Remove Runtime registration'}</button>
</div>
</section>
{/if}
{/if}
</section>
@@ -1,257 +1,10 @@
<script lang="ts">
import { loadJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
import { parseWorkingDirectoryListResponse } from '#lib/workspace/api/workdirs.ts';
import { canRemoveWorkdir, removalCause, removalGuard, removeWorkdir, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let cleanupBusyTarget = $state<string | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workdirs = $state<WorkingDirectorySummary[]>([]);
let inventoryLoaded = $state(false);
let feedback = $state<{ id: string; message: string; retryable: boolean; removed: boolean } | null>(null);
let runtimeLabel = $derived(data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId);
let canManage = $derived(data.workspace?.permissions.manage_runtimes === true);
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
$effect(() => {
cleanupPlan = data.cleanupPlan ?? null;
workdirs = data.workdirs?.items ?? [];
inventoryLoaded = Boolean(data.workdirs);
refreshError = data.workdirsError || data.cleanupPlanError ? 'Inventory or removal eligibility is unavailable. Refresh before deletion.' : null;
feedback = null;
});
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
}
function currentCheckout(workdir: WorkingDirectorySummary): string {
const key = repositoryKey(workdir);
const provider = key ? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null : null;
return formatCurrentWorkdirCheckout(workdir, provider);
}
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
}
function isDeleteDisabled(workdir: WorkingDirectorySummary): boolean {
return !canManage || Boolean(refreshError) || refreshing || cleanupBusyTarget !== null || !canRemoveWorkdir(workdir, cleanupCandidate(workdir));
}
async function refreshInventory(): Promise<void> {
refreshing = true;
// Capture route authority before awaiting; a departed route must not receive this result.
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
try {
const base = `/runtimes/${encodeURIComponent(runtimeId)}`;
const [inventory, plan] = await Promise.all([
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/working-directories`), undefined, parseWorkingDirectoryListResponse),
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/cleanup-plan`), undefined, parseRuntimeCleanupPlan),
]);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
// Keep the last observed list on failure, but never authorize another deletion with stale eligibility.
if (inventory.data) { workdirs = inventory.data.items; inventoryLoaded = true; }
cleanupPlan = plan.data;
refreshError = inventory.error || plan.error ? 'Inventory or removal eligibility could not be refreshed. Refresh before another deletion.' : null;
} finally {
refreshing = false;
}
}
async function deleteWorkdir(workdir: WorkingDirectorySummary): Promise<void> {
if (isDeleteDisabled(workdir)) return;
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
cleanupBusyTarget = workdir.working_directory_id;
feedback = null;
try {
const result = await removeWorkdir(fetch, workspaceId, runtimeId, workdir.working_directory_id);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = {
id: workdir.working_directory_id,
message: result.disposition === 'removed' ? 'Deletion confirmed.' : removalCause(result.failure_category),
retryable: result.retryable && result.disposition !== 'removed',
removed: result.disposition === 'removed',
};
} catch (error) {
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = { id: workdir.working_directory_id, message: error instanceof WorkdirRemovalError ? error.message : removalCause(null), retryable: false, removed: false };
} finally {
// Even a lost/error response can follow a real provider side effect. Re-read inventory AND guards.
if (workspaceId === data.workspaceId && runtimeId === data.runtimeId) await refreshInventory();
cleanupBusyTarget = null;
}
}
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Workdirs · {runtimeLabel} · Yoi Workspace</title>
<meta name="description" content="Runtime workdirs" />
</svelte:head>
<section class="workdirs-page main-content" aria-labelledby="workdirs-heading" aria-busy={refreshing}>
<header class="page-header-row">
<div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
</div>
<button class="icon-action" type="button" disabled={refreshing || cleanupBusyTarget !== null} aria-label="Refresh" title="Refresh workdirs" onclick={refreshInventory}>
{#if refreshing}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 7v5h-5" /><path d="M4 17v-5h5" /><path d="M6.1 7a7 7 0 0 1 11.6-1L20 9M4 15l2.3 3A7 7 0 0 0 17.9 17" /></svg>
{/if}
</button>
</header>
{#if feedback}
<div class="removal-feedback" class:error={!feedback.removed} role={feedback.removed ? 'status' : 'alert'}>
<p><strong>{feedback.removed ? 'Workdir deleted' : 'Workdir not deleted'}</strong> · <code>{feedback.id}</code></p>
<p>{feedback.message}</p>
{#if feedback.retryable}<p>After resolving this cause, retry Delete. Current safety conditions will be rechecked.</p>{/if}
</div>
{/if}
{#if refreshError}<p class="section-state error" role="alert">{refreshError}</p>{/if}
{#if !inventoryLoaded}
<p class="section-state">{data.workdirsError ? 'Workdir inventory is unavailable.' : 'Loading workdirs…'}</p>
{:else if workdirs.length === 0}
<p class="section-state">No workdirs are visible for this Runtime.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal inventory scroll must be keyboard-focusable.) -->
<div class="table-wrap" role="region" aria-label="Workdir inventory" tabindex="0">
<table class="workdirs-table">
<thead>
<tr>
<th>Workdir</th>
<th>Repository</th>
<th>Checkout</th>
<th>Status</th>
<th>Cleanliness</th>
<th>Occupied by</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each workdirs as workdir (workdir.working_directory_id)}
{@const cleanup = cleanupCandidate(workdir)}
<tr>
<td>
<span>{workdir.display_name ?? '—'}</span>
<small><code>{workdir.working_directory_id}</code></small>
</td>
<td>{repositoryKey(workdir) ?? 'External'}</td>
<td><code>{currentCheckout(workdir)}</code></td>
<td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
{#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.worker_id}</small>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if canManage}
<button
class="icon-action danger"
type="button"
disabled={isDeleteDisabled(workdir)}
aria-label={`${feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete'} ${workdir.working_directory_id}`}
aria-busy={cleanupBusyTarget === workdir.working_directory_id}
title={!canRemoveWorkdir(workdir, cleanup) ? removalGuard(workdir, cleanup) : feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete Workdir'}
onclick={() => deleteWorkdir(workdir)}
>
{#if cleanupBusyTarget === workdir.working_directory_id}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{:else}
<span class="muted">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
.workdirs-page,
.table-wrap {
min-width: 0;
}
.removal-feedback {
overflow-wrap: anywhere;
}
.removal-feedback p {
margin-block: var(--space-2);
}
.removal-feedback.error strong {
color: var(--danger);
}
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: workdir-action-spin 0.8s linear infinite;
}
@keyframes workdir-action-spin {
to {
transform: rotate(360deg);
}
}
</style>
<DeferredPage {data} title="Workdirs">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
@@ -1,10 +1,11 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkspaceRuntimeList } from "#lib/workspace/api/runtime-management.ts";
import { parseRuntimeCleanupPlan } from "#lib/workspace/api/runtime-workers.ts";
import { parseWorkingDirectoryListResponse } from "#lib/workspace/api/workdirs.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const runtimeId = params.runtimeId;
const [runtimes, workdirs, cleanupPlan] = await Promise.all([
loadJson(
@@ -43,4 +44,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
cleanupPlan: cleanupPlan.data,
cleanupPlanError: cleanupPlan.error,
};
};
});
@@ -0,0 +1,259 @@
<script lang="ts">
import { loadJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
import { parseRuntimeCleanupPlan } from '#lib/workspace/api/runtime-workers.ts';
import { parseWorkingDirectoryListResponse } from '#lib/workspace/api/workdirs.ts';
import { canRemoveWorkdir, removalCause, removalGuard, removeWorkdir, WorkdirRemovalError } from '#lib/workspace/settings/workdir-removal.ts';
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
import type { CleanupWorkdirCandidate, RuntimeCleanupPlanResponse, WorkingDirectorySummary } from '#lib/workspace/sidebar/types.ts';
import type { PageData as RouteData } from './$types';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
type PageProps = { data: ResolvedPageData<RouteData> };
let { data }: PageProps = $props();
let cleanupBusyTarget = $state<string | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let cleanupPlan = $state<RuntimeCleanupPlanResponse | null>(null);
let workdirs = $state<WorkingDirectorySummary[]>([]);
let inventoryLoaded = $state(false);
let feedback = $state<{ id: string; message: string; retryable: boolean; removed: boolean } | null>(null);
let runtimeLabel = $derived(data.runtimes?.items.find((runtime) => runtime.runtime_id === data.runtimeId)?.label ?? data.runtimeId);
let canManage = $derived(data.workspace?.permissions.manage_runtimes === true);
let cleanupCandidates = $derived(cleanupPlan?.workdirs ?? []);
$effect(() => {
cleanupPlan = data.cleanupPlan ?? null;
workdirs = data.workdirs?.items ?? [];
inventoryLoaded = Boolean(data.workdirs);
refreshError = data.workdirsError || data.cleanupPlanError ? 'Inventory or removal eligibility is unavailable. Refresh before deletion.' : null;
feedback = null;
});
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
}
function currentCheckout(workdir: WorkingDirectorySummary): string {
const key = repositoryKey(workdir);
const provider = key ? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null : null;
return formatCurrentWorkdirCheckout(workdir, provider);
}
function cleanupCandidate(workdir: WorkingDirectorySummary): CleanupWorkdirCandidate | undefined {
return cleanupCandidates.find((candidate) => candidate.workdir_id === workdir.working_directory_id);
}
function isDeleteDisabled(workdir: WorkingDirectorySummary): boolean {
return !canManage || Boolean(refreshError) || refreshing || cleanupBusyTarget !== null || !canRemoveWorkdir(workdir, cleanupCandidate(workdir));
}
async function refreshInventory(): Promise<void> {
refreshing = true;
// Capture route authority before awaiting; a departed route must not receive this result.
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
try {
const base = `/runtimes/${encodeURIComponent(runtimeId)}`;
const [inventory, plan] = await Promise.all([
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/working-directories`), undefined, parseWorkingDirectoryListResponse),
loadJson(fetch, workspaceApiPath(workspaceId, `${base}/cleanup-plan`), undefined, parseRuntimeCleanupPlan),
]);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
// Keep the last observed list on failure, but never authorize another deletion with stale eligibility.
if (inventory.data) { workdirs = inventory.data.items; inventoryLoaded = true; }
cleanupPlan = plan.data;
refreshError = inventory.error || plan.error ? 'Inventory or removal eligibility could not be refreshed. Refresh before another deletion.' : null;
} finally {
refreshing = false;
}
}
async function deleteWorkdir(workdir: WorkingDirectorySummary): Promise<void> {
if (isDeleteDisabled(workdir)) return;
const workspaceId = data.workspaceId;
const runtimeId = data.runtimeId;
cleanupBusyTarget = workdir.working_directory_id;
feedback = null;
try {
const result = await removeWorkdir(fetch, workspaceId, runtimeId, workdir.working_directory_id);
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = {
id: workdir.working_directory_id,
message: result.disposition === 'removed' ? 'Deletion confirmed.' : removalCause(result.failure_category),
retryable: result.retryable && result.disposition !== 'removed',
removed: result.disposition === 'removed',
};
} catch (error) {
if (workspaceId !== data.workspaceId || runtimeId !== data.runtimeId) return;
feedback = { id: workdir.working_directory_id, message: error instanceof WorkdirRemovalError ? error.message : removalCause(null), retryable: false, removed: false };
} finally {
// Even a lost/error response can follow a real provider side effect. Re-read inventory AND guards.
if (workspaceId === data.workspaceId && runtimeId === data.runtimeId) await refreshInventory();
cleanupBusyTarget = null;
}
}
</script>
<svelte:head>
<title>Workdirs · {runtimeLabel} · Yoi Workspace</title>
<meta name="description" content="Runtime workdirs" />
</svelte:head>
<section class="workdirs-page main-content" aria-labelledby="workdirs-heading" aria-busy={refreshing}>
<header class="page-header-row">
<div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
</div>
<button class="icon-action" type="button" disabled={refreshing || cleanupBusyTarget !== null} aria-label="Refresh" title="Refresh workdirs" onclick={refreshInventory}>
{#if refreshing}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 7v5h-5" /><path d="M4 17v-5h5" /><path d="M6.1 7a7 7 0 0 1 11.6-1L20 9M4 15l2.3 3A7 7 0 0 0 17.9 17" /></svg>
{/if}
</button>
</header>
{#if feedback}
<div class="removal-feedback" class:error={!feedback.removed} role={feedback.removed ? 'status' : 'alert'}>
<p><strong>{feedback.removed ? 'Workdir deleted' : 'Workdir not deleted'}</strong> · <code>{feedback.id}</code></p>
<p>{feedback.message}</p>
{#if feedback.retryable}<p>After resolving this cause, retry Delete. Current safety conditions will be rechecked.</p>{/if}
</div>
{/if}
{#if refreshError}<p class="section-state error" role="alert">{refreshError}</p>{/if}
{#if !inventoryLoaded}
<p class="section-state">{data.workdirsError ? 'Workdir inventory is unavailable.' : 'Loading workdirs…'}</p>
{:else if workdirs.length === 0}
<p class="section-state">No workdirs are visible for this Runtime.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal inventory scroll must be keyboard-focusable.) -->
<div class="table-wrap" role="region" aria-label="Workdir inventory" tabindex="0">
<table class="workdirs-table">
<thead>
<tr>
<th>Workdir</th>
<th>Repository</th>
<th>Checkout</th>
<th>Status</th>
<th>Cleanliness</th>
<th>Occupied by</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each workdirs as workdir (workdir.working_directory_id)}
{@const cleanup = cleanupCandidate(workdir)}
<tr>
<td>
<span>{workdir.display_name ?? '—'}</span>
<small><code>{workdir.working_directory_id}</code></small>
</td>
<td>{repositoryKey(workdir) ?? 'External'}</td>
<td><code>{currentCheckout(workdir)}</code></td>
<td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td>
<td>
{#if workdir.occupied_by}
<span>{workdir.occupied_by.display_name}</span>
<small>{workdir.occupied_by.runtime_id}:{workdir.occupied_by.worker_id}</small>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td>
{#if canManage}
<button
class="icon-action danger"
type="button"
disabled={isDeleteDisabled(workdir)}
aria-label={`${feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete'} ${workdir.working_directory_id}`}
aria-busy={cleanupBusyTarget === workdir.working_directory_id}
title={!canRemoveWorkdir(workdir, cleanup) ? removalGuard(workdir, cleanup) : feedback?.id === workdir.working_directory_id && feedback.retryable ? 'Retry Delete' : 'Delete Workdir'}
onclick={() => deleteWorkdir(workdir)}
>
{#if cleanupBusyTarget === workdir.working_directory_id}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{:else}
<span class="muted">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
.workdirs-page,
.table-wrap {
min-width: 0;
}
.removal-feedback {
overflow-wrap: anywhere;
}
.removal-feedback p {
margin-block: var(--space-2);
}
.removal-feedback.error strong {
color: var(--danger);
}
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: workdir-action-spin 0.8s linear infinite;
}
@keyframes workdir-action-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -8,11 +8,12 @@ import {
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import RuntimeSettings from "./+page.svelte";
import RuntimeDetail from "./[runtimeId]/+page.svelte";
import RuntimeSettings from "./PageContent.svelte";
import RuntimeDetail from "./[runtimeId]/PageContent.svelte";
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn() }));
vi.mock("$app/navigation", () => ({ invalidateAll: vi.fn(async () => {}) }));
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn(), refresh: vi.fn() }));
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
vi.mock("#lib/workspace/navigation/refresh.ts", () => ({ refreshPageData: api.refresh }));
vi.mock("#lib/workspace/api/runtime-management.ts", async (importOriginal) => ({
...await importOriginal<
typeof import("#lib/workspace/api/runtime-management.ts")
@@ -21,6 +22,13 @@ vi.mock("#lib/workspace/api/runtime-management.ts", async (importOriginal) => ({
previewRuntimePublicKeyFingerprint: api.fingerprint,
}));
function deferredRefresh() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
api.refresh.mockReturnValue(promise);
return resolve;
}
afterEach(() => {
cleanup();
vi.resetAllMocks();
@@ -29,6 +37,7 @@ afterEach(() => {
test("Runtime registration uses public bundle without requiring copied Workspace enrollment identity", async () => {
api.fingerprint.mockResolvedValue("sha256:runtime-public-key");
api.create.mockResolvedValue({});
const finishRefresh = deferredRefresh();
const data = {
workspaceId: "workspace-a",
workspace: { permissions: { manage_runtimes: true } },
@@ -37,7 +46,7 @@ test("Runtime registration uses public bundle without requiring copied Workspace
signingIdentityError: "Workspace public identity could not be loaded",
} as unknown as ComponentProps<typeof RuntimeSettings>["data"];
render(RuntimeSettings, {
props: { data, params: { workspaceId: "workspace-a" } },
props: { data },
});
await fireEvent.click(screen.getByRole("button", { name: "Add Runtime" }));
await fireEvent.input(
@@ -74,11 +83,17 @@ test("Runtime registration uses public bundle without requiring copied Workspace
endpoint: "https://runtime.example",
expected_binding_id: null,
});
await waitFor(() => expect(api.refresh).toHaveBeenCalledOnce());
await fireEvent.click(screen.getByRole("button", { name: "Add Runtime" }));
expect(screen.getByRole("button", { name: "Preview fingerprint" })).toHaveProperty("disabled", true);
finishRefresh();
await waitFor(() => expect(screen.getByRole("button", { name: "Preview fingerprint" })).toHaveProperty("disabled", false));
});
test("Runtime reactivation uses the observed binding without copied Workspace identity", async () => {
api.fingerprint.mockResolvedValue("sha256:replacement-key");
api.create.mockResolvedValue({});
const finishRefresh = deferredRefresh();
const data = {
workspaceId: "workspace-a",
runtimeId: "runtime-a",
@@ -111,7 +126,6 @@ test("Runtime reactivation uses the observed binding without copied Workspace id
render(RuntimeDetail, {
props: {
data,
params: { workspaceId: "workspace-a", runtimeId: "runtime-a" },
},
});
expect(screen.queryByLabelText("Workspace trust ID")).toBeNull();
@@ -136,4 +150,10 @@ test("Runtime reactivation uses the observed binding without copied Workspace id
endpoint: "https://runtime.example",
expected_binding_id: "observed-binding",
});
await waitFor(() => expect(api.refresh).toHaveBeenCalledOnce());
expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty("disabled", true);
expect(screen.getByRole("button", { name: "Edit Runtime" })).toHaveProperty("disabled", true);
finishRefresh();
await waitFor(() => expect(screen.getByRole("button", { name: "Reactivate with this key" })).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Edit Runtime" })).toHaveProperty("disabled", false);
});
@@ -9,7 +9,7 @@ import {
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import { goto, invalidate } from "$app/navigation";
import Settings from "./+page.svelte";
import Settings from "./PageContent.svelte";
import {
deletionFixture,
identityFixture,
@@ -42,7 +42,6 @@ function mount(value = data()) {
return render(Settings, {
props: {
data: value,
params: { workspaceId: value.workspace!.workspace_id },
},
});
}
@@ -466,7 +465,6 @@ test("Workspace switch resets drafts and ignores old load/mutation completions",
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
await view.rerender({
data: data("home-long"),
params: { workspaceId: "home-long" },
});
await screen.findByText(metadataFixture("home-long").display_name);
release(
@@ -492,7 +490,6 @@ test("slow identity keeps the name usable and old identity responses cannot leak
expect(screen.getByText("Loading identity…")).toBeTruthy();
await view.rerender({
data: data("home-empty"),
params: { workspaceId: "home-empty" },
});
await screen.findByText("Not provisioned");
release(Response.json(identityFixture()));
@@ -520,7 +517,6 @@ test("a completed deletion request from an unmounted Workspace cannot navigate a
await screen.findByText("Deleting…");
await view.rerender({
data: data("home-member", false),
params: { workspaceId: "home-member" },
});
await screen.findByRole("button", { name: "Edit name" });
release(Response.json(operation()));
@@ -1,373 +1,13 @@
<script lang="ts">
// 検索条件とページ順序はBackendのqueryに任せ、取得済みの一部を並べ替えない。
// 案件状態と担当の自己申告は、同じ行でも別の情報として表示する。
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "#lib/workspace/api/http.ts";
import {
loadJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketQueryResponse,
parseTicketRecordRef,
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
TicketQueryItem as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
let { data }: { data: PageData } = $props();
let response = $state(untrack(() => data.tickets));
let searchText = $state(untrack(() => data.search));
let searchBusy = $state(false);
let pageBusy = $state(false);
let navigationError = $state<string | null>(null);
$effect(() => {
const currentData = data;
untrack(() => {
response = currentData.tickets;
searchText = currentData.search;
pageBusy = false;
navigationError = null;
orchestrator = currentData.orchestrator;
});
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
let createError = $state<string | null>(null);
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
const keys = new Set<string>();
for (const target of createTargets) {
if (
!repositories.some((repository) =>
repository.repository_key === target.repository_key
) || keys.has(target.repository_key)
) return false;
keys.add(target.repository_key);
}
return true;
});
async function searchTickets(event?: SubmitEvent) {
event?.preventDefault();
if (searchBusy) return;
searchBusy = true;
navigationError = null;
try {
const query = new URLSearchParams({ q: searchText.trim() });
await goto(`/w/${encodeURIComponent(data.workspaceId)}/tickets?${query}`, {
reset: false, refreshAll: true,
});
} catch (error) {
navigationError = error instanceof Error ? error.message : String(error);
} finally {
searchBusy = false;
}
}
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(): Promise<void> {
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
const observedData = data;
const previous = response.data;
pageBusy = true;
response = { data: previous, error: null };
const result = await loadJson(
fetch,
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
{
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
if (data !== observedData) return;
response = result.data
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
: { data: previous, error: result.error };
pageBusy = false;
}
function addCreateTarget(): void {
createTargets.push({
repository_key: "",
ref_selector: "",
access: createTargets.some((target) => target.access === "read_write")
? "read_only"
: "read_write",
});
}
function removeCreateTarget(index: number): void {
createTargets.splice(index, 1);
}
function normalizedCreateTargets(): TicketTarget[] {
return createTargets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
createBusy = true;
createError = null;
const request: NewTicket = {
title: createTitle.trim(),
kind: "task",
priority: "P2",
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "open",
targets: normalizedCreateTargets(),
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
} finally {
createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
orchestratorStarting = true;
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(data.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
orchestratorStarting = false;
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
</script>
<svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<h1>Tickets</h1>
</div>
<div class="ticket-panel-controls">
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
class="workspace-secondary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
>
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
</button>
{/if}
</div>
</div>
</header>
{#if creatingTicket}
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{/if}
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
<div class="ticket-target-list">
<strong>Repository resources (optional)</strong>
{#each createTargets as target, index}
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} required>
<option value="">Choose repository</option>
{#each repositories as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} /></label>
<label>Access
<select bind:value={target.access}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeCreateTarget(index)}>Remove target</button>
</fieldset>
{/each}
<button class="workspace-secondary-button" type="button" onclick={addCreateTarget}>Add target</button>
</div>
{#if !createTargetsSavable}
<p class="workspace-empty-copy">Each optional resource row must select a different registered repository. Empty or read-only resources are allowed.</p>
{/if}
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
{createBusy ? "Creating…" : "Create Ticket"}
</button>
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{/if}
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
placeholder="Search tickets · state:open" spellcheck="false" />
<button class="workspace-secondary-button" type="submit" disabled={searchBusy}>
{searchBusy ? "Searching…" : "Search"}
</button>
</form>
<div class="ticket-search-context">
<details class="ticket-search-help">
<summary>Search syntax</summary>
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
</details>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
</div>
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
<section class="ticket-list" aria-label="Tickets" aria-busy={searchBusy || pageBusy}>
{#if response.data}
<p class="ticket-list-count" aria-live="polite">{response.data.items.length} tickets loaded</p>
{/if}
<ul class="ticket-list-rows">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const assignment = currentOrchestratorAssignment(ticket)}
<li>
<a class="ticket-list-row" data-state={ticket.state} href={ticketHref(data.workspaceId, ticket)}>
<span class="ticket-list-state">{ticket.state === "open" ? "Open" : "Closed"}</span>
<div class="ticket-list-content">
<strong class="ticket-list-title">{ticket.title}</strong>
<div class="ticket-list-meta">
<span class="ticket-list-key">{ticket.resource_key}</span>
<time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time>
{#each ticket.tags as tag (tag.id)}<span class="ticket-list-tag">{tag.name}</span>{/each}
{#if ticket.current_closure}<span>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
{#if assignment}<span title={assignmentPrincipalLabel(assignment.principal)}>Handling: {handlingLabel(assignment.tracking?.status)} ({assignment.tracking ? "self-reported" : "not reported"})</span>{/if}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if response.error}
<div class="ticket-list-error" role="alert">
<span>{response.error}</span>
<button class="workspace-secondary-button" type="button" disabled={pageBusy || searchBusy}
onclick={() => data.queryRequest ? loadMore() : searchTickets()}>Retry</button>
</div>
{:else if !response.data?.items.length && !searchBusy}
<p class="ticket-list-empty">No tickets match this query.</p>
{/if}
{#if pageBusy}
<p class="ticket-list-page-state" aria-live="polite">Loading…</p>
{:else if response.data?.page.has_more && !response.error}
<button class="workspace-secondary-button ticket-load-more" type="button" disabled={searchBusy} onclick={loadMore}>Load more tickets</button>
{/if}
</section>
</div>
<style>
.ticket-query-form { display: flex; align-items: stretch; gap: var(--space-2); margin-top: var(--space-4); }
.ticket-query-form input { flex: 1; min-width: 0; padding: var(--space-2) var(--space-3); color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.ticket-query-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ticket-search-context { display: flex; align-items: start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); margin-block: var(--space-2) var(--space-4); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-search-context a { color: inherit; }
.ticket-search-help { min-width: 0; max-width: 100%; }
.ticket-search-help summary { cursor: pointer; width: fit-content; }
.ticket-search-help p { margin-block: var(--space-2); overflow-wrap: anywhere; }
.ticket-search-help code { font-size: inherit; }
.ticket-list { min-width: 0; }
.ticket-list-count { margin: 0; padding-block: var(--space-2); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-list-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.ticket-list-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: start; gap: var(--space-2); padding-block: var(--space-3); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; }
.ticket-list-row:hover { background: var(--interactive-hover); }
.ticket-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ticket-list-state { padding-top: 2px; font-size: var(--font-size-compact); color: var(--text-muted); }
.ticket-list-row[data-state="open"] .ticket-list-state { color: var(--success); }
.ticket-list-content { min-width: 0; }
.ticket-list-title { display: block; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); overflow-wrap: anywhere; }
.ticket-list-meta { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin-top: var(--space-1); font-size: var(--font-size-compact); color: var(--text-muted); overflow-wrap: anywhere; }
.ticket-list-key { font-family: var(--font-mono); }
.ticket-list-tag { color: var(--text); }
.ticket-list-empty, .ticket-list-page-state { padding-block: var(--space-4); margin: 0; color: var(--text-muted); }
.ticket-list-error { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); padding-block: var(--space-4); color: var(--danger); overflow-wrap: anywhere; }
.ticket-load-more { margin-block: var(--space-4); }
@media (max-width: 480px) { .ticket-list-row { grid-template-columns: 3.5rem minmax(0, 1fr); } }
</style>
<DeferredPage {data} title="Tickets">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -16,8 +16,9 @@ import type {
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params, url }) => {
export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const workspaceId = params.workspaceId;
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
const loadTickets = async () => {
@@ -84,4 +85,4 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return { workspaceId, search, ...result, orchestrator, repositories };
};
});
@@ -0,0 +1,376 @@
<script lang="ts">
// 検索条件とページ順序はBackendのqueryに任せ、取得済みの一部を並べ替えない。
// 案件状態と担当の自己申告は、同じ行でも別の情報として表示する。
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "#lib/workspace/api/http.ts";
import {
loadJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketQueryResponse,
parseTicketRecordRef,
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
TicketQueryItem as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<RouteData>;
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
let { data }: { data: PageData } = $props();
let response = $state(untrack(() => data.tickets));
let searchText = $state(untrack(() => data.search));
let searchBusy = $state(false);
let pageBusy = $state(false);
let navigationError = $state<string | null>(null);
$effect(() => {
const currentData = data;
untrack(() => {
response = currentData.tickets;
searchText = currentData.search;
pageBusy = false;
navigationError = null;
orchestrator = currentData.orchestrator;
});
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
let createError = $state<string | null>(null);
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
const keys = new Set<string>();
for (const target of createTargets) {
if (
!repositories.some((repository) =>
repository.repository_key === target.repository_key
) || keys.has(target.repository_key)
) return false;
keys.add(target.repository_key);
}
return true;
});
async function searchTickets(event?: SubmitEvent) {
event?.preventDefault();
if (searchBusy) return;
searchBusy = true;
navigationError = null;
try {
const query = new URLSearchParams({ q: searchText.trim() });
await goto(`/w/${encodeURIComponent(data.workspaceId)}/tickets?${query}`, {
reset: false, refreshAll: true,
});
} catch (error) {
navigationError = error instanceof Error ? error.message : String(error);
} finally {
searchBusy = false;
}
}
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(): Promise<void> {
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
const observedData = data;
const previous = response.data;
pageBusy = true;
response = { data: previous, error: null };
const result = await loadJson(
fetch,
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
{
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
if (data !== observedData) return;
response = result.data
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
: { data: previous, error: result.error };
pageBusy = false;
}
function addCreateTarget(): void {
createTargets.push({
repository_key: "",
ref_selector: "",
access: createTargets.some((target) => target.access === "read_write")
? "read_only"
: "read_write",
});
}
function removeCreateTarget(index: number): void {
createTargets.splice(index, 1);
}
function normalizedCreateTargets(): TicketTarget[] {
return createTargets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
createBusy = true;
createError = null;
const request: NewTicket = {
title: createTitle.trim(),
kind: "task",
priority: "P2",
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "open",
targets: normalizedCreateTargets(),
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
} finally {
createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
orchestratorStarting = true;
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(data.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
orchestratorStarting = false;
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
</script>
<svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<h1>Tickets</h1>
</div>
<div class="ticket-panel-controls">
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
class="workspace-secondary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
>
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
</button>
{/if}
</div>
</div>
</header>
{#if creatingTicket}
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{/if}
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
<div class="ticket-target-list">
<strong>Repository resources (optional)</strong>
{#each createTargets as target, index}
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} required>
<option value="">Choose repository</option>
{#each repositories as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} /></label>
<label>Access
<select bind:value={target.access}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeCreateTarget(index)}>Remove target</button>
</fieldset>
{/each}
<button class="workspace-secondary-button" type="button" onclick={addCreateTarget}>Add target</button>
</div>
{#if !createTargetsSavable}
<p class="workspace-empty-copy">Each optional resource row must select a different registered repository. Empty or read-only resources are allowed.</p>
{/if}
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
{createBusy ? "Creating…" : "Create Ticket"}
</button>
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{/if}
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
placeholder="Search tickets · state:open" spellcheck="false" />
<button class="workspace-secondary-button" type="submit" disabled={searchBusy}>
{searchBusy ? "Searching…" : "Search"}
</button>
</form>
<div class="ticket-search-context">
<details class="ticket-search-help">
<summary>Search syntax</summary>
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
</details>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
</div>
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
<section class="ticket-list" aria-label="Tickets" aria-busy={searchBusy || pageBusy}>
{#if response.data}
<p class="ticket-list-count" aria-live="polite">{response.data.items.length} tickets loaded</p>
{/if}
<ul class="ticket-list-rows">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const assignment = currentOrchestratorAssignment(ticket)}
<li>
<a class="ticket-list-row" data-state={ticket.state} href={ticketHref(data.workspaceId, ticket)}>
<span class="ticket-list-state">{ticket.state === "open" ? "Open" : "Closed"}</span>
<div class="ticket-list-content">
<strong class="ticket-list-title">{ticket.title}</strong>
<div class="ticket-list-meta">
<span class="ticket-list-key">{ticket.resource_key}</span>
<time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time>
{#each ticket.tags as tag (tag.id)}<span class="ticket-list-tag">{tag.name}</span>{/each}
{#if ticket.current_closure}<span>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
{#if assignment}<span title={assignmentPrincipalLabel(assignment.principal)}>Handling: {handlingLabel(assignment.tracking?.status)} ({assignment.tracking ? "self-reported" : "not reported"})</span>{/if}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if response.error}
<div class="ticket-list-error" role="alert">
<span>{response.error}</span>
<button class="workspace-secondary-button" type="button" disabled={pageBusy || searchBusy}
onclick={() => data.queryRequest ? loadMore() : searchTickets()}>Retry</button>
</div>
{:else if !response.data?.items.length && !searchBusy}
<p class="ticket-list-empty">No tickets match this query.</p>
{/if}
{#if pageBusy}
<p class="ticket-list-page-state" aria-live="polite">Loading…</p>
{:else if response.data?.page.has_more && !response.error}
<button class="workspace-secondary-button ticket-load-more" type="button" disabled={searchBusy} onclick={loadMore}>Load more tickets</button>
{/if}
</section>
</div>
<style>
.ticket-query-form { display: flex; align-items: stretch; gap: var(--space-2); margin-top: var(--space-4); }
.ticket-query-form input { flex: 1; min-width: 0; padding: var(--space-2) var(--space-3); color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.ticket-query-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ticket-search-context { display: flex; align-items: start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); margin-block: var(--space-2) var(--space-4); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-search-context a { color: inherit; }
.ticket-search-help { min-width: 0; max-width: 100%; }
.ticket-search-help summary { cursor: pointer; width: fit-content; }
.ticket-search-help p { margin-block: var(--space-2); overflow-wrap: anywhere; }
.ticket-search-help code { font-size: inherit; }
.ticket-list { min-width: 0; }
.ticket-list-count { margin: 0; padding-block: var(--space-2); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-list-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.ticket-list-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: start; gap: var(--space-2); padding-block: var(--space-3); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; }
.ticket-list-row:hover { background: var(--interactive-hover); }
.ticket-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ticket-list-state { padding-top: 2px; font-size: var(--font-size-compact); color: var(--text-muted); }
.ticket-list-row[data-state="open"] .ticket-list-state { color: var(--success); }
.ticket-list-content { min-width: 0; }
.ticket-list-title { display: block; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); overflow-wrap: anywhere; }
.ticket-list-meta { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin-top: var(--space-1); font-size: var(--font-size-compact); color: var(--text-muted); overflow-wrap: anywhere; }
.ticket-list-key { font-family: var(--font-mono); }
.ticket-list-tag { color: var(--text); }
.ticket-list-empty, .ticket-list-page-state { padding-block: var(--space-4); margin: 0; color: var(--text-muted); }
.ticket-list-error { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); padding-block: var(--space-4); color: var(--danger); overflow-wrap: anywhere; }
.ticket-load-more { margin-block: var(--space-4); }
@media (max-width: 480px) { .ticket-list-row { grid-template-columns: 3.5rem minmax(0, 1fr); } }
</style>
@@ -1,764 +1,13 @@
<script lang="ts">
import { untrack } from "svelte";
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import {
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketSummary,
parseTicketDetail,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import {
relationLabel,
ticketWorkerLaunchHref,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { ApiResult } from "#lib/workspace/api/http.ts";
import type {
TicketCaseAction,
TicketClosureReason,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "#lib/workspace/sidebar/types.ts";
import PageContent from "./PageContent.svelte";
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import type { PageData } from "./$types";
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key,
ref_selector: target.ref_selector ?? "",
access: target.access,
}));
}
function normalizedTargets(targets: EditableTicketTarget[]): TicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
const { data } = $props<{
data: {
workspaceId: string;
ticketId: string;
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = $derived(data.repositories.data);
let ticket = $state<TicketDetail>(loadedTicket);
const mergeRequests = $derived(ticket.merge_requests);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let transitionReason = $state("");
let closureReason = $state("unknown");
let otherClosureReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let caseOperation = $state<ObservedOperation | null>(null);
let caseSaved = $state(false);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
let cancellationReason = $state("");
let routeTicketSnapshot = JSON.stringify([initialData.workspaceId, initialData.ticketId, loadedTicket]);
let routeGeneration = 0;
const workerAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "worker") ?? null,
);
function repositoryFor(repositoryKey: string): RepositorySummary | null {
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
repository.repository_key === repositoryKey
) ?? null;
}
function effectiveRefSelector(target: EditableTicketTarget): string {
return target.ref_selector.trim() ||
repositoryFor(target.repository_key)?.default_selector || "";
}
const targetCandidateValid = $derived.by(() => {
const repositoryKeys = new Set<string>();
for (const target of targetDrafts) {
const repository = repositoryFor(target.repository_key);
if (
!target.repository_key || repository === null ||
repositoryKeys.has(target.repository_key)
) return false;
repositoryKeys.add(target.repository_key);
}
return true;
});
const implementationStartEligible = $derived(
ticket.action_eligibility.can_start_manual_worker,
);
const ticketPath = $derived(
workspaceApiPath(
data.workspaceId,
`/tickets/${encodeURIComponent(data.ticketId)}`,
),
);
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
targetDrafts = editableTargets(updatedTicket.targets);
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
closureReason = "unknown";
otherClosureReason = "";
threadRole = "comment";
threadBody = "";
busy = null;
errorMessage = null;
caseOperation = null;
caseSaved = false;
manualRuntimeId = "";
manualWorkerId = "";
manualBindings = [];
cancellationReason = "";
}
$effect(() => {
const incomingTicketId = data.ticketId;
const incomingTicket = data.ticket.data;
if (!incomingTicket) return;
const incomingSnapshot = JSON.stringify([data.workspaceId, incomingTicketId, incomingTicket]);
untrack(() => {
if (incomingSnapshot === routeTicketSnapshot) return;
routeTicketSnapshot = incomingSnapshot;
routeGeneration += 1;
resetTicketView(incomingTicket);
});
});
function ticketMutationError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current content or resource binding before retrying. ${message}`;
if (/\(401\)|\(403\)/.test(message)) return `Permission denied. This action did not grant access. ${message}`;
if (/\(404\)/.test(message)) return `Resource unavailable or not connected. Check the Ticket and authorized resource connection. ${message}`;
return `Outcome unknown or request rejected. Refresh before retrying; no success is inferred. ${message}`;
}
async function mutate(
action: string,
suffix: string,
body?: Record<string, unknown>,
method = "POST",
): Promise<boolean> {
if (busy) return false;
const generation = routeGeneration;
const path = `${ticketPath}${suffix}`;
busy = action;
errorMessage = null;
try {
const response = await workspaceApiJsonWithBody(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
} catch (error) {
if (generation === routeGeneration) {
errorMessage = ticketMutationError(error);
}
return false;
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
busy = "refresh";
try {
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
errorMessage = null;
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshOperationTicket(): Promise<void> {
const generation = routeGeneration;
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
}
async function mutateAssignment(
action: string,
role: "orchestrator" | "worker",
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = action;
errorMessage = null;
try {
await workspaceApiJsonWithBody(
`${path}/assignments/${role}`,
{
method: "PUT",
body: JSON.stringify({
operation_id: crypto.randomUUID(),
principal,
expected_assignment_id: ticket.assignments.find((row) => row.role === role)?.assignment_id ?? null,
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
}),
},
parseTicketRoleAssignmentMutationResponse,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
if (generation === routeGeneration) {
errorMessage = ticketMutationError(error);
}
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
await mutateAssignment("start-manual", "worker", {
kind: "worker",
runtime_id: manualRuntimeId.trim(),
worker_id: manualWorkerId.trim(),
});
}
async function cancelImplementation(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!workerAssignment || !cancellationReason.trim()) return;
if (
await mutate("cancel-implementation", "/implementation-cancellations", {
operation_id: crypto.randomUUID(),
assignment_id: workerAssignment.assignment_id,
reason: cancellationReason.trim(),
})
) cancellationReason = "";
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
function addTarget(access: TicketTargetAccess = "read_only"): void {
targetDrafts.push({ repository_key: "", ref_selector: "", access });
}
function removeTarget(index: number): void {
targetDrafts.splice(index, 1);
}
function targetEditBody(): Record<string, unknown> {
const targets = normalizedTargets(targetDrafts);
return {
target: targets.length > 0
? { action: "set", targets }
: { action: "clear" },
};
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", targetEditBody(), "PATCH");
}
function selectedClosureReason(): TicketClosureReason {
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || caseOperation || !transitionReason.trim()) return;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
await runCaseDecision(observeOperation(path, "POST", request));
}
async function runCaseDecision(operation: ObservedOperation): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = "case";
caseOperation = operation;
errorMessage = null;
caseSaved = false;
try {
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
caseOperation = null;
caseSaved = true;
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
transitionReason = "";
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
}
function eventTitle(kind: string): string {
return relationLabel(kind);
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
<div class="workspace-page ticket-detail-page">
<header class="ticket-detail-header">
<div>
<div class="ticket-detail-kicker">
<span>{ticket.resource_key}</span>
<span class="workspace-status-pill" data-status={ticket.state}>Case: {ticket.state}</span>
</div>
<h1>{ticket.title}</h1>
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
{#if errorMessage}
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
{/if}
</section>
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
{/key}
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
</div>
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
{#if blocker.blocking_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
<strong>Dependency {blocker.blocking_resource_key} remains unresolved</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{:else}
<div>
<strong>Blocked by resource key unavailable</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</div>
{/if}
{/each}
</div>
{/if}
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
{#if relation.target_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_resource_key)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#each ticket.relations.incoming as relation}
{#if relation.source_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_resource_key)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
<p class="workspace-empty-copy">No Ticket relations.</p>
{/if}
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
{#each event.references as reference}<p>{reference}</p>{/each}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-case-card">
<header><h2>Case decision</h2></header>
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
{#if caseOperation}
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
{/if}
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
<label>Closure reason
<select bind:value={closureReason}>
<option value="completed">Completed</option>
<option value="not_planned">Not planned</option>
<option value="duplicate">Duplicate</option>
<option value="unknown">Unknown</option>
<option value="other">Other</option>
</select>
</label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
<section class="ticket-control-card ticket-worker-card">
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
{#each ticket.assignments as assignment}
<li>
<strong>{assignment.role}</strong>
<span>
{#if assignment.principal.kind === "worker"}
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
{:else if assignment.principal.kind === "user"}
{assignment.principal.account_id}
{:else}
{assignment.principal.agent_key}
{/if}
</span>
</li>
{/each}
</ul>
{:else}
<p class="workspace-empty-copy">No role responsibility recorded.</p>
{/if}
{#if implementationStartEligible}
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<p class="workspace-empty-copy">Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.</p>
{#each manualBindings as binding, index}
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
<label>Alias<input bind:value={binding.alias} required /></label>
<label>Workdir ID<input bind:value={binding.working_directory_id} required /></label>
<label>Connection ID<input bind:value={binding.connection_id} required /></label>
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.splice(index, 1)}>Remove binding</button>
</fieldset>
{/each}
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.push({ alias: "", working_directory_id: "", connection_id: "" })}>Add resource binding</button>
<button
class="workspace-secondary-button"
type="submit"
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
>
{busy === "start-manual" ? "Assigning…" : "Assign Worker"}
</button>
</form></details>
{/if}
{#if workerAssignment}
<details class="ticket-cancel-implementation">
<summary>Cancel implementation</summary>
<form class="ticket-control-form" onsubmit={cancelImplementation}>
<p class="workspace-empty-copy">
Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir.
</p>
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
<button
class="workspace-danger-button"
type="submit"
disabled={busy !== null || !cancellationReason.trim()}
>
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"}
</button>
</form>
</details>
{/if}
{#if ticket.assignment_diagnostics.length > 0}
{#each ticket.assignment_diagnostics as diagnostic}
<p class="workspace-callout">{diagnostic}</p>
{/each}
{/if}
</section>
<section class="ticket-control-card">
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
<details class="ticket-control-card">
<summary>Record result or comment</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
</form>
</details>
{#if mergeRequests.length > 0}
<section class="ticket-control-card">
<header><h2>MR requirement evidence</h2></header>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</section>
{/if}
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
{#if mergeRequest.state === "merged"}
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
{:else}
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
{/if}
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
{/if}
</section>
{#if ticket.current_closure}
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
<RichMarkdown text={ticket.current_closure.explanation} />
{#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</section>
{/if}
{#if ticket.closure_history.length > 0}
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
{#each ticket.closure_history as judgment (judgment.operation_key)}
<article>
<p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p>
<RichMarkdown text={judgment.explanation} />
{#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</article>
{/each}
</details>
{/if}
{#if ticket.legacy_handoff}
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
</details>
{/if}
</aside>
</div>
</div>
<DeferredPage {data} title="Ticket">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -12,8 +12,9 @@ import {
} from "#lib/workspace/resource-links.ts";
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = (async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const reference = resourceKey(params.ticketId);
const ticketPath = workspaceApiPath(
params.workspaceId,
@@ -58,4 +59,4 @@ export const load = (async ({ fetch, params }) => {
repositories,
orchestrator,
};
}) satisfies PageLoad;
});
@@ -0,0 +1,757 @@
<script lang="ts">
import { untrack } from "svelte";
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import {
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketSummary,
parseTicketDetail,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import {
relationLabel,
ticketWorkerLaunchHref,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type {
TicketCaseAction,
TicketClosureReason,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import type {
RepositorySummary,
TicketDetail,
} from "#lib/workspace/sidebar/types.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
type PageData = ResolvedPageData<RouteData>;
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key,
ref_selector: target.ref_selector ?? "",
access: target.access,
}));
}
function normalizedTargets(targets: EditableTicketTarget[]): TicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
const { data } = $props<{ data: PageData }>();
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = $derived(data.repositories.data);
let ticket = $state<TicketDetail>(loadedTicket);
const mergeRequests = $derived(ticket.merge_requests);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let transitionReason = $state("");
let closureReason = $state("unknown");
let otherClosureReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let caseOperation = $state<ObservedOperation | null>(null);
let caseSaved = $state(false);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
let cancellationReason = $state("");
let routeTicketSnapshot = JSON.stringify([initialData.workspaceId, initialData.ticketId, loadedTicket]);
let routeGeneration = 0;
const workerAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "worker") ?? null,
);
function repositoryFor(repositoryKey: string): RepositorySummary | null {
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
repository.repository_key === repositoryKey
) ?? null;
}
function effectiveRefSelector(target: EditableTicketTarget): string {
return target.ref_selector.trim() ||
repositoryFor(target.repository_key)?.default_selector || "";
}
const targetCandidateValid = $derived.by(() => {
const repositoryKeys = new Set<string>();
for (const target of targetDrafts) {
const repository = repositoryFor(target.repository_key);
if (
!target.repository_key || repository === null ||
repositoryKeys.has(target.repository_key)
) return false;
repositoryKeys.add(target.repository_key);
}
return true;
});
const implementationStartEligible = $derived(
ticket.action_eligibility.can_start_manual_worker,
);
const ticketPath = $derived(
workspaceApiPath(
data.workspaceId,
`/tickets/${encodeURIComponent(data.ticketId)}`,
),
);
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
targetDrafts = editableTargets(updatedTicket.targets);
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
closureReason = "unknown";
otherClosureReason = "";
threadRole = "comment";
threadBody = "";
busy = null;
errorMessage = null;
caseOperation = null;
caseSaved = false;
manualRuntimeId = "";
manualWorkerId = "";
manualBindings = [];
cancellationReason = "";
}
$effect(() => {
const incomingTicketId = data.ticketId;
const incomingTicket = data.ticket.data;
if (!incomingTicket) return;
const incomingSnapshot = JSON.stringify([data.workspaceId, incomingTicketId, incomingTicket]);
untrack(() => {
if (incomingSnapshot === routeTicketSnapshot) return;
routeTicketSnapshot = incomingSnapshot;
routeGeneration += 1;
resetTicketView(incomingTicket);
});
});
function ticketMutationError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/\(409\)/.test(message)) return `Update conflict. Refresh the Ticket and resolve the current content or resource binding before retrying. ${message}`;
if (/\(401\)|\(403\)/.test(message)) return `Permission denied. This action did not grant access. ${message}`;
if (/\(404\)/.test(message)) return `Resource unavailable or not connected. Check the Ticket and authorized resource connection. ${message}`;
return `Outcome unknown or request rejected. Refresh before retrying; no success is inferred. ${message}`;
}
async function mutate(
action: string,
suffix: string,
body?: Record<string, unknown>,
method = "POST",
): Promise<boolean> {
if (busy) return false;
const generation = routeGeneration;
const path = `${ticketPath}${suffix}`;
busy = action;
errorMessage = null;
try {
const response = await workspaceApiJsonWithBody(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
} catch (error) {
if (generation === routeGeneration) {
errorMessage = ticketMutationError(error);
}
return false;
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
busy = "refresh";
try {
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
errorMessage = null;
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshOperationTicket(): Promise<void> {
const generation = routeGeneration;
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
}
async function mutateAssignment(
action: string,
role: "orchestrator" | "worker",
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = action;
errorMessage = null;
try {
await workspaceApiJsonWithBody(
`${path}/assignments/${role}`,
{
method: "PUT",
body: JSON.stringify({
operation_id: crypto.randomUUID(),
principal,
expected_assignment_id: ticket.assignments.find((row) => row.role === role)?.assignment_id ?? null,
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
}),
},
parseTicketRoleAssignmentMutationResponse,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
if (generation === routeGeneration) {
errorMessage = ticketMutationError(error);
}
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
await mutateAssignment("start-manual", "worker", {
kind: "worker",
runtime_id: manualRuntimeId.trim(),
worker_id: manualWorkerId.trim(),
});
}
async function cancelImplementation(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!workerAssignment || !cancellationReason.trim()) return;
if (
await mutate("cancel-implementation", "/implementation-cancellations", {
operation_id: crypto.randomUUID(),
assignment_id: workerAssignment.assignment_id,
reason: cancellationReason.trim(),
})
) cancellationReason = "";
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
function addTarget(access: TicketTargetAccess = "read_only"): void {
targetDrafts.push({ repository_key: "", ref_selector: "", access });
}
function removeTarget(index: number): void {
targetDrafts.splice(index, 1);
}
function targetEditBody(): Record<string, unknown> {
const targets = normalizedTargets(targetDrafts);
return {
target: targets.length > 0
? { action: "set", targets }
: { action: "clear" },
};
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", targetEditBody(), "PATCH");
}
function selectedClosureReason(): TicketClosureReason {
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || caseOperation || !transitionReason.trim()) return;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
await runCaseDecision(observeOperation(path, "POST", request));
}
async function runCaseDecision(operation: ObservedOperation): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = "case";
caseOperation = operation;
errorMessage = null;
caseSaved = false;
try {
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
caseOperation = null;
caseSaved = true;
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
transitionReason = "";
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
}
function eventTitle(kind: string): string {
return relationLabel(kind);
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
<div class="workspace-page ticket-detail-page">
<header class="ticket-detail-header">
<div>
<div class="ticket-detail-kicker">
<span>{ticket.resource_key}</span>
<span class="workspace-status-pill" data-status={ticket.state}>Case: {ticket.state}</span>
</div>
<h1>{ticket.title}</h1>
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
{#if errorMessage}
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
{/if}
</section>
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
{/key}
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
</div>
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
{#if blocker.blocking_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
<strong>Dependency {blocker.blocking_resource_key} remains unresolved</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{:else}
<div>
<strong>Blocked by resource key unavailable</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</div>
{/if}
{/each}
</div>
{/if}
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
{#if relation.target_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_resource_key)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#each ticket.relations.incoming as relation}
{#if relation.source_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_resource_key)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_resource_key}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{:else}
<span><strong>resource key unavailable</strong></span>
{/if}
{/each}
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
<p class="workspace-empty-copy">No Ticket relations.</p>
{/if}
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
{#each event.references as reference}<p>{reference}</p>{/each}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-case-card">
<header><h2>Case decision</h2></header>
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
{#if caseOperation}
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
{/if}
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
<label>Closure reason
<select bind:value={closureReason}>
<option value="completed">Completed</option>
<option value="not_planned">Not planned</option>
<option value="duplicate">Duplicate</option>
<option value="unknown">Unknown</option>
<option value="other">Other</option>
</select>
</label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
<section class="ticket-control-card ticket-worker-card">
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
{#each ticket.assignments as assignment}
<li>
<strong>{assignment.role}</strong>
<span>
{#if assignment.principal.kind === "worker"}
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
{:else if assignment.principal.kind === "user"}
{assignment.principal.account_id}
{:else}
{assignment.principal.agent_key}
{/if}
</span>
</li>
{/each}
</ul>
{:else}
<p class="workspace-empty-copy">No role responsibility recorded.</p>
{/if}
{#if implementationStartEligible}
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<p class="workspace-empty-copy">Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.</p>
{#each manualBindings as binding, index}
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
<label>Alias<input bind:value={binding.alias} required /></label>
<label>Workdir ID<input bind:value={binding.working_directory_id} required /></label>
<label>Connection ID<input bind:value={binding.connection_id} required /></label>
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.splice(index, 1)}>Remove binding</button>
</fieldset>
{/each}
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => manualBindings.push({ alias: "", working_directory_id: "", connection_id: "" })}>Add resource binding</button>
<button
class="workspace-secondary-button"
type="submit"
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
>
{busy === "start-manual" ? "Assigning…" : "Assign Worker"}
</button>
</form></details>
{/if}
{#if workerAssignment}
<details class="ticket-cancel-implementation">
<summary>Cancel implementation</summary>
<form class="ticket-control-form" onsubmit={cancelImplementation}>
<p class="workspace-empty-copy">
Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir.
</p>
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
<button
class="workspace-danger-button"
type="submit"
disabled={busy !== null || !cancellationReason.trim()}
>
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"}
</button>
</form>
</details>
{/if}
{#if ticket.assignment_diagnostics.length > 0}
{#each ticket.assignment_diagnostics as diagnostic}
<p class="workspace-callout">{diagnostic}</p>
{/each}
{/if}
</section>
<section class="ticket-control-card">
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
<details class="ticket-control-card">
<summary>Record result or comment</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
</form>
</details>
{#if mergeRequests.length > 0}
<section class="ticket-control-card">
<header><h2>MR requirement evidence</h2></header>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</section>
{/if}
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
{#if mergeRequest.state === "merged"}
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
{:else}
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
{/if}
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
{/if}
</section>
{#if ticket.current_closure}
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
<RichMarkdown text={ticket.current_closure.explanation} />
{#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</section>
{/if}
{#if ticket.closure_history.length > 0}
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
{#each ticket.closure_history as judgment (judgment.operation_key)}
<article>
<p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p>
<RichMarkdown text={judgment.explanation} />
{#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</article>
{/each}
</details>
{/if}
{#if ticket.legacy_handoff}
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
</details>
{/if}
</aside>
</div>
</div>
@@ -1,481 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte';
import WorkerDriveGrants from '#lib/workspace/drive-grants/WorkerDriveGrants.svelte';
import { canRestoreWorker, createRestoreRequest, restoreWorkspaceWorker, restoreNotice, restoreErrorNotice, type RestoreRequest } from '#lib/workspace/sidebar/worker-actions.ts';
import { workspaceWorkersStore, refreshWorkspaceWorkers } from '#lib/workspace/sidebar/worker-subscription.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseWorkerRetentionResponse,
} from '#lib/workspace/api/runtime-workers.ts';
import { workerHref } from '#lib/workspace/resource-links.ts';
import { formatWorkdirPermissions } from '#lib/workspace/settings/workdir-permissions.ts';
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
import { canOpenWorkerConsole } from '#lib/workspace/sidebar/workers.ts';
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
import type { CleanupWorkerCandidate, RuntimeCleanupPlanResponse, Worker } from '#lib/workspace/sidebar/types.ts';
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
type WorkerActionKind = 'restore' | 'pin' | 'delete';
let { data }: PageProps = $props();
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let lifetime = 0;
let cleanupEpoch = 0;
let catalogRefreshing = $state(true);
let catalogReady = $state(false);
function actionScope() {
const id = data.workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && data.workspaceId === id };
}
$effect(() => {
const id = data.workspaceId;
lifetime++;
cleanupEpoch++;
busyAction = null;
restoreRetries = {};
catalogReady = false;
const initial = untrack(() => data);
workers = initial.workers?.items ?? [];
cleanupPlans = initial.cleanupPlans;
let previousCatalog: string | undefined;
let previousRequest: AbortSignal | null | undefined;
let previousRefreshing: boolean | undefined;
const scope = actionScope();
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
if (!scope.isCurrent()) return;
const observationChanged = previousRequest !== state.catalogRequest;
const refreshStarted = state.catalogRefreshing && previousRefreshing !== true;
const refreshCompleted = !state.catalogRefreshing && previousRefreshing === true;
const catalog = state.catalogWorkers === null ? undefined : JSON.stringify(state.catalogWorkers);
const catalogChanged = catalog !== previousCatalog;
previousRequest = state.catalogRequest;
previousRefreshing = state.catalogRefreshing;
previousCatalog = catalog;
catalogRefreshing = state.catalogRefreshing;
if (observationChanged || catalogChanged || refreshStarted) {
cleanupPlans = {};
++cleanupEpoch;
}
if (state.catalogWorkers === null) return;
catalogReady = true;
workers = state.catalogWorkers;
// Subscription overlays stay visible during the GET, but cleanup must wait
// for its authoritative catalog result, even if that result is unchanged.
if (catalogRefreshing || (!catalogChanged && !observationChanged && !refreshCompleted)) return;
cleanupPlans = {};
const epoch = ++cleanupEpoch;
for (const runtimeId of new Set(state.catalogWorkers.map((worker) => worker.runtime_id))) {
void refreshCleanupPlan(runtimeId, scope, epoch).catch(() => {});
}
});
return () => {
lifetime++;
cleanupEpoch++;
unsubscribe();
};
});
function workerKey(worker: Worker): string {
return `${worker.runtime_id}/${worker.worker_id}`;
}
function isActionBusy(worker: Worker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function actionsDisabled(): boolean {
return busyAction !== null;
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
if ('message' in payload && typeof payload.message === 'string') return payload.message;
if ('error' in payload) {
const error = payload.error;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message;
}
if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) {
const diagnostic = payload.diagnostics.find(
(entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string',
);
if (diagnostic) return diagnostic.message;
}
}
return fallback;
}
async function refreshCleanupPlan(
runtimeId: string,
scope = actionScope(),
epoch = cleanupEpoch,
): Promise<void> {
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
const response = await fetch(
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
if (!response.ok) return;
const plan = parseRuntimeCleanupPlan(await response.json());
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
}
async function restoreWorker(worker: Worker, retry = false): Promise<void> {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
busyAction = { workerKey: key, kind: 'restore' };
cleanupPlans = {};
++cleanupEpoch;
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
if (busyAction) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(
scope.workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
),
{ method: pinned ? 'PUT' : 'DELETE' },
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
parseWorkerRetentionResponse(payload);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
function cleanupCandidate(worker: Worker): CleanupWorkerCandidate | undefined {
return cleanupPlans?.[worker.runtime_id]?.workers.find(
(candidate) => candidate.runtime_id === worker.runtime_id && candidate.runtime_worker_id === worker.worker_id,
);
}
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
const plan = cleanupPlans?.[worker.runtime_id];
if (!plan || busyAction || candidate.blocking_reason || cleanupCandidate(worker) !== candidate) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
const execution = parseRuntimeCleanupExecution(payload);
const result = execution.results.find((entry) => entry.target_id === candidate.target_id);
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Worker');
}
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
function workerStatus(worker: Worker): string {
return liveWorkerState(worker);
}
function workerProfile(worker: Worker): string {
return worker.profile ?? 'unknown';
}
function workerDirectory(worker: Worker): string {
const attachments = worker.workdir_attachments ?? [];
if (attachments.length === 0) return '—';
return attachments.map(({ alias, effective_permissions, working_directory: directory }) => {
const repositoryKey = directory.source.kind === 'repository'
? directory.source.repository_key
: null;
const provider = repositoryKey
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') {
return `${alias}: ${label} · ${formatWorkdirPermissions(effective_permissions)}`;
}
return `${alias}: ${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
}).join(', ');
}
</script>
<svelte:head>
<title>Workers · Yoi Workspace</title>
<meta name="description" content="Workspace Workers" />
</svelte:head>
<section class="workers-page" aria-labelledby="workers-heading">
<header class="workers-page-header">
<div>
<h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
</div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if !catalogReady && data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !catalogReady && !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal scroll regions must be keyboard-focusable.) -->
<div class="table-wrap workers-table-wrap" role="region" aria-label="Workers" tabindex="0">
<table class="workers-table">
<thead>
<tr>
<th>Worker</th>
<th>Runtime</th>
<th>Profile</th>
<th>Status</th>
<th>Retention</th>
<th>Workdir</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each workers as worker}
{@const cleanup = cleanupCandidate(worker)}
{@const canDelete = cleanup && !cleanup.blocking_reason}
{@const anyActionDisabled = actionsDisabled()}
{@const workerDisplayName = worker.display_name || worker.label}
<tr>
<td>
{#if canOpenWorkerConsole(worker) && worker.resource_key}
<a class="worker-title-link" href={workerHref(data.workspaceId, { ...worker, resource_key: worker.resource_key })}><strong>{workerDisplayName}</strong></a>
{:else}
<strong>{workerDisplayName}</strong>
{/if}
<small>worker <code>{worker.resource_key}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
<td>{workerStatus(worker)}</td>
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
<td>{workerDirectory(worker)}</td>
<td>
<div class="worker-actions" aria-label={`Actions for ${workerDisplayName}`}>
<button
class="icon-action"
type="button"
disabled={anyActionDisabled || !canRestoreWorker(worker) || Boolean(restoreRetries[workerKey(worker)])}
aria-label={`Restore ${workerDisplayName}`}
title="Restore"
onclick={() => restoreWorker(worker)}
>
{#if isActionBusy(worker, 'restore')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 6.4" /><path d="M3 3v8h8" /></svg>
{/if}
</button>
{#if restoreRetries[workerKey(worker)]}
<button type="button" disabled={anyActionDisabled} onclick={() => restoreWorker(worker, true)} aria-label={`Retry Restore ${workerDisplayName}`}>Retry Restore</button>
<span role="status">Restore outcome unresolved. Retry the same request to reconcile.</span>
{/if}
<button
class="icon-action"
type="button"
disabled={anyActionDisabled}
aria-label={worker.pinned ? `Unpin ${workerDisplayName}` : `Pin ${workerDisplayName}`}
title={worker.pinned ? 'Unpin' : 'Pin'}
onclick={() => setPinned(worker, !worker.pinned)}
>
{#if isActionBusy(worker, 'pin')}
<span class="spinner" aria-hidden="true"></span>
{:else if worker.pinned}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" /></svg>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></svg>
{/if}
</button>
{#if cleanup}
<button
class="icon-action danger"
type="button"
disabled={!canDelete || anyActionDisabled}
aria-label={`Delete ${workerDisplayName}`}
title={cleanup.blocking_reason ?? cleanup.reason}
onclick={() => deleteWorker(worker, cleanup)}
>
{#if isActionBusy(worker, 'delete')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<WorkerDriveGrants
workspaceId={data.workspaceId}
{workers}
workersReady={catalogReady && !catalogRefreshing}
canManage={data.workspace?.permissions.manage_runtimes === true}
/>
</section>
<style>
.workers-page {
min-width: 0;
grid-template-columns: minmax(0, 1fr);
}
.workers-table-wrap {
min-width: 0;
overflow-x: auto;
}
.workers-table-wrap:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.worker-title-link {
color: inherit;
text-decoration: none;
}
.worker-title-link:hover,
.worker-title-link:focus-visible {
color: var(--accent);
text-decoration: underline;
}
.worker-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
.icon-action:hover:not(:disabled),
.icon-action:focus-visible:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: worker-action-spin 0.8s linear infinite;
}
@keyframes worker-action-spin {
to {
transform: rotate(360deg);
}
}
</style>
<DeferredPage {data} title="Workers">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -5,8 +5,9 @@ import type {
RuntimeCleanupPlanResponse,
} from "#lib/workspace/sidebar/types.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const workers = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/workers"),
@@ -45,4 +46,4 @@ export const load: PageLoad = async ({ fetch, params }) => {
cleanupPlans,
cleanupPlanErrors,
};
};
});
@@ -0,0 +1,483 @@
<script lang="ts">
import { untrack } from 'svelte';
import WorkerDriveGrants from '#lib/workspace/drive-grants/WorkerDriveGrants.svelte';
import { canRestoreWorker, createRestoreRequest, restoreWorkspaceWorker, restoreNotice, restoreErrorNotice, type RestoreRequest } from '#lib/workspace/sidebar/worker-actions.ts';
import { workspaceWorkersStore, refreshWorkspaceWorkers } from '#lib/workspace/sidebar/worker-subscription.ts';
import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import {
parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan,
parseWorkerRetentionResponse,
} from '#lib/workspace/api/runtime-workers.ts';
import { workerHref } from '#lib/workspace/resource-links.ts';
import { formatWorkdirPermissions } from '#lib/workspace/settings/workdir-permissions.ts';
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
import { canOpenWorkerConsole } from '#lib/workspace/sidebar/workers.ts';
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
import type { CleanupWorkerCandidate, RuntimeCleanupPlanResponse, Worker } from '#lib/workspace/sidebar/types.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as DeferredPageData } from './$types';
type PageData = ResolvedPageData<DeferredPageData>;
type WorkerActionKind = 'restore' | 'pin' | 'delete';
let { data }: { data: PageData } = $props();
let cleanupPlans = $state<Record<string, RuntimeCleanupPlanResponse>>({});
let workers = $state<Worker[]>([]);
let restoreRetries = $state<Record<string, RestoreRequest>>({});
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let lifetime = 0;
let cleanupEpoch = 0;
let catalogRefreshing = $state(true);
let catalogReady = $state(false);
function actionScope() {
const id = data.workspaceId;
const epoch = lifetime;
return { workspaceId: id, isCurrent: () => lifetime === epoch && data.workspaceId === id };
}
$effect(() => {
const id = data.workspaceId;
lifetime++;
cleanupEpoch++;
busyAction = null;
restoreRetries = {};
catalogReady = false;
const initial = untrack(() => data);
workers = initial.workers?.items ?? [];
cleanupPlans = initial.cleanupPlans;
let previousCatalog: string | undefined;
let previousRequest: AbortSignal | null | undefined;
let previousRefreshing: boolean | undefined;
const scope = actionScope();
const unsubscribe = workspaceWorkersStore(id).subscribe((state) => {
if (!scope.isCurrent()) return;
const observationChanged = previousRequest !== state.catalogRequest;
const refreshStarted = state.catalogRefreshing && previousRefreshing !== true;
const refreshCompleted = !state.catalogRefreshing && previousRefreshing === true;
const catalog = state.catalogWorkers === null ? undefined : JSON.stringify(state.catalogWorkers);
const catalogChanged = catalog !== previousCatalog;
previousRequest = state.catalogRequest;
previousRefreshing = state.catalogRefreshing;
previousCatalog = catalog;
catalogRefreshing = state.catalogRefreshing;
if (observationChanged || catalogChanged || refreshStarted) {
cleanupPlans = {};
++cleanupEpoch;
}
if (state.catalogWorkers === null) return;
catalogReady = true;
workers = state.catalogWorkers;
// Subscription overlays stay visible during the GET, but cleanup must wait
// for its authoritative catalog result, even if that result is unchanged.
if (catalogRefreshing || (!catalogChanged && !observationChanged && !refreshCompleted)) return;
cleanupPlans = {};
const epoch = ++cleanupEpoch;
for (const runtimeId of new Set(state.catalogWorkers.map((worker) => worker.runtime_id))) {
void refreshCleanupPlan(runtimeId, scope, epoch).catch(() => {});
}
});
return () => {
lifetime++;
cleanupEpoch++;
unsubscribe();
};
});
function workerKey(worker: Worker): string {
return `${worker.runtime_id}/${worker.worker_id}`;
}
function isActionBusy(worker: Worker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function actionsDisabled(): boolean {
return busyAction !== null;
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object') {
if ('message' in payload && typeof payload.message === 'string') return payload.message;
if ('error' in payload) {
const error = payload.error;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message;
}
if ('diagnostics' in payload && Array.isArray(payload.diagnostics)) {
const diagnostic = payload.diagnostics.find(
(entry): entry is { message: string } => Boolean(entry) && typeof entry === 'object' && 'message' in entry && typeof entry.message === 'string',
);
if (diagnostic) return diagnostic.message;
}
}
return fallback;
}
async function refreshCleanupPlan(
runtimeId: string,
scope = actionScope(),
epoch = cleanupEpoch,
): Promise<void> {
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
const response = await fetch(
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(runtimeId)}/cleanup-plan`),
);
if (!response.ok) return;
const plan = parseRuntimeCleanupPlan(await response.json());
if (!scope.isCurrent() || catalogRefreshing || cleanupEpoch !== epoch) return;
cleanupPlans = { ...cleanupPlans, [runtimeId]: plan };
}
async function restoreWorker(worker: Worker, retry = false): Promise<void> {
const key = workerKey(worker);
if (busyAction || (retry ? !restoreRetries[key] : restoreRetries[key] || !canRestoreWorker(worker))) return;
const scope = actionScope();
const request = retry ? restoreRetries[key] : createRestoreRequest(worker);
busyAction = { workerKey: key, kind: 'restore' };
cleanupPlans = {};
++cleanupEpoch;
try {
let notice;
try {
notice = restoreNotice(await restoreWorkspaceWorker(scope.workspaceId, worker, request));
} catch (cause) {
notice = restoreErrorNotice(cause);
}
if (!scope.isCurrent()) return;
const next = { ...restoreRetries };
if (notice.retry) next[key] = request;
else delete next[key];
restoreRetries = next;
pushWorkspaceAlert(notice.level, notice.message, { title: notice.title });
await refreshWorkspaceWorkers(scope.workspaceId).catch(() => {});
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope).catch(() => {});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
async function setPinned(worker: Worker, pinned: boolean): Promise<void> {
if (busyAction) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'pin' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(
scope.workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${encodeURIComponent(worker.worker_id)}/pin`,
),
{ method: pinned ? 'PUT' : 'DELETE' },
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
parseWorkerRetentionResponse(payload);
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker pin failed', {
title: 'Worker pin failed',
});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
function cleanupCandidate(worker: Worker): CleanupWorkerCandidate | undefined {
return cleanupPlans?.[worker.runtime_id]?.workers.find(
(candidate) => candidate.runtime_id === worker.runtime_id && candidate.runtime_worker_id === worker.worker_id,
);
}
async function deleteWorker(worker: Worker, candidate: CleanupWorkerCandidate): Promise<void> {
const plan = cleanupPlans?.[worker.runtime_id];
if (!plan || busyAction || candidate.blocking_reason || cleanupCandidate(worker) !== candidate) return;
const scope = actionScope();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
cleanupPlans = {};
++cleanupEpoch;
try {
const response = await fetch(
workspaceApiPath(scope.workspaceId, `/runtimes/${encodeURIComponent(worker.runtime_id)}/cleanup-executions`),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
const payload = await response.json().catch(() => null);
if (!scope.isCurrent()) return;
if (!response.ok) throw new Error(errorMessage(payload, response.statusText));
const execution = parseRuntimeCleanupExecution(payload);
const result = execution.results.find((entry) => entry.target_id === candidate.target_id);
if (!result || result.status !== 'deleted') {
throw new Error(result?.message ?? 'Runtime did not delete the selected Worker');
}
await refreshWorkspaceWorkers(scope.workspaceId);
if (!scope.isCurrent()) return;
await refreshCleanupPlan(worker.runtime_id, scope);
} catch (error) {
if (!scope.isCurrent()) return;
pushWorkspaceAlert('error', error instanceof Error ? error.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
if (scope.isCurrent()) busyAction = null;
}
}
function workerStatus(worker: Worker): string {
return liveWorkerState(worker);
}
function workerProfile(worker: Worker): string {
return worker.profile ?? 'unknown';
}
function workerDirectory(worker: Worker): string {
const attachments = worker.workdir_attachments ?? [];
if (attachments.length === 0) return '—';
return attachments.map(({ alias, effective_permissions, working_directory: directory }) => {
const repositoryKey = directory.source.kind === 'repository'
? directory.source.repository_key
: null;
const provider = repositoryKey
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') {
return `${alias}: ${label} · ${formatWorkdirPermissions(effective_permissions)}`;
}
return `${alias}: ${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
}).join(', ');
}
</script>
<svelte:head>
<title>Workers · Yoi Workspace</title>
<meta name="description" content="Workspace Workers" />
</svelte:head>
<section class="workers-page" aria-labelledby="workers-heading">
<header class="workers-page-header">
<div>
<h1 id="workers-heading">Workers</h1>
<p>Workers running or persisted for this workspace. Pinning updates Backend retention.</p>
</div>
<a class="section-action" href={`/w/${data.workspaceId}/workers/new`}>New Worker</a>
</header>
{#if !catalogReady && data.workersError}
<p class="section-state error">{data.workersError}</p>
{:else if !catalogReady && !data.workers}
<p class="section-state">Loading Workers…</p>
{:else if workers.length === 0}
<p class="section-state">No Workers are visible.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Horizontal scroll regions must be keyboard-focusable.) -->
<div class="table-wrap workers-table-wrap" role="region" aria-label="Workers" tabindex="0">
<table class="workers-table">
<thead>
<tr>
<th>Worker</th>
<th>Runtime</th>
<th>Profile</th>
<th>Status</th>
<th>Retention</th>
<th>Workdir</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{#each workers as worker}
{@const cleanup = cleanupCandidate(worker)}
{@const canDelete = cleanup && !cleanup.blocking_reason}
{@const anyActionDisabled = actionsDisabled()}
{@const workerDisplayName = worker.display_name || worker.label}
<tr>
<td>
{#if canOpenWorkerConsole(worker) && worker.resource_key}
<a class="worker-title-link" href={workerHref(data.workspaceId, { ...worker, resource_key: worker.resource_key })}><strong>{workerDisplayName}</strong></a>
{:else}
<strong>{workerDisplayName}</strong>
{/if}
<small>worker <code>{worker.resource_key}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
<td>{workerStatus(worker)}</td>
<td><span class="pill {worker.pinned ? 'success' : 'muted'}">{worker.retention_state ?? 'normal'}</span></td>
<td>{workerDirectory(worker)}</td>
<td>
<div class="worker-actions" aria-label={`Actions for ${workerDisplayName}`}>
<button
class="icon-action"
type="button"
disabled={anyActionDisabled || !canRestoreWorker(worker) || Boolean(restoreRetries[workerKey(worker)])}
aria-label={`Restore ${workerDisplayName}`}
title="Restore"
onclick={() => restoreWorker(worker)}
>
{#if isActionBusy(worker, 'restore')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2.6 6.4" /><path d="M3 3v8h8" /></svg>
{/if}
</button>
{#if restoreRetries[workerKey(worker)]}
<button type="button" disabled={anyActionDisabled} onclick={() => restoreWorker(worker, true)} aria-label={`Retry Restore ${workerDisplayName}`}>Retry Restore</button>
<span role="status">Restore outcome unresolved. Retry the same request to reconcile.</span>
{/if}
<button
class="icon-action"
type="button"
disabled={anyActionDisabled}
aria-label={worker.pinned ? `Unpin ${workerDisplayName}` : `Pin ${workerDisplayName}`}
title={worker.pinned ? 'Unpin' : 'Pin'}
onclick={() => setPinned(worker, !worker.pinned)}
>
{#if isActionBusy(worker, 'pin')}
<span class="spinner" aria-hidden="true"></span>
{:else if worker.pinned}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" /></svg>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></svg>
{/if}
</button>
{#if cleanup}
<button
class="icon-action danger"
type="button"
disabled={!canDelete || anyActionDisabled}
aria-label={`Delete ${workerDisplayName}`}
title={cleanup.blocking_reason ?? cleanup.reason}
onclick={() => deleteWorker(worker, cleanup)}
>
{#if isActionBusy(worker, 'delete')}
<span class="spinner" aria-hidden="true"></span>
{:else}
<svg class="action-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
{/if}
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<WorkerDriveGrants
workspaceId={data.workspaceId}
{workers}
workersReady={catalogReady && !catalogRefreshing}
canManage={data.workspace?.permissions.manage_runtimes === true}
/>
</section>
<style>
.workers-page {
min-width: 0;
grid-template-columns: minmax(0, 1fr);
}
.workers-table-wrap {
min-width: 0;
overflow-x: auto;
}
.workers-table-wrap:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.worker-title-link {
color: inherit;
text-decoration: none;
}
.worker-title-link:hover,
.worker-title-link:focus-visible {
color: var(--accent);
text-decoration: underline;
}
.worker-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg-raised);
color: var(--text);
cursor: pointer;
}
.icon-action:hover:not(:disabled),
.icon-action:focus-visible:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.icon-action.danger:hover:not(:disabled),
.icon-action.danger:focus-visible:not(:disabled) {
border-color: var(--danger, oklch(60% 0.18 30));
color: var(--danger, oklch(60% 0.18 30));
}
.icon-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.action-icon {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
animation: worker-action-spin 0.8s linear infinite;
}
@keyframes worker-action-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,30 +1,13 @@
<script lang="ts">
import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head><title>{data.worker?.resource_key ?? 'Worker'} · Yoi</title></svelte:head>
<section class="workspace-page-shell">
{#if data.workerError}
<p class="error-message">{data.workerError}</p>
{:else if data.worker}
<header class="workspace-page-header">
<div>
<p class="eyebrow">{data.worker.resource_key}</p>
<h1>{data.worker.display_name}</h1>
</div>
<a
class="button-primary"
href={workerConsoleHref(data.workspaceId, data.worker)}
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{liveWorkerState(data.worker)}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>
{/if}
</section>
<DeferredPage {data} title="Worker">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -6,8 +6,9 @@ import {
resourceKey,
} from "#lib/workspace/resource-links.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = (async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const reference = resourceKey(params.workerRef);
const result = await loadJson(
fetch,
@@ -36,4 +37,4 @@ export const load = (async ({ fetch, params }) => {
worker: result.data,
workerError: result.error,
};
}) satisfies PageLoad;
});
@@ -0,0 +1,32 @@
<script lang="ts">
import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
import { liveWorkerState } from '#lib/workspace/sidebar/worker-state.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as DeferredPageData } from './$types';
type PageData = ResolvedPageData<DeferredPageData>;
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{data.worker?.resource_key ?? 'Worker'} · Yoi</title></svelte:head>
<section class="workspace-page-shell">
{#if data.workerError}
<p class="error-message">{data.workerError}</p>
{:else if data.worker}
<header class="workspace-page-header">
<div>
<p class="eyebrow">{data.worker.resource_key}</p>
<h1>{data.worker.display_name}</h1>
</div>
<a
class="button-primary"
href={workerConsoleHref(data.workspaceId, data.worker)}
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{liveWorkerState(data.worker)}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>
{/if}
</section>
File diff suppressed because it is too large Load Diff
@@ -5,9 +5,10 @@ import {
canonicalResourceReference,
resourceKey,
} from "#lib/workspace/resource-links.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const reference = resourceKey(params.workerRef);
const result = await loadJson(
fetch,
@@ -41,4 +42,4 @@ export const load = (async ({ fetch, params }) => {
worker: result.data,
workerError: result.error,
};
}) satisfies PageLoad;
});

Some files were not shown because too many files have changed in this diff Show More