fix: navigate before loading page data
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
})()),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
+7
-659
@@ -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,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
+668
@@ -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>
|
||||
+7
-122
@@ -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,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
+131
@@ -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>
|
||||
+7
-254
@@ -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,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
+262
@@ -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 }>;
|
||||
});
|
||||
|
||||
+895
@@ -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>
|
||||
+111
-4
@@ -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>
|
||||
+7
-656
@@ -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,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
+662
@@ -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>
|
||||
+7
-254
@@ -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>
|
||||
|
||||
+3
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
+259
@@ -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>
|
||||
+26
-6
@@ -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>
|
||||
+9
-2957
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;
|
||||
});
|
||||
|
||||
+2961
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -22,7 +22,7 @@ import * as attachmentsApi from "#lib/workspace/console/composer-attachments.ts"
|
||||
import { undo } from "@codemirror/commands";
|
||||
import * as alerts from "#lib/workspace/alerts/store.ts";
|
||||
import * as consoleModel from "#lib/workspace/console/model.ts";
|
||||
import ConsolePage from "./+page.svelte";
|
||||
import ConsolePage from "./PageContent.svelte";
|
||||
|
||||
// happy-dom does not implement Web Animations. Motion itself is covered in Chromium.
|
||||
vi.mock("svelte/motion", () => ({ prefersReducedMotion: { current: true } }));
|
||||
|
||||
@@ -1,637 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { readBoundedJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
|
||||
import {
|
||||
parseWorkingDirectoryCreateResponse,
|
||||
validateWorkingDirectoryCreateRequest,
|
||||
} from '#lib/workspace/api/workdirs.ts';
|
||||
import {
|
||||
parseBrowserCreateWorkerResponse,
|
||||
parseWorkerApiError,
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '#lib/workspace/api/workers.ts';
|
||||
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
|
||||
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
|
||||
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
workerLaunchAttachmentError,
|
||||
genericLaunchProfiles,
|
||||
workerLaunchOutcomeUnknown,
|
||||
} from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type {
|
||||
Diagnostic,
|
||||
WorkerLaunchOptionsResponse,
|
||||
WorkingDirectorySummary,
|
||||
} from '#lib/workspace/sidebar/types.ts';
|
||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type DisplayError = {
|
||||
message: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
|
||||
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 `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
|
||||
}
|
||||
return `${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
|
||||
}
|
||||
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
|
||||
import PageContent from './PageContent.svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspaceId);
|
||||
const ticketContext = untrack(() => data.ticketContext);
|
||||
|
||||
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
|
||||
|
||||
let loading = $state(true);
|
||||
let options = $state<WorkerLaunchOptionsResponse | null>(null);
|
||||
let optionsError = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<DisplayError | null>(null);
|
||||
let outcomeUnknown = $state(false);
|
||||
let displayName = $state(ticketContext?.ticketTitle ?? 'Worker');
|
||||
let runtimeId = $state('');
|
||||
let profile = $state('');
|
||||
let flow = $state('');
|
||||
let subjektivSubjectId = $state('');
|
||||
let subjects = $state<SubjektivSubjectResponse[]>([]);
|
||||
let subjectsLoading = $state(false);
|
||||
let subjectsLoaded = $state(false);
|
||||
let subjectsError = $state<string | null>(null);
|
||||
let subjectsNextCursor = $state<string | null>(null);
|
||||
let subjectsHaveMore = $state(false);
|
||||
let previousProfile = $state('');
|
||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||
let workingDirectoryDisplayName = $state('');
|
||||
let workingDirectoryRepositoryKey = $state('');
|
||||
let workingDirectorySelector = $state('HEAD');
|
||||
let creatingWorkingDirectory = $state(false);
|
||||
let newWorkingDirectoryAttachmentIndex = $derived(
|
||||
workdirAttachments.findIndex((attachment) => attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE),
|
||||
);
|
||||
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||
let availableProfiles = $derived(genericLaunchProfiles(options?.profiles ?? []));
|
||||
let selectedProfile = $derived(availableProfiles.find((candidate) => candidate.id === (profile || options?.default_profile)));
|
||||
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
|
||||
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
|
||||
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
|
||||
let selectedRuntimeForbidsWorkdirAttachments = $derived(selectedRuntime?.supports_workdir_attachments === false);
|
||||
let availableWorkingDirectories = $derived(
|
||||
selectedRuntimeForbidsWorkdirAttachments
|
||||
? []
|
||||
: (options?.working_directories ?? []).filter((directory) =>
|
||||
directory.status === 'active' && directory.source.kind !== 'workspace_config' &&
|
||||
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
|
||||
directory.occupied_by == null
|
||||
),
|
||||
);
|
||||
let attachmentValidationError = $derived(
|
||||
isNewWorkingDirectorySelected
|
||||
? 'Create the new Workdir before starting the Worker.'
|
||||
: workerLaunchAttachmentError(workdirAttachments),
|
||||
);
|
||||
let canStartWorker = $derived(Boolean(
|
||||
runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown &&
|
||||
!attachmentValidationError &&
|
||||
(!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0),
|
||||
));
|
||||
|
||||
function workerApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
optionsError = 'workspace id is unavailable';
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void loadLaunchOptions(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
workdirAttachments = [];
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (profile !== previousProfile) {
|
||||
subjektivSubjectId = '';
|
||||
previousProfile = profile;
|
||||
}
|
||||
if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) {
|
||||
void loadSubjects(false);
|
||||
}
|
||||
});
|
||||
|
||||
function nextAttachmentAlias(): string {
|
||||
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
||||
if (!aliases.has('workdir')) return 'workdir';
|
||||
let suffix = 2;
|
||||
while (aliases.has(`workdir${suffix}`)) suffix += 1;
|
||||
return `workdir${suffix}`;
|
||||
}
|
||||
|
||||
function addAttachment(): void {
|
||||
workdirAttachments = [...workdirAttachments, {
|
||||
alias: nextAttachmentAlias(),
|
||||
working_directory_id: '',
|
||||
relative_cwd: '',
|
||||
}];
|
||||
}
|
||||
|
||||
function removeAttachment(index: number): void {
|
||||
workdirAttachments = workdirAttachments.filter((_, candidateIndex) => candidateIndex !== index);
|
||||
}
|
||||
|
||||
function workdirSelectedByAnotherAttachment(workdirId: string, index: number): boolean {
|
||||
return workdirAttachments.some((attachment, candidateIndex) =>
|
||||
candidateIndex !== index && attachment.working_directory_id === workdirId
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
optionsError = null;
|
||||
try {
|
||||
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`worker launch options failed (${response.status})`);
|
||||
}
|
||||
const payload = parseWorkerLaunchOptionsResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
options = payload;
|
||||
const form = defaultWorkerLaunchForm(payload, {
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
});
|
||||
runtimeId = form.runtime_id;
|
||||
displayName = form.display_name;
|
||||
profile = form.profile;
|
||||
subjektivSubjectId = form.subjektiv_subject_id;
|
||||
workdirAttachments = form.workdir_attachments;
|
||||
workingDirectoryRepositoryKey = form.working_directory_repository_key;
|
||||
workingDirectorySelector = form.working_directory_selector;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubjects(append: boolean) {
|
||||
if (!workspaceId || subjectsLoading) return;
|
||||
const cursor = append ? subjectsNextCursor : null;
|
||||
if (append && !cursor) return;
|
||||
subjectsLoading = true;
|
||||
subjectsLoaded = true;
|
||||
subjectsError = null;
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: '100' });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`));
|
||||
if (!response.ok) {
|
||||
throw new Error(`subject list failed (${response.status})`);
|
||||
}
|
||||
const payload = parseSubjektivSubjectListResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
if (append) {
|
||||
const byId = new Map(subjects.map((subject) => [subject.id, subject]));
|
||||
for (const subject of payload.items) byId.set(subject.id, subject);
|
||||
subjects = [...byId.values()];
|
||||
} else {
|
||||
subjects = payload.items;
|
||||
}
|
||||
subjectsNextCursor = payload.next_cursor ?? null;
|
||||
subjectsHaveMore = payload.has_more;
|
||||
if (
|
||||
subjektivSubjectId &&
|
||||
!subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active')
|
||||
) {
|
||||
subjektivSubjectId = '';
|
||||
}
|
||||
} catch (err) {
|
||||
subjectsError = err instanceof Error ? err.message : 'subject list failed';
|
||||
} finally {
|
||||
subjectsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkingDirectory() {
|
||||
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
||||
if (attachmentIndex < 0) {
|
||||
submitError = { message: 'select New workdir on an attachment first', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!runtimeId) {
|
||||
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeForbidsWorkdirAttachments) {
|
||||
submitError = { message: 'the selected Runtime does not support Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!workingDirectoryRepositoryKey) {
|
||||
submitError = { message: 'select a repository before creating a workdir', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
creatingWorkingDirectory = true;
|
||||
submitError = null;
|
||||
try {
|
||||
const request = validateWorkingDirectoryCreateRequest({
|
||||
runtime_id: runtimeId,
|
||||
...(workingDirectoryDisplayName.trim() ? { display_name: workingDirectoryDisplayName.trim() } : {}),
|
||||
repository_key: workingDirectoryRepositoryKey,
|
||||
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
|
||||
});
|
||||
const response = await fetch(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
submitError = await responseDisplayError(response, 'workdir create failed');
|
||||
return;
|
||||
}
|
||||
const payload = parseWorkingDirectoryCreateResponse(await response.json());
|
||||
const items = options?.working_directories ?? [];
|
||||
options = options
|
||||
? {
|
||||
...options,
|
||||
working_directories: [
|
||||
...items.filter((item) => item.working_directory_id !== payload.item.working_directory_id),
|
||||
payload.item,
|
||||
],
|
||||
}
|
||||
: options;
|
||||
workdirAttachments = workdirAttachments.map((attachment, index) =>
|
||||
index === attachmentIndex && attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE
|
||||
? { ...attachment, working_directory_id: payload.item.working_directory_id }
|
||||
: attachment
|
||||
);
|
||||
} catch (err) {
|
||||
submitError = exceptionDisplayError(err, 'workdir create failed');
|
||||
} finally {
|
||||
creatingWorkingDirectory = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorker() {
|
||||
if (submitting || outcomeUnknown) return;
|
||||
if (!workspaceId) {
|
||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
submitError = { message: 'the selected Runtime must start without Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (attachmentValidationError) {
|
||||
submitError = { message: attachmentValidationError, diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
submitError = null;
|
||||
submitting = true;
|
||||
let dispatched = false;
|
||||
try {
|
||||
const request = buildCreateWorkspaceWorkerRequest({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
flow,
|
||||
ticket_assignment: ticketContext
|
||||
? { ticket_id: ticketContext.ticketId, operation_id: crypto.randomUUID() }
|
||||
: null,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
});
|
||||
dispatched = true;
|
||||
const response = await fetch(workerApiPath('/workers'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
submitError = await responseDisplayError(response, 'worker create failed');
|
||||
outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics });
|
||||
return;
|
||||
}
|
||||
const payload = parseBrowserCreateWorkerResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
await goto(payload.console_href);
|
||||
} catch (err) {
|
||||
outcomeUnknown = dispatched;
|
||||
submitError = exceptionDisplayError(err, 'worker create failed');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
|
||||
try {
|
||||
const payload = parseWorkerApiError(
|
||||
await readBoundedJson(response, 1024 * 1024),
|
||||
);
|
||||
return {
|
||||
message: `${payload.error}: ${payload.message}`,
|
||||
diagnostics: payload.diagnostics ?? [],
|
||||
};
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { message: `${fallback} (${response.status})`, diagnostics: [] };
|
||||
}
|
||||
|
||||
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
|
||||
return {
|
||||
message: err instanceof Error ? err.message : fallback,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Worker · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="worker-new-page" aria-labelledby="new-worker-heading">
|
||||
<header class="worker-new-page-header">
|
||||
<div>
|
||||
<h1 id="new-worker-heading">New Worker</h1>
|
||||
<p>Choose a Runtime and any resources needed for this request.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if ticketContext}
|
||||
<aside class="worker-ticket-context">
|
||||
<div>
|
||||
<span>Ticket assignment</span>
|
||||
<strong>{ticketContext.ticketTitle}</strong>
|
||||
<code>{ticketContext.ticketId}</code>
|
||||
</div>
|
||||
<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>View ticket</a>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="section-state">Loading launch options…</p>
|
||||
{:else if optionsError}
|
||||
<p class="section-state error">{optionsError}</p>
|
||||
{:else}
|
||||
<form class="worker-launch-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
|
||||
<section class="worker-form-section" aria-labelledby="worker-location-heading">
|
||||
<h2 id="worker-location-heading">Location</h2>
|
||||
|
||||
<div class="worker-launch-sentence">
|
||||
<span>Run in</span>
|
||||
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||
{#if options?.runtimes.length}
|
||||
{#each options.runtimes as runtime}
|
||||
<option value={runtime.runtime_id} disabled={!runtime.worker_creation_available}>
|
||||
{runtime.display_name}
|
||||
</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option value="" disabled>No Runtime options</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if selectedRuntimeForbidsWorkdirAttachments}
|
||||
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
|
||||
{:else}
|
||||
<div class="worker-attachment-heading">
|
||||
<h3>Initial Workdir attachments</h3>
|
||||
<button type="button" class="secondary-button" onclick={addAttachment}>Add attachment</button>
|
||||
</div>
|
||||
|
||||
<div class="worker-attachment-list">
|
||||
{#each workdirAttachments as attachment, index}
|
||||
<div class="worker-attachment-row">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
<input
|
||||
bind:value={attachment.alias}
|
||||
autocomplete="off"
|
||||
maxlength="64"
|
||||
placeholder="workdir"
|
||||
aria-label={`Attachment ${index + 1} alias`}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Workdir</span>
|
||||
<select bind:value={attachment.working_directory_id} aria-label={`Attachment ${index + 1} Workdir`}>
|
||||
<option value="" disabled>Select Workdir</option>
|
||||
{#each availableWorkingDirectories as directory}
|
||||
<option
|
||||
value={directory.working_directory_id}
|
||||
disabled={workdirSelectedByAnotherAttachment(directory.working_directory_id, index)}
|
||||
>
|
||||
{workdirOptionLabel(directory)}
|
||||
</option>
|
||||
{/each}
|
||||
<option
|
||||
value={NEW_WORKING_DIRECTORY_VALUE}
|
||||
disabled={isNewWorkingDirectorySelected && newWorkingDirectoryAttachmentIndex !== index}
|
||||
>New Workdir…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Relative cwd</span>
|
||||
<input
|
||||
bind:value={attachment.relative_cwd}
|
||||
autocomplete="off"
|
||||
placeholder="Optional path inside Workdir"
|
||||
aria-label={`Attachment ${index + 1} relative cwd`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="worker-attachment-remove"
|
||||
onclick={() => removeAttachment(index)}
|
||||
disabled={creatingWorkingDirectory && newWorkingDirectoryAttachmentIndex === index}
|
||||
aria-label={`Remove attachment ${index + 1}`}
|
||||
>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if attachmentValidationError}
|
||||
<p class="worker-workdir-note error">{attachmentValidationError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isNewWorkingDirectorySelected}
|
||||
<div class="new-working-directory-panel">
|
||||
<h3>New Workdir for “{workdirAttachments[newWorkingDirectoryAttachmentIndex]?.alias || `attachment ${newWorkingDirectoryAttachmentIndex + 1}`}”</h3>
|
||||
<div class="new-working-directory-fields">
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={workingDirectoryDisplayName} autocomplete="off" placeholder="Optional label" maxlength="80" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Repository</span>
|
||||
<select bind:value={workingDirectoryRepositoryKey}>
|
||||
<option value="">Select repository</option>
|
||||
{#if options?.repositories.length}
|
||||
{#each options.repositories as repository}
|
||||
<option value={repository.repository_key}>{repository.repository_key}</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option value="" disabled>No configured repositories</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Selector</span>
|
||||
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryKey} onclick={() => void createWorkingDirectory()}>
|
||||
{creatingWorkingDirectory ? 'Creating…' : 'Create Workdir'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="worker-form-section" aria-labelledby="worker-details-heading">
|
||||
<h2 id="worker-details-heading">Worker</h2>
|
||||
<div class="worker-detail-grid">
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Profile (optional)</span>
|
||||
<select bind:value={profile} aria-label="Profile">
|
||||
<option value="">Workspace default</option>
|
||||
{#each availableProfiles as candidate}
|
||||
<option value={candidate.id}>{candidate.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if subjektivConnectionAvailable}
|
||||
<div class="worker-feature-connection" data-worker-feature-connection="subjektiv">
|
||||
<div class="worker-feature-connection-heading">
|
||||
<div>
|
||||
<h3>Subject Memory</h3>
|
||||
<p>Optionally connect this Worker to one existing active subject.</p>
|
||||
</div>
|
||||
<span>Optional</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>Subject</span>
|
||||
<select bind:value={subjektivSubjectId} aria-label="Subject Memory connection">
|
||||
<option value="">Do not connect a subject</option>
|
||||
{#each activeSubjects as subject (subject.id)}
|
||||
<option value={subject.id}>{subject.role} · {subject.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if selectedSubject}
|
||||
<p class="worker-feature-connection-selection">
|
||||
<strong>{selectedSubject.role}</strong>
|
||||
<code title={selectedSubject.id}>{selectedSubject.id}</code>
|
||||
{#if selectedSubject.current_worker}
|
||||
<span>Current Worker: {selectedSubject.current_worker.display_name}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if subjectsLoading && subjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">Loading authorized subjects…</p>
|
||||
{:else if subjectsError}
|
||||
<div class="worker-feature-connection-state is-error" role="alert">
|
||||
<span>{subjectsError}</span>
|
||||
<button type="button" class="secondary-button" onclick={() => void loadSubjects(false)}>Retry</button>
|
||||
</div>
|
||||
{:else if subjectsLoaded && activeSubjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">No active subjects are available.</p>
|
||||
{/if}
|
||||
{#if subjectsHaveMore}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button worker-subject-load-more"
|
||||
disabled={subjectsLoading}
|
||||
onclick={() => void loadSubjects(true)}
|
||||
>{subjectsLoading ? 'Loading…' : 'Load more subjects'}</button>
|
||||
{/if}
|
||||
<p class="worker-feature-connection-note">Profile enablement does not connect a subject by itself.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<label>
|
||||
<span>Flow (optional)</span>
|
||||
<input bind:value={flow} autocomplete="off" placeholder="Optional Flow selector" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Initial request</span>
|
||||
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if submitError}
|
||||
<div class="section-state error worker-submit-error">
|
||||
<p>{submitError.message}</p>
|
||||
{#if outcomeUnknown}
|
||||
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p>
|
||||
<a href={`/w/${workspaceId}/workers`}>Check Workers</a>
|
||||
{/if}
|
||||
{#if submitError.diagnostics.length > 0}
|
||||
<ul class="worker-error-diagnostics">
|
||||
{#each submitError.diagnostics as diagnostic}
|
||||
<li class={diagnostic.severity}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="worker-new-actions">
|
||||
<button type="submit" disabled={submitting || !canStartWorker}>
|
||||
{submitting ? 'Starting…' : 'Start Worker'}
|
||||
</button>
|
||||
<a class="secondary-link" href={`/w/${workspaceId}`}>Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
<DeferredPage {data} title="New Worker" identity={`${data.workspaceId}:${JSON.stringify(data.ticketContext)}`}>
|
||||
{#snippet children(resolved)}
|
||||
<PageContent data={resolved} />
|
||||
{/snippet}
|
||||
</DeferredPage>
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { readBoundedJson, workspaceApiPath } from '#lib/workspace/api/http.ts';
|
||||
import {
|
||||
parseWorkingDirectoryCreateResponse,
|
||||
validateWorkingDirectoryCreateRequest,
|
||||
} from '#lib/workspace/api/workdirs.ts';
|
||||
import {
|
||||
parseBrowserCreateWorkerResponse,
|
||||
parseWorkerApiError,
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '#lib/workspace/api/workers.ts';
|
||||
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
|
||||
import { formatCurrentWorkdirCheckout } from '#lib/workspace/settings/workdir-checkout.ts';
|
||||
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
workerLaunchAttachmentError,
|
||||
genericLaunchProfiles,
|
||||
workerLaunchOutcomeUnknown,
|
||||
} from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type { WorkerLaunchAttachmentFormState } from '#lib/workspace/sidebar/worker-launch.ts';
|
||||
import type {
|
||||
Diagnostic,
|
||||
WorkerLaunchOptionsResponse,
|
||||
WorkingDirectorySummary,
|
||||
} from '#lib/workspace/sidebar/types.ts';
|
||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { PageData as DeferredPageData } from './$types';
|
||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||
|
||||
type PageData = ResolvedPageData<DeferredPageData>;
|
||||
|
||||
type DisplayError = {
|
||||
message: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
|
||||
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 `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
|
||||
}
|
||||
return `${label} · ${formatCurrentWorkdirCheckout(directory, provider)}`;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let workspaceId = $derived(data.workspaceId);
|
||||
const ticketContext = untrack(() => data.ticketContext);
|
||||
|
||||
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
|
||||
|
||||
let loading = $state(true);
|
||||
let options = $state<WorkerLaunchOptionsResponse | null>(null);
|
||||
let optionsError = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<DisplayError | null>(null);
|
||||
let outcomeUnknown = $state(false);
|
||||
let displayName = $state(ticketContext?.ticketTitle ?? 'Worker');
|
||||
let runtimeId = $state('');
|
||||
let profile = $state('');
|
||||
let flow = $state('');
|
||||
let subjektivSubjectId = $state('');
|
||||
let subjects = $state<SubjektivSubjectResponse[]>([]);
|
||||
let subjectsLoading = $state(false);
|
||||
let subjectsLoaded = $state(false);
|
||||
let subjectsError = $state<string | null>(null);
|
||||
let subjectsNextCursor = $state<string | null>(null);
|
||||
let subjectsHaveMore = $state(false);
|
||||
let previousProfile = $state('');
|
||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||
let workingDirectoryDisplayName = $state('');
|
||||
let workingDirectoryRepositoryKey = $state('');
|
||||
let workingDirectorySelector = $state('HEAD');
|
||||
let creatingWorkingDirectory = $state(false);
|
||||
let newWorkingDirectoryAttachmentIndex = $derived(
|
||||
workdirAttachments.findIndex((attachment) => attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE),
|
||||
);
|
||||
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||
let availableProfiles = $derived(genericLaunchProfiles(options?.profiles ?? []));
|
||||
let selectedProfile = $derived(availableProfiles.find((candidate) => candidate.id === (profile || options?.default_profile)));
|
||||
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
|
||||
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
|
||||
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
|
||||
let selectedRuntimeForbidsWorkdirAttachments = $derived(selectedRuntime?.supports_workdir_attachments === false);
|
||||
let availableWorkingDirectories = $derived(
|
||||
selectedRuntimeForbidsWorkdirAttachments
|
||||
? []
|
||||
: (options?.working_directories ?? []).filter((directory) =>
|
||||
directory.status === 'active' && directory.source.kind !== 'workspace_config' &&
|
||||
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
|
||||
directory.occupied_by == null
|
||||
),
|
||||
);
|
||||
let attachmentValidationError = $derived(
|
||||
isNewWorkingDirectorySelected
|
||||
? 'Create the new Workdir before starting the Worker.'
|
||||
: workerLaunchAttachmentError(workdirAttachments),
|
||||
);
|
||||
let canStartWorker = $derived(Boolean(
|
||||
runtimeId && selectedRuntime?.worker_creation_available && !outcomeUnknown &&
|
||||
!attachmentValidationError &&
|
||||
(!selectedRuntimeForbidsWorkdirAttachments || workdirAttachments.length === 0),
|
||||
));
|
||||
|
||||
function workerApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
optionsError = 'workspace id is unavailable';
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void loadLaunchOptions(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
workdirAttachments = [];
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (profile !== previousProfile) {
|
||||
subjektivSubjectId = '';
|
||||
previousProfile = profile;
|
||||
}
|
||||
if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) {
|
||||
void loadSubjects(false);
|
||||
}
|
||||
});
|
||||
|
||||
function nextAttachmentAlias(): string {
|
||||
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
||||
if (!aliases.has('workdir')) return 'workdir';
|
||||
let suffix = 2;
|
||||
while (aliases.has(`workdir${suffix}`)) suffix += 1;
|
||||
return `workdir${suffix}`;
|
||||
}
|
||||
|
||||
function addAttachment(): void {
|
||||
workdirAttachments = [...workdirAttachments, {
|
||||
alias: nextAttachmentAlias(),
|
||||
working_directory_id: '',
|
||||
relative_cwd: '',
|
||||
}];
|
||||
}
|
||||
|
||||
function removeAttachment(index: number): void {
|
||||
workdirAttachments = workdirAttachments.filter((_, candidateIndex) => candidateIndex !== index);
|
||||
}
|
||||
|
||||
function workdirSelectedByAnotherAttachment(workdirId: string, index: number): boolean {
|
||||
return workdirAttachments.some((attachment, candidateIndex) =>
|
||||
candidateIndex !== index && attachment.working_directory_id === workdirId
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
optionsError = null;
|
||||
try {
|
||||
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`worker launch options failed (${response.status})`);
|
||||
}
|
||||
const payload = parseWorkerLaunchOptionsResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
options = payload;
|
||||
const form = defaultWorkerLaunchForm(payload, {
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
});
|
||||
runtimeId = form.runtime_id;
|
||||
displayName = form.display_name;
|
||||
profile = form.profile;
|
||||
subjektivSubjectId = form.subjektiv_subject_id;
|
||||
workdirAttachments = form.workdir_attachments;
|
||||
workingDirectoryRepositoryKey = form.working_directory_repository_key;
|
||||
workingDirectorySelector = form.working_directory_selector;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubjects(append: boolean) {
|
||||
if (!workspaceId || subjectsLoading) return;
|
||||
const cursor = append ? subjectsNextCursor : null;
|
||||
if (append && !cursor) return;
|
||||
subjectsLoading = true;
|
||||
subjectsLoaded = true;
|
||||
subjectsError = null;
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: '100' });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`));
|
||||
if (!response.ok) {
|
||||
throw new Error(`subject list failed (${response.status})`);
|
||||
}
|
||||
const payload = parseSubjektivSubjectListResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
if (append) {
|
||||
const byId = new Map(subjects.map((subject) => [subject.id, subject]));
|
||||
for (const subject of payload.items) byId.set(subject.id, subject);
|
||||
subjects = [...byId.values()];
|
||||
} else {
|
||||
subjects = payload.items;
|
||||
}
|
||||
subjectsNextCursor = payload.next_cursor ?? null;
|
||||
subjectsHaveMore = payload.has_more;
|
||||
if (
|
||||
subjektivSubjectId &&
|
||||
!subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active')
|
||||
) {
|
||||
subjektivSubjectId = '';
|
||||
}
|
||||
} catch (err) {
|
||||
subjectsError = err instanceof Error ? err.message : 'subject list failed';
|
||||
} finally {
|
||||
subjectsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkingDirectory() {
|
||||
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
||||
if (attachmentIndex < 0) {
|
||||
submitError = { message: 'select New workdir on an attachment first', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!runtimeId) {
|
||||
submitError = { message: 'select a runtime before creating a workdir', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeForbidsWorkdirAttachments) {
|
||||
submitError = { message: 'the selected Runtime does not support Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (!workingDirectoryRepositoryKey) {
|
||||
submitError = { message: 'select a repository before creating a workdir', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
creatingWorkingDirectory = true;
|
||||
submitError = null;
|
||||
try {
|
||||
const request = validateWorkingDirectoryCreateRequest({
|
||||
runtime_id: runtimeId,
|
||||
...(workingDirectoryDisplayName.trim() ? { display_name: workingDirectoryDisplayName.trim() } : {}),
|
||||
repository_key: workingDirectoryRepositoryKey,
|
||||
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
|
||||
});
|
||||
const response = await fetch(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
submitError = await responseDisplayError(response, 'workdir create failed');
|
||||
return;
|
||||
}
|
||||
const payload = parseWorkingDirectoryCreateResponse(await response.json());
|
||||
const items = options?.working_directories ?? [];
|
||||
options = options
|
||||
? {
|
||||
...options,
|
||||
working_directories: [
|
||||
...items.filter((item) => item.working_directory_id !== payload.item.working_directory_id),
|
||||
payload.item,
|
||||
],
|
||||
}
|
||||
: options;
|
||||
workdirAttachments = workdirAttachments.map((attachment, index) =>
|
||||
index === attachmentIndex && attachment.working_directory_id === NEW_WORKING_DIRECTORY_VALUE
|
||||
? { ...attachment, working_directory_id: payload.item.working_directory_id }
|
||||
: attachment
|
||||
);
|
||||
} catch (err) {
|
||||
submitError = exceptionDisplayError(err, 'workdir create failed');
|
||||
} finally {
|
||||
creatingWorkingDirectory = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorker() {
|
||||
if (submitting || outcomeUnknown) return;
|
||||
if (!workspaceId) {
|
||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (selectedRuntimeForbidsWorkdirAttachments && workdirAttachments.length > 0) {
|
||||
submitError = { message: 'the selected Runtime must start without Workdir attachments', diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
if (attachmentValidationError) {
|
||||
submitError = { message: attachmentValidationError, diagnostics: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
submitError = null;
|
||||
submitting = true;
|
||||
let dispatched = false;
|
||||
try {
|
||||
const request = buildCreateWorkspaceWorkerRequest({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
flow,
|
||||
ticket_assignment: ticketContext
|
||||
? { ticket_id: ticketContext.ticketId, operation_id: crypto.randomUUID() }
|
||||
: null,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
working_directory_selector: workingDirectorySelector,
|
||||
});
|
||||
dispatched = true;
|
||||
const response = await fetch(workerApiPath('/workers'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!response.ok) {
|
||||
submitError = await responseDisplayError(response, 'worker create failed');
|
||||
outcomeUnknown = workerLaunchOutcomeUnknown({ error: submitError.message, message: '', diagnostics: submitError.diagnostics });
|
||||
return;
|
||||
}
|
||||
const payload = parseBrowserCreateWorkerResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
await goto(payload.console_href);
|
||||
} catch (err) {
|
||||
outcomeUnknown = dispatched;
|
||||
submitError = exceptionDisplayError(err, 'worker create failed');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
|
||||
try {
|
||||
const payload = parseWorkerApiError(
|
||||
await readBoundedJson(response, 1024 * 1024),
|
||||
);
|
||||
return {
|
||||
message: `${payload.error}: ${payload.message}`,
|
||||
diagnostics: payload.diagnostics ?? [],
|
||||
};
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { message: `${fallback} (${response.status})`, diagnostics: [] };
|
||||
}
|
||||
|
||||
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
|
||||
return {
|
||||
message: err instanceof Error ? err.message : fallback,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Worker · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="worker-new-page" aria-labelledby="new-worker-heading">
|
||||
<header class="worker-new-page-header">
|
||||
<div>
|
||||
<h1 id="new-worker-heading">New Worker</h1>
|
||||
<p>Choose a Runtime and any resources needed for this request.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if ticketContext}
|
||||
<aside class="worker-ticket-context">
|
||||
<div>
|
||||
<span>Ticket assignment</span>
|
||||
<strong>{ticketContext.ticketTitle}</strong>
|
||||
<code>{ticketContext.ticketId}</code>
|
||||
</div>
|
||||
<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>View ticket</a>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="section-state">Loading launch options…</p>
|
||||
{:else if optionsError}
|
||||
<p class="section-state error">{optionsError}</p>
|
||||
{:else}
|
||||
<form class="worker-launch-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
|
||||
<section class="worker-form-section" aria-labelledby="worker-location-heading">
|
||||
<h2 id="worker-location-heading">Location</h2>
|
||||
|
||||
<div class="worker-launch-sentence">
|
||||
<span>Run in</span>
|
||||
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||
{#if options?.runtimes.length}
|
||||
{#each options.runtimes as runtime}
|
||||
<option value={runtime.runtime_id} disabled={!runtime.worker_creation_available}>
|
||||
{runtime.display_name}
|
||||
</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option value="" disabled>No Runtime options</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if selectedRuntimeForbidsWorkdirAttachments}
|
||||
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a Workdir.</p>
|
||||
{:else}
|
||||
<div class="worker-attachment-heading">
|
||||
<h3>Initial Workdir attachments</h3>
|
||||
<button type="button" class="secondary-button" onclick={addAttachment}>Add attachment</button>
|
||||
</div>
|
||||
|
||||
<div class="worker-attachment-list">
|
||||
{#each workdirAttachments as attachment, index}
|
||||
<div class="worker-attachment-row">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
<input
|
||||
bind:value={attachment.alias}
|
||||
autocomplete="off"
|
||||
maxlength="64"
|
||||
placeholder="workdir"
|
||||
aria-label={`Attachment ${index + 1} alias`}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Workdir</span>
|
||||
<select bind:value={attachment.working_directory_id} aria-label={`Attachment ${index + 1} Workdir`}>
|
||||
<option value="" disabled>Select Workdir</option>
|
||||
{#each availableWorkingDirectories as directory}
|
||||
<option
|
||||
value={directory.working_directory_id}
|
||||
disabled={workdirSelectedByAnotherAttachment(directory.working_directory_id, index)}
|
||||
>
|
||||
{workdirOptionLabel(directory)}
|
||||
</option>
|
||||
{/each}
|
||||
<option
|
||||
value={NEW_WORKING_DIRECTORY_VALUE}
|
||||
disabled={isNewWorkingDirectorySelected && newWorkingDirectoryAttachmentIndex !== index}
|
||||
>New Workdir…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Relative cwd</span>
|
||||
<input
|
||||
bind:value={attachment.relative_cwd}
|
||||
autocomplete="off"
|
||||
placeholder="Optional path inside Workdir"
|
||||
aria-label={`Attachment ${index + 1} relative cwd`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="worker-attachment-remove"
|
||||
onclick={() => removeAttachment(index)}
|
||||
disabled={creatingWorkingDirectory && newWorkingDirectoryAttachmentIndex === index}
|
||||
aria-label={`Remove attachment ${index + 1}`}
|
||||
>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if attachmentValidationError}
|
||||
<p class="worker-workdir-note error">{attachmentValidationError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isNewWorkingDirectorySelected}
|
||||
<div class="new-working-directory-panel">
|
||||
<h3>New Workdir for “{workdirAttachments[newWorkingDirectoryAttachmentIndex]?.alias || `attachment ${newWorkingDirectoryAttachmentIndex + 1}`}”</h3>
|
||||
<div class="new-working-directory-fields">
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={workingDirectoryDisplayName} autocomplete="off" placeholder="Optional label" maxlength="80" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Repository</span>
|
||||
<select bind:value={workingDirectoryRepositoryKey}>
|
||||
<option value="">Select repository</option>
|
||||
{#if options?.repositories.length}
|
||||
{#each options.repositories as repository}
|
||||
<option value={repository.repository_key}>{repository.repository_key}</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option value="" disabled>No configured repositories</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Selector</span>
|
||||
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryKey} onclick={() => void createWorkingDirectory()}>
|
||||
{creatingWorkingDirectory ? 'Creating…' : 'Create Workdir'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="worker-form-section" aria-labelledby="worker-details-heading">
|
||||
<h2 id="worker-details-heading">Worker</h2>
|
||||
<div class="worker-detail-grid">
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Profile (optional)</span>
|
||||
<select bind:value={profile} aria-label="Profile">
|
||||
<option value="">Workspace default</option>
|
||||
{#each availableProfiles as candidate}
|
||||
<option value={candidate.id}>{candidate.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if subjektivConnectionAvailable}
|
||||
<div class="worker-feature-connection" data-worker-feature-connection="subjektiv">
|
||||
<div class="worker-feature-connection-heading">
|
||||
<div>
|
||||
<h3>Subject Memory</h3>
|
||||
<p>Optionally connect this Worker to one existing active subject.</p>
|
||||
</div>
|
||||
<span>Optional</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>Subject</span>
|
||||
<select bind:value={subjektivSubjectId} aria-label="Subject Memory connection">
|
||||
<option value="">Do not connect a subject</option>
|
||||
{#each activeSubjects as subject (subject.id)}
|
||||
<option value={subject.id}>{subject.role} · {subject.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if selectedSubject}
|
||||
<p class="worker-feature-connection-selection">
|
||||
<strong>{selectedSubject.role}</strong>
|
||||
<code title={selectedSubject.id}>{selectedSubject.id}</code>
|
||||
{#if selectedSubject.current_worker}
|
||||
<span>Current Worker: {selectedSubject.current_worker.display_name}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if subjectsLoading && subjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">Loading authorized subjects…</p>
|
||||
{:else if subjectsError}
|
||||
<div class="worker-feature-connection-state is-error" role="alert">
|
||||
<span>{subjectsError}</span>
|
||||
<button type="button" class="secondary-button" onclick={() => void loadSubjects(false)}>Retry</button>
|
||||
</div>
|
||||
{:else if subjectsLoaded && activeSubjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">No active subjects are available.</p>
|
||||
{/if}
|
||||
{#if subjectsHaveMore}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button worker-subject-load-more"
|
||||
disabled={subjectsLoading}
|
||||
onclick={() => void loadSubjects(true)}
|
||||
>{subjectsLoading ? 'Loading…' : 'Load more subjects'}</button>
|
||||
{/if}
|
||||
<p class="worker-feature-connection-note">Profile enablement does not connect a subject by itself.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<label>
|
||||
<span>Flow (optional)</span>
|
||||
<input bind:value={flow} autocomplete="off" placeholder="Optional Flow selector" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Initial request</span>
|
||||
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if submitError}
|
||||
<div class="section-state error worker-submit-error">
|
||||
<p>{submitError.message}</p>
|
||||
{#if outcomeUnknown}
|
||||
<p>The launch outcome is unknown. Check the Worker list or Ticket before starting another Worker.</p>
|
||||
<a href={`/w/${workspaceId}/workers`}>Check Workers</a>
|
||||
{/if}
|
||||
{#if submitError.diagnostics.length > 0}
|
||||
<ul class="worker-error-diagnostics">
|
||||
{#each submitError.diagnostics as diagnostic}
|
||||
<li class={diagnostic.severity}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="worker-new-actions">
|
||||
<button type="submit" disabled={submitting || !canStartWorker}>
|
||||
{submitting ? 'Starting…' : 'Start Worker'}
|
||||
</button>
|
||||
<a class="secondary-link" href={`/w/${workspaceId}`}>Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user