diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css
index 9c42b30a..6ebc935c 100644
--- a/web/workspace/src/app.css
+++ b/web/workspace/src/app.css
@@ -445,12 +445,14 @@
.runtime-card {
padding: 0;
}
+ .ticket-list,
.objective-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
+ .ticket-row,
.objective-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
@@ -463,27 +465,34 @@
text-decoration: none;
transition: background-color 140ms ease;
}
+ .ticket-row:hover,
+ .ticket-row:focus-visible,
.objective-row:hover,
.objective-row:focus-visible {
background: var(--interactive-hover);
}
+ .ticket-row.selected,
.objective-row.selected {
background: var(--interactive-selected);
}
+ .ticket-row.selected .ticket-title,
.objective-row.selected .objective-title {
color: var(--accent);
}
+ .ticket-main,
.objective-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
+ .ticket-title-row,
.objective-title-row {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
+ .ticket-title,
.objective-title {
min-width: 0;
overflow: hidden;
@@ -500,12 +509,14 @@
letter-spacing: 0.05em;
text-transform: uppercase;
}
+ .ticket-summary,
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.4;
}
+ .ticket-meta,
.objective-meta {
display: flex;
flex-wrap: wrap;
@@ -518,10 +529,12 @@
white-space: nowrap;
}
@media (max-width: 900px) {
+ .ticket-row,
.objective-row {
grid-template-columns: 1fr;
gap: var(--space-2);
}
+ .ticket-meta,
.objective-meta {
justify-content: flex-start;
text-align: left;
@@ -542,6 +555,9 @@
.runtime-heading span.warn {
color: var(--warning);
}
+ .detail-heading.compact {
+ margin-bottom: var(--space-2);
+ }
.detail-heading h3 {
margin: 0;
}
@@ -549,6 +565,36 @@
color: var(--text-muted);
font-size: 0.8rem;
}
+ .ticket-detail-grid {
+ margin-bottom: var(--space-4);
+ }
+ .risk-flags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+ }
+ .risk-flags span,
+ .warning-pill {
+ border-radius: 999px;
+ padding: 0.18rem 0.55rem;
+ background: var(--bg-subtle);
+ color: var(--warning);
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ }
+ .ticket-body pre {
+ overflow-x: auto;
+ margin: 0;
+ padding: var(--space-4);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-panel);
+ background: var(--bg-raised);
+ color: var(--text);
+ white-space: pre-wrap;
+ }
dl {
display: grid;
gap: var(--space-1);
diff --git a/web/workspace/src/lib/workspace/api/http.test.ts b/web/workspace/src/lib/workspace/api/http.test.ts
index cdce78a9..d4e9db62 100644
--- a/web/workspace/src/lib/workspace/api/http.test.ts
+++ b/web/workspace/src/lib/workspace/api/http.test.ts
@@ -38,7 +38,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
);
});
-Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
+Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
@@ -47,24 +47,17 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
"unscoped layout may use only the workspace-id bootstrap endpoint",
);
assert(
- layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
- "unscoped layout should redirect to the scoped workspace route",
+ layout.includes("throw redirect(307") &&
+ layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
+ !layout.includes("scopedCompatibilityRoute") &&
+ !layout.includes("workspaceRoute(workspaceId, pathname)"),
+ "root layout should redirect only to the scoped workspace entry",
);
assert(
!layout.includes("`/api${path}`") &&
!layout.includes('"/api/repositories"'),
"layout must not fall back to unscoped workspace-scoped API calls",
);
-
- const unscopedSettings = await Deno.readTextFile(
- new URL("./../../../routes/settings/+page.svelte", import.meta.url),
- );
- assert(
- unscopedSettings.includes("Redirecting to scoped settings") &&
- !unscopedSettings.includes("fetch(") &&
- !unscopedSettings.includes("settingsApiPath"),
- "unscoped settings route should remain a thin redirect shim, not a data/control surface",
- );
});
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
index 2c003a7f..73c9bd3d 100644
--- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
+++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
@@ -27,9 +27,13 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
- workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
- workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
- "top workspace page should link to Runtimes and Workers pages",
+ workspacePage.includes("ticketsHref") &&
+ workspacePage.includes("runtimeSettingsHref") &&
+ workspacePage.includes("workersHref") &&
+ workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
+ workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
+ workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
+ "top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
);
assert(
!workspacePage.includes("workerConsoleHref") &&
@@ -51,8 +55,66 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
!sidebar.includes("CompanionNavSection") &&
+ sidebar.includes("TicketsNavSection") &&
sidebar.includes("WorkersNavSection"),
- "standalone Companion/Console navigation should not remain canonical",
+ "standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
+ );
+});
+
+Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
+ const ticketsNav = await Deno.readTextFile(
+ new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
+ );
+ const ticketsLoad = await Deno.readTextFile(
+ new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
+ );
+ const ticketsPage = await Deno.readTextFile(
+ new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
+ );
+ const ticketDetailLoad = await Deno.readTextFile(
+ new URL(
+ "./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
+ import.meta.url,
+ ),
+ );
+ const ticketDetailPage = await Deno.readTextFile(
+ new URL(
+ "./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
+ import.meta.url,
+ ),
+ );
+
+ assert(
+ ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
+ ticketsNav.includes("Open Tickets"),
+ "Tickets sidebar section should link to the workspace Tickets surface",
+ );
+ assert(
+ ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
+ ticketsPage.includes("This surface is read-only") &&
+ ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
+ "Tickets list should read the workspace-scoped Ticket API and link to Ticket details",
+ );
+ assert(
+ ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
+ ticketDetailPage.includes("event_count") &&
+ ticketDetailPage.includes("artifact_count") &&
+ ticketDetailPage.includes("
{data.ticket.data.body"),
+ "Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
+ );
+});
+
+Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
+ const layoutLoad = await Deno.readTextFile(
+ new URL("./../../../routes/+layout.ts", import.meta.url),
+ );
+
+ assert(
+ !layoutLoad.includes("scopedCompatibilityRoute") &&
+ !layoutLoad.includes('pathname === "/runtimes"') &&
+ !layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
+ layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
+ "root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
);
});
@@ -188,49 +250,50 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
);
});
-Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
+Deno.test("workspace Runtime inventory lives under Settings admin routes", async () => {
const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
- const runtimesNav = await Deno.readTextFile(
- new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
+ const settingsModel = await Deno.readTextFile(
+ new URL("../settings/model.ts", import.meta.url),
);
const runtimesPage = await Deno.readTextFile(
new URL(
- "./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
+ "./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
const workdirsPage = await Deno.readTextFile(
new URL(
- "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
+ "./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
import.meta.url,
),
);
const workdirsLoad = await Deno.readTextFile(
new URL(
- "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
+ "./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts",
import.meta.url,
),
);
assert(
- sidebar.includes("RuntimesNavSection") &&
- runtimesNav.includes("href={runtimesHref}") &&
- runtimesNav.includes("/runtimes"),
- "sidebar should expose Runtime management navigation",
+ !sidebar.includes("RuntimesNavSection") &&
+ settingsModel.includes('id: "runtime-inventory"') &&
+ settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
+ "Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
- runtimesPage.includes("Open workdirs") &&
+ runtimesPage.includes("Runtime Inventory") &&
+ runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") &&
- runtimesPage.includes("/workdirs"),
- "Runtimes page should table Runtimes and link to each Runtime's workdirs",
+ runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
+ "Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
);
assert(
- workdirsPage.includes("Workdirs") &&
+ workdirsPage.includes("Runtime Inventory") &&
workdirsPage.includes("workdirs-table") &&
workdirsLoad.includes("/working-directories"),
- "Workdirs page should read Runtime-owned working-directory API while using workdir UI language",
+ "Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
);
});
diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts
index 7fad42f4..768f31fc 100644
--- a/web/workspace/src/lib/workspace/settings/model.ts
+++ b/web/workspace/src/lib/workspace/settings/model.ts
@@ -6,6 +6,7 @@ export type Diagnostic = {
export type SettingsSectionId =
| "runtime-connections"
+ | "runtime-inventory"
| "profile-sources"
| "backend-config"
| "workspace-identity";
@@ -85,6 +86,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
],
},
+ {
+ id: "runtime-inventory",
+ label: "Runtime Inventory",
+ status: "read-only",
+ summary:
+ "Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
+ bullets: [
+ "Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
+ "Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
+ "Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
+ ],
+ },
{
id: "profile-sources",
label: "Profile Sources",
@@ -145,6 +158,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
switch (id) {
case "runtime-connections":
return `${SETTINGS_ROUTE}/runtime-connections`;
+ case "runtime-inventory":
+ return `${SETTINGS_ROUTE}/runtimes`;
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":
diff --git a/web/workspace/src/lib/workspace/sidebar/RuntimesNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/RuntimesNavSection.svelte
deleted file mode 100644
index 4cace34f..00000000
--- a/web/workspace/src/lib/workspace/sidebar/RuntimesNavSection.svelte
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
diff --git a/web/workspace/src/lib/workspace/sidebar/TicketsNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/TicketsNavSection.svelte
new file mode 100644
index 00000000..f93249d3
--- /dev/null
+++ b/web/workspace/src/lib/workspace/sidebar/TicketsNavSection.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte
index da01a04a..54326508 100644
--- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte
+++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte
@@ -2,7 +2,7 @@
import { workspaceRoute } from '$lib/workspace/api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
- import RuntimesNavSection from './RuntimesNavSection.svelte';
+ import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
@@ -107,7 +107,7 @@
{/if}
diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts
index 7091da1f..dc48b11d 100644
--- a/web/workspace/src/lib/workspace/sidebar/types.ts
+++ b/web/workspace/src/lib/workspace/sidebar/types.ts
@@ -320,6 +320,31 @@ export type TicketSummary = {
record_source?: string;
};
+export type TicketListResponse = {
+ workspace_id: string;
+ limit: number;
+ items: TicketSummary[];
+ invalid_records: InvalidProjectRecord[];
+ record_authority: string;
+};
+
+export type TicketDetail = {
+ id: string;
+ title: string;
+ state: string;
+ priority?: string | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+ queued_by?: string | null;
+ queued_at?: string | null;
+ risk_flags: string[];
+ body: string;
+ body_truncated: boolean;
+ event_count: number;
+ artifact_count: number;
+ record_source: string;
+};
+
export type TicketKanbanColumn = {
state: string;
items: TicketSummary[];
diff --git a/web/workspace/src/routes/+layout.ts b/web/workspace/src/routes/+layout.ts
index 2ff18641..4fb579c5 100644
--- a/web/workspace/src/routes/+layout.ts
+++ b/web/workspace/src/routes/+layout.ts
@@ -12,10 +12,7 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (!workspaceId) {
const workspace = await loadJson(fetch, "/api/workspace");
if (workspace.data) {
- const scopedPath = workspaceRoute(
- workspace.data.workspace_id,
- url.pathname === "/" ? "" : url.pathname,
- );
+ const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {
diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte
index 87fceed3..cb5bef45 100644
--- a/web/workspace/src/routes/+page.svelte
+++ b/web/workspace/src/routes/+page.svelte
@@ -1,6 +1,6 @@
Redirecting to scoped workspace…
- This compatibility route bootstraps the current workspace id and redirects to the canonical /w/<workspace-id> route.
+ The workspace entry bootstraps the current workspace id and opens the canonical /w/<workspace-id> route.
diff --git a/web/workspace/src/routes/objectives/+page.svelte b/web/workspace/src/routes/objectives/+page.svelte
deleted file mode 100644
index c3cbc837..00000000
--- a/web/workspace/src/routes/objectives/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Redirecting to scoped objectives…
- Objectives are available at the canonical /w/<workspace-id>/objectives route.
-
-
diff --git a/web/workspace/src/routes/objectives/+page.ts b/web/workspace/src/routes/objectives/+page.ts
deleted file mode 100644
index 7a066b58..00000000
--- a/web/workspace/src/routes/objectives/+page.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { PageLoad } from "./$types";
-
-export const load: PageLoad = async () => ({});
diff --git a/web/workspace/src/routes/objectives/[objectiveId]/+page.svelte b/web/workspace/src/routes/objectives/[objectiveId]/+page.svelte
deleted file mode 100644
index 37f9891b..00000000
--- a/web/workspace/src/routes/objectives/[objectiveId]/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Redirecting to scoped objective…
- Objective detail routes are canonical under /w/<workspace-id>/objectives/<objective-id>.
-
-
diff --git a/web/workspace/src/routes/objectives/[objectiveId]/+page.ts b/web/workspace/src/routes/objectives/[objectiveId]/+page.ts
deleted file mode 100644
index 7a066b58..00000000
--- a/web/workspace/src/routes/objectives/[objectiveId]/+page.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { PageLoad } from "./$types";
-
-export const load: PageLoad = async () => ({});
diff --git a/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte
deleted file mode 100644
index c9ddef3c..00000000
--- a/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Redirecting to scoped repository…
- Repository routes are canonical under /w/<workspace-id>/repositories/<repository-id>.
-
-
diff --git a/web/workspace/src/routes/repositories/[repositoryId]/+page.ts b/web/workspace/src/routes/repositories/[repositoryId]/+page.ts
deleted file mode 100644
index 7a066b58..00000000
--- a/web/workspace/src/routes/repositories/[repositoryId]/+page.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { PageLoad } from "./$types";
-
-export const load: PageLoad = async () => ({});
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte
deleted file mode 100644
index 5469576b..00000000
--- a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Redirecting to scoped Worker Console…
- Worker Console routes are canonical under /w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console.
-
-
diff --git a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts b/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts
deleted file mode 100644
index 7a066b58..00000000
--- a/web/workspace/src/routes/runtimes/[runtimeId]/workers/[workerId]/console/+page.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { PageLoad } from "./$types";
-
-export const load: PageLoad = async () => ({});
diff --git a/web/workspace/src/routes/settings/+page.svelte b/web/workspace/src/routes/settings/+page.svelte
deleted file mode 100644
index 18b67741..00000000
--- a/web/workspace/src/routes/settings/+page.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- Redirecting to scoped settings…
- Settings are available at the canonical /w/<workspace-id>/settings route.
-
-
diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte
index ce57122c..d790fc4c 100644
--- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte
+++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte
@@ -1,8 +1,12 @@
@@ -39,12 +43,17 @@