From b0d49737176fb7e511a0baa6ff8f1c410e7bc8cc Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 4 Oct 2026 21:03:16 +0900 Subject: [PATCH 01/24] fix: align console metadata and details controls --- .../console_pending_shell_test.ts | 56 ++++++++++++++++ .../WorkerContextStatus.browser.spec.ts | 23 ++++++- .../console/WorkerContextStatus.svelte | 65 +++++++++++++++---- .../lib/workspace/console/run-status.test.ts | 6 +- .../console/worker-console.ui.test.ts | 20 ++++++ .../lib/workspace/console/worker-metadata.ts | 6 +- .../workers/[workerRef]/console/+page.svelte | 22 +++---- 7 files changed, 162 insertions(+), 36 deletions(-) diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts index 7be6c4046..a419e985f 100644 --- a/tools/web-ux/browser-tests/console_pending_shell_test.ts +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -64,6 +64,62 @@ Deno.test("Console pending scopes are independent and match Tasks row typography `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, ); await page.locator(".task-mini-row").waitFor(); + const metadata = page.getByLabel("Worker model and context", { exact: true }); + const statusGeometry = await metadata.evaluate((status) => { + const task = document.querySelector(".task-mini-row")!; + const composer = document.querySelector(".console-composer")!; + const metrics = (node: Element) => { + const style = getComputedStyle(node); + return [style.fontFamily, style.fontSize, style.lineHeight]; + }; + const rect = status.getBoundingClientRect(); + return { + typography: metrics(status), + taskTypography: metrics(task), + height: rect.height, + belowComposer: rect.top >= composer.getBoundingClientRect().bottom, + alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left, + visible: rect.bottom <= innerHeight, + wrap: getComputedStyle(status).flexWrap, + pageOverflow: document.documentElement.scrollWidth > innerWidth, + }; + }); + assertEquals(statusGeometry.typography, statusGeometry.taskTypography); + assertEquals(statusGeometry.height, 16); + assertEquals(statusGeometry.belowComposer, true); + assertEquals(statusGeometry.alignedWithTasks, true); + assertEquals(statusGeometry.visible, true); + assertEquals(statusGeometry.wrap, "nowrap"); + assertEquals(statusGeometry.pageOverflow, false); + const details = metadata.getByRole("button", { name: "Details", exact: true }); + assertEquals(await page.locator(".console-header").getByRole("button", { name: "Details" }).count(), 0); + const detailsGeometry = await details.evaluate((button) => { + const row = button.closest(".worker-context-status")!; + const rect = button.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + return { + rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1, + centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1, + width: rect.width, + }; + }); + assertEquals(detailsGeometry.rightAligned, true); + assertEquals(detailsGeometry.centered, true); + assert(detailsGeometry.width > 0); + await details.focus(); + await page.keyboard.press("Enter"); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).waitFor(); + await page.keyboard.press("Space"); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + await details.click(); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + if (mode === "both") { + await Deno.mkdir(join(root, "target/web-ux/composer-metadata"), { recursive: true }); + await page.screenshot({ path: join(root, `target/web-ux/composer-metadata/${colorScheme}-${width}.png`) }); + } const queue = mode === "both" || mode === "queue"; const notifications = ["both", "notifications", "legacy"].includes(mode); assertEquals( diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts index f45776f62..fd37c8bcf 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte"; afterEach(cleanup); +test("the three-dot Details control invokes the existing toggle and reflects its state", async () => { + let toggles = 0; + const view = render(WorkerContextStatus, { + metadata: null, + detailsOpen: false, + onToggleDetails: () => { toggles++; }, + }); + const button = view.getByRole("button", { name: "Details" }); + expect(button.querySelectorAll("circle")).toHaveLength(3); + expect(button.textContent?.trim()).toBe(""); + expect(button.getAttribute("aria-expanded")).toBe("false"); + button.click(); + expect(toggles).toBe(1); + await view.rerender({ detailsOpen: true }); + expect(button.getAttribute("aria-expanded")).toBe("true"); +}); + test("renders resolved model, reasoning, and measured context accessibly", () => { const view = render(WorkerContextStatus, { metadata: { @@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () => const status = view.getByLabelText("Worker model and context"); expect(status.textContent).toContain("gpt-6-astra · high"); - expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)"); + expect(status.textContent).toContain("142.0k / 272.0k (52%)"); }); test("renders honest unavailable state instead of a fabricated zero percent", async () => { const view = render(WorkerContextStatus, { metadata: null }); expect(view.getByText("Model unavailable")).not.toBeNull(); - expect(view.getByText("Context unavailable")).not.toBeNull(); + expect(view.getByText("unavailable")).not.toBeNull(); expect(view.container.textContent).not.toContain("0%"); await view.rerender({ @@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as }, }); expect(view.container.textContent).toContain("claude · 8.2k token budget"); - expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)"); + expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)"); await view.rerender({ metadata: { diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte index 248cf5025..c2fdb3e65 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte @@ -7,9 +7,11 @@ type Props = { metadata: ConsoleWorkerMetadata | null; + detailsOpen?: boolean; + onToggleDetails?: () => void; }; - let { metadata }: Props = $props(); + let { metadata, detailsOpen = false, onToggleDetails }: Props = $props(); const modelSummary = $derived(formatModelSummary(metadata)); const contextSummary = $derived(formatContextSummary(metadata)); @@ -18,12 +20,32 @@ {modelSummary} {contextSummary} + {#if onToggleDetails} + + {/if} diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index 29de10947..bc8a9f989 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 142_000, contextSource: "estimated", }), - "Context ~142.0k / 272.0k (52%)", + "~142.0k / 272.0k (52%)", ); assertEquals(formatModelSummary(null), "Model unavailable"); assertEquals( @@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }), "reasoning unavailable", ); - assertEquals(formatContextSummary(null), "Context unavailable"); + assertEquals(formatContextSummary(null), "unavailable"); assertEquals( formatContextSummary({ model: null, @@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 0, contextSource: null, }), - "Context unavailable", + "unavailable", ); }); 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 ba0543d4d..9160e9583 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 @@ -738,6 +738,26 @@ 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", + import.meta.url, + )); + const composer = page.indexOf('
", composer); + const status = page.indexOf("= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer"); + assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata"); + assert(page.indexOf(" { const files = [ "ComposerInput.svelte", diff --git a/web/workspace/src/lib/workspace/console/worker-metadata.ts b/web/workspace/src/lib/workspace/console/worker-metadata.ts index 03f9618e3..d9217cb61 100644 --- a/web/workspace/src/lib/workspace/console/worker-metadata.ts +++ b/web/workspace/src/lib/workspace/console/worker-metadata.ts @@ -36,11 +36,11 @@ export function formatContextSummary( used === null || used === undefined || limit === null || limit === undefined || !Number.isSafeInteger(used) || !Number.isSafeInteger(limit) || used < 0 || limit <= 0 - ) return "Context unavailable"; + ) return "unavailable"; const percent = Math.round((used / limit) * 100); - if (!Number.isFinite(percent)) return "Context unavailable"; + if (!Number.isFinite(percent)) return "unavailable"; const estimate = metadata?.contextSource === "estimated" ? "~" : ""; - return `Context ${estimate}${formatRunTokens(used)} / ${ + return `${estimate}${formatRunTokens(used)} / ${ formatRunTokens(limit) } (${percent}%)`; } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 773e1996d..1a00e244e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1652,7 +1652,6 @@
-
-
@@ -2094,6 +2082,14 @@
+ { + workerDetailsOpen = !workerDetailsOpen; + if (workerDetailsOpen) taskPaneOpen = false; + }} + /> diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index fc951de67..125f09ff5 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history await screen.findByText("Start of conversation"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1); - await fireEvent.click(screen.getByRole("button", { name: "Normal" })); + const footer = screen.getByLabelText("Worker model and context"); + const overview = screen.getByRole("switch", { name: "Overview" }); + expect(screen.queryByRole("button", { name: "Normal" })).toBeNull(); + const details = screen.getByRole("button", { name: "Details" }); + expect(view.container.querySelector(".console-header")).toBeNull(); + expect(footer.contains(overview)).toBe(true); + expect(overview.nextElementSibling).toBe(details); + expect(overview.closest("form")).toBeNull(); + expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer); + expect(overview.getAttribute("aria-checked")).toBe("true"); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("false"); + expect(overview.textContent?.trim()).toBe("Overview"); const transcript = screen.getByRole("article", { name: "main transcript" }); const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id")); expect(ids).toHaveLength(3); expect(new Set(ids).size).toBe(ids.length); - await fireEvent.click(screen.getByRole("button", { name: "Overview" })); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("true"); + expect(overview.textContent?.trim()).toBe("Overview"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); + expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled(); }); test.each([ @@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button", latestListener().onFrame(subscribedFrame(session)); const summary = await screen.findByRole("button", { name: /1 task — pending/ }); expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull(); - expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks"); + expect(view.container.querySelector(".console-header")).toBeNull(); await fireEvent.click(screen.getByRole("button", { name: "Details" })); const details = await screen.findByRole("complementary", { name: "Worker detail" }); expect(details.textContent).not.toContain("Capabilities"); From fbb09e6597f7815c7e66ac6256adfdeb0651578c Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 13:56:33 +0900 Subject: [PATCH 07/24] feat(web): clarify Subject Memory status --- .../memory_document_fixture_server.ts | 45 +++- .../memory_document_shell_test.ts | 63 ++++-- .../scenarios/memory-subjects-dark.json | 40 +++- .../scenarios/memory-subjects-light.json | 40 +++- .../console/worker-console.ui.test.ts | 9 +- .../w/[workspaceId]/memory/+page.svelte | 30 ++- .../memory/[subjectId]/+page.svelte | 207 ++++++++++++++---- .../[subjectId]/[memoryId]/+page.svelte | 29 ++- .../memory/memory-page.browser.spec.ts | 58 +++-- web/workspace/tests/memory-api.test.ts | 59 ++++- 10 files changed, 460 insertions(+), 120 deletions(-) diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 3cd9cc001..b370ffe81 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -66,7 +66,13 @@ ${"detail-wide-code-".repeat(18)} `; const subjects = [ - subject(representativeSubjectId, "Release coordination", "active", 42), + subject( + representativeSubjectId, + "Release coordination", + "active", + 42, + connectedWorker("Release coordination Worker with a deliberately long display name"), + ), subject(emptySubjectId, "Empty ready subject", "active", 0), subject(staleSubjectId, "Stale surface subject", "active", 19), subject(failedSubjectId, "Failed surface subject", "active", 8), @@ -75,7 +81,37 @@ const subjects = [ ]; const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); -function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { +function connectedWorker(displayName: string) { + return { + runtime_id: "embedded-worker-runtime", + worker_id: "release-coordination-worker", + host_id: "fixture-host", + display_name: displayName, + label: displayName, + profile: "builtin:companion", + singleton_key: `subjektiv:${representativeSubjectId}`, + tags: [], + workspace: { + visibility: "workspace", + identity: workspaceId, + workspace_id: workspaceId, + }, + state: "idle", + last_seen_at: "2026-01-02T03:04:05Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Fixture Worker" }, + diagnostics: [], + }; +} + +function subject( + id: string, + role: string, + state: "active" | "retired", + storeRevision: number, + currentWorker?: ReturnType, +) { return { id, role, @@ -83,6 +119,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev store_revision: storeRevision, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", + ...(currentWorker ? { current_worker: currentWorker } : {}), }; } @@ -125,7 +162,7 @@ function memoriesFor(subjectId: string) { 1, "working_assumption", "retracted", - "T-672 UNIQUE END MARKER", + "Memory list end marker for scroll validation", ), ]; } @@ -235,7 +272,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { evidence_id: "evidence-message-0001", origin: { kind: "human_input", account_id: "account-fixture" }, evidence_kind: "message", - label: "T-672 product direction", + label: "Memory information design source", summary: "Bounded source reference retained with the committed revision.", }], source_refs_total: 1, diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index c0913082a..53b167168 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -16,7 +16,7 @@ const memoryId = const subjectPageCursor = "fixture-subject-page-2"; const createInteractionArtifacts = join( repositoryRoot, - "target/web-ux/t-689-browser-interactions", + "target/web-ux/t-692-browser-interactions", ); async function freePort(): Promise { @@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise { const document = (globalThis as any).document; const main = document.querySelector("main"); const marker = [...document.querySelectorAll("h3")].find((heading) => - heading.textContent === "T-672 UNIQUE END MARKER" + heading.textContent === "Memory list end marker for scroll validation" ); if (!main || !marker) return false; const mainRect = main.getBoundingClientRect(); @@ -262,7 +262,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); assertEquals( - await page.getByRole("article", { name: "Resident surface" }).locator("img, script") + await page.getByRole("article", { name: "Resident context" }).locator("img, script") .count(), 0, ); @@ -282,7 +282,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await page.locator("main").hover(); await page.mouse.wheel(0, 100_000); } else if (scenario.label === "keyboard") { - await page.getByText("Surface provenance", { exact: true }).focus(); + await page.getByText("Surface sources and diagnostics", { exact: true }).focus(); await page.keyboard.press("End"); } else { await swipeMainToEnd(page); @@ -313,17 +313,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco level: 1, }).waitFor(); assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); - assertEquals(await detailPage.getByText("T-672 product direction").count(), 1); + assertEquals(await detailPage.getByText("Memory information design source").count(), 1); assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); - assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0); + assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0); await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( "link", { name: "Next page →" }, ).click(); - await detailPage.getByText("Revision 1", { exact: true }).waitFor(); + await detailPage.getByText("Memory revision 1", { exact: true }).waitFor(); assert(new URL(detailPage.url()).searchParams.has("revision_cursor")); assertEquals( await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( @@ -352,6 +352,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const paginationPage = await paginationContext.newPage(); await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`); await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor(); + assertEquals( + await paginationPage.getByText( + "Release coordination Worker with a deliberately long display name", + { exact: true }, + ).count(), + 1, + ); + assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0); + assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( "link", @@ -451,10 +460,12 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, ); - const currentWorker = createPage.locator(".subject-facts div").filter({ - hasText: "Current worker", - }); - assert((await currentWorker.textContent())?.includes("None")); + const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator( + "..", + ); + assert((await currentWorker.textContent())?.includes("Not connected")); + await createPage.getByText("Resident context has not been generated.").waitFor(); + await createPage.getByText("No committed Memories yet.").waitFor(); assertEquals(mutationPaths, [ `/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`, @@ -472,21 +483,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const stateCases = [ { id: "empty-subject", - text: "Resident surface is ready and empty.", + text: "Resident context is current but empty.", + role: "status" as const, + }, + { + id: "stale-subject", + text: "Resident context needs to be refreshed.", role: "status" as const, }, - { id: "stale-subject", text: "Resident surface is stale.", role: "status" as const }, { id: "failed-subject", - text: "Resident surface generation failed.", + text: "Resident context generation failed.", role: "alert" as const, }, { id: "ungenerated-subject", - text: "Resident surface has not been generated.", + text: "Resident context has not been generated.", role: "status" as const, }, - { id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const }, + { + id: "error-subject", + text: "Resident context status unavailable.", + role: "alert" as const, + }, ]; for (const stateCase of stateCases) { const page = await stateContext.newPage(); @@ -494,8 +513,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text }); await state.waitFor(); assert((await state.textContent())?.includes(stateCase.text)); + if (stateCase.id === "stale-subject") { + assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1); + } + if (stateCase.id === "failed-subject") { + assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1); + } if (stateCase.id !== "empty-subject") { - assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0); + assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0); } await assertNoPageWideOverflow(page); await page.close(); diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json index 7fbdebfb0..bfb3de69b 100644 --- a/tools/web-ux/scenarios/memory-subjects-dark.json +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json index 9ddf6f803..905f15372 100644 --- a/tools/web-ux/scenarios/memory-subjects-light.json +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", 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 ba0543d4d..55b0d091f 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 @@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectIndexLoad.includes('"/subjektiv/subjects"') && subjectIndexLoad.includes("parseSubjektivSubjectListResponse") && subjectIndexPage.includes(">Subjects") && - subjectIndexPage.includes("subject.store_revision") && + subjectIndexPage.includes("Worker connection") && + subjectIndexPage.includes("subject.current_worker") && + !subjectIndexPage.includes("subject.store_revision") && subjectIndexPage.includes('data-memory-view="subjects"'), "Memory product entry should list explicit typed subjects", ); @@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectPageLoad.includes("`${subjectPath}/surface`") && subjectPageLoad.includes("`${subjectPath}/memories`") && subjectPage.includes("DocumentMarkdown from") && - subjectPage.includes("Resident surface") && + subjectPage.includes("Resident context") && + subjectPage.includes("Subject store revision") && subjectPage.includes("Current Memories") && subjectPage.includes("surface?.availability === 'ready'") && + subjectPage.includes("surface?.availability === 'ungenerated'") && subjectPage.includes("surface?.availability === 'stale'") && + subjectPage.includes("surface?.availability === 'failed'") && subjectPage.includes("data-surface-ready-empty") && !subjectPage.includes("overflow-y"), "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller", diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 9483ad3fc..2ba3dbdca 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -31,6 +31,10 @@ return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path; } + function subjectStateLabel(state: SubjektivSubjectResponse['state']): string { + return state === 'active' ? 'Available' : 'Retired'; + } + async function openCreateForm(): Promise { createExpanded = true; createError = null; @@ -109,7 +113,7 @@
{#if data.subjects.data} - {subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'} + {subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''} {/if}
-
Memory id
{memory.id}
-
Revision
{memory.revision}
-
Updated
+
Memory ID
{memory.id}
+
Memory revision
{memory.revision}
+
Last changed
{/each} @@ -270,25 +323,79 @@ font-size: var(--font-size-compact); } - .subject-facts { - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-3); + .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-facts > div { - display: block; + .subject-overview > div { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; } - .subject-facts dt { - white-space: normal; + .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-facts dd { - margin-top: var(--space-1); - color: var(--text); + .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, @@ -455,7 +562,7 @@ } @media (max-width: 900px) { - .subject-facts { + .subject-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -472,10 +579,16 @@ gap: var(--space-2); } - .subject-facts { + .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; } diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte index 07a1108fe..71c038627 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -63,6 +63,11 @@ 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'; } @@ -98,8 +103,8 @@
- {memory.state} - {label(memory.kind)} + {titleLabel(memory.state)} + {titleLabel(memory.kind)} {#if memory.revision !== memory.current_revision}Historical revision{/if}

{memory.claim}

@@ -109,11 +114,11 @@
-
Revision
{memory.revision} of {memory.current_revision}
-
State
{memory.state}
-
Kind
{label(memory.kind)}
+
Memory revision
{memory.revision} of {memory.current_revision}
+
Status
{titleLabel(memory.state)}
+
Type
{titleLabel(memory.kind)}
Created
-
Updated
+
Last changed
@@ -140,7 +145,7 @@

Audit context

Candidate provenance

- {memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'} + {memory.source_candidates.length} shown
{#if memory.source_candidates.length === 0} @@ -226,7 +231,11 @@
-

Immutable record

Revision history

{#if data.revisions.data}{revisions.length}{data.revisions.data.has_more ? '+' : ''}{/if}
+
+

Immutable record

Revision history

+ {#if data.revisions.data}{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}{/if} +
+

These are versions of this Memory record, separate from the Subject’s internal update number.

{#if data.revisions.data} {#if revisions.length === 0}
@@ -238,7 +247,7 @@ {#each revisions as revision (revision.revision)}
  • -
    Revision {revision.revision}{revision.state}{label(revision.kind)}
    +
    Memory revision {revision.revision}{titleLabel(revision.state)}{titleLabel(revision.kind)}

    {revision.claim}

    {revision.change_reason} ·
    @@ -386,6 +395,7 @@ } .memory-facts { + display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } @@ -414,6 +424,7 @@ .why-useful, .staleness, .change-reason, + .section-intro, .empty-copy, .bounded-note { margin: 0; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index cc767fa43..9ed075458 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -1,12 +1,6 @@ // @vitest-environment happy-dom -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/svelte"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; const { gotoMock } = vi.hoisted(() => ({ @@ -73,6 +67,9 @@ test("renders the subject index as the Memory product entry", () => { expect(screen.getByRole("link", { name: /Release coordinator/ })).not .toBeNull(); expect(screen.getByText("subject-1")).not.toBeNull(); + expect(screen.getByText("Not connected")).not.toBeNull(); + expect(screen.queryByText("Store revision", { exact: true })).toBeNull(); + expect(screen.getByText("1 shown · more available")).not.toBeNull(); expect(screen.getByRole("link", { name: "First page" }).getAttribute("href")) .toBe("/w/workspace-1/memory"); expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) @@ -184,9 +181,7 @@ test("preserves invalid and rejected role drafts with accessible errors", async }); test("warns about an unknown create outcome without replaying the POST", async () => { - const fetchMock = vi.fn(() => - Promise.reject(new TypeError("connection reset")) - ); + const fetchMock = vi.fn(() => Promise.reject(new TypeError("connection reset"))); vi.stubGlobal("fetch", fetchMock); render(SubjectIndexPage, { data: subjectIndexData() } as never); @@ -214,14 +209,16 @@ test("renders a ready resident surface and committed Memory lifecycle states", a workspaceId: "workspace-1", subjectId: "subject-1", cursor: "memory-current", - subject: result(subject()), + subject: result({ + ...subject(), + current_worker: { display_name: "Release Worker" }, + }), surface: result({ subject_id: "subject-1", availability: "ready" as const, snapshot: { snapshot_id: "snapshot-1", - body_md: - "# Resident context\n\nUse the **current** committed record.", + body_md: "# Resident context\n\nUse the **current** committed record.", memory_refs: [{ memory_id: "memory-1", revision: 3 }], built_from_store_revision: 9, created_at: "2026-01-02T03:04:05Z", @@ -267,9 +264,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) .not.toBeNull(); }); - expect(screen.getByRole("article", { name: "Resident surface" })).not + expect(screen.getByRole("article", { name: "Resident context" })).not .toBeNull(); - expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan( + expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); + expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull(); + expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull(); + expect(screen.getByText("Subject store revision", { exact: true })).not + .toBeNull(); + expect(screen.getByText(/not a Memory count/)).not.toBeNull(); + expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan( 0, ); expect(screen.getByText("Resolved", { exact: true })).not.toBeNull(); @@ -303,8 +306,24 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }), }, } as never); - expect(screen.getByText("Resident surface is ready and empty.")).not + expect(screen.getByText("Resident context is current but empty.")).not .toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + + await view.rerender( + { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "ungenerated" as const, + }), + }, + } as never, + ); + expect(screen.getByText("Resident context has not been generated.")).not + .toBeNull(); + expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); await view.rerender( { @@ -317,7 +336,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }, } as never, ); - expect(screen.getByText("Resident surface is stale.")).not.toBeNull(); + expect(screen.getByText("Resident context needs to be refreshed.")).not + .toBeNull(); await view.rerender( { @@ -333,7 +353,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s expect(screen.getByRole("alert").textContent).toContain("generation failed"); await view.rerender({ data: { ...base, surface: result(null) } } as never); - expect(screen.getByText("Resident surface data is unavailable.")).not + expect(screen.getByText("Resident context status is unavailable.")).not .toBeNull(); await view.rerender( @@ -342,7 +362,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s } as never, ); expect(screen.getByRole("alert").textContent).toContain( - "Resident surface unavailable", + "Resident context status unavailable", ); }); diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 97f036e1e..52f7c017e 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -61,9 +61,7 @@ async function assertSubjectCreateRejects( assertEquals(error.status, expectedStatus); if (!error.message.includes(expectedMessage)) { throw new Error( - `expected ${JSON.stringify(error.message)} to include ${ - JSON.stringify(expectedMessage) - }`, + `expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`, ); } return; @@ -82,6 +80,30 @@ function subject(id = "subject-1") { }; } +function currentWorker() { + return { + runtime_id: "runtime-1", + worker_id: "worker-1", + host_id: "host-1", + display_name: "Release Worker", + label: "Release Worker", + profile: "builtin:companion", + singleton_key: "subjektiv:subject-1", + tags: [], + workspace: { + visibility: "workspace", + identity: "workspace-1", + workspace_id: "workspace-1", + }, + state: "idle", + last_seen_at: "2026-09-02T00:00:00Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Embedded" }, + diagnostics: [], + }; +} + function queryItem(id = "memory-1") { return { id, @@ -187,14 +209,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo () => parseSubjektivSubjectListResponse({ limit: 100, - items: Array.from({ length: 101 }, (_, index) => - subject(`subject-${index}`)), + items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)), has_more: false, }), "bounded array", ); }); +Deno.test("Subject parser preserves live Worker connection state", () => { + assertEquals( + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: currentWorker(), + }), + { ...subject(), current_worker: currentWorker() }, + ); + assertEquals( + parseSubjektivSubjectResponse({ ...subject(), current_worker: null }), + { ...subject(), current_worker: null }, + ); + assertThrows( + () => + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: { display_name: "Incomplete Worker" }, + }), + "current worker.runtime_id", + ); +}); + Deno.test("Subject list parser preserves the exact bounded response", () => { const response = { limit: 100, items: [subject()], has_more: false }; assertEquals(parseSubjektivSubjectListResponse(response), response); @@ -328,8 +371,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit throw new TypeError("connection reset"); }) as typeof fetch; await assertSubjectCreateRejects( - () => - createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), + () => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), "unknown_outcome", "may have been created", ); @@ -433,8 +475,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, assertThrows( () => parseSubjektivMemoryQueryResponse({ - items: Array.from({ length: 101 }, (_, index) => - queryItem(`memory-${index}`)), + items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)), has_more: false, }), "bounded array", From 4cf6c130cde19197d6b657783b1ac5218984a2d1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 13:59:39 +0900 Subject: [PATCH 08/24] fix(web): distinguish empty resident context --- .../memory/[subjectId]/+page.svelte | 9 ++++++++- .../memory/memory-page.browser.spec.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte index 86292ffa1..def40f255 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte @@ -47,6 +47,13 @@ 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 resident context to show.'; + } + function subjectStateLabel(state: 'active' | 'retired'): string { return state === 'active' ? 'Available' : 'Retired'; } @@ -95,7 +102,7 @@ Resident context {#if surface} {surfaceLabel(surface.availability)} -

    {surface.availability === 'ready' ? 'Current generated context is available.' : 'No current generated context is available to use.'}

    +

    {surfaceSummary(surface.availability)}

    {:else} Unavailable

    The resident context status could not be read.

    diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 9ed075458..f1b9bb906 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -1,6 +1,12 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; const { gotoMock } = vi.hoisted(() => ({ @@ -181,7 +187,9 @@ test("preserves invalid and rejected role drafts with accessible errors", async }); test("warns about an unknown create outcome without replaying the POST", async () => { - const fetchMock = vi.fn(() => Promise.reject(new TypeError("connection reset"))); + const fetchMock = vi.fn(() => + Promise.reject(new TypeError("connection reset")) + ); vi.stubGlobal("fetch", fetchMock); render(SubjectIndexPage, { data: subjectIndexData() } as never); @@ -218,7 +226,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a availability: "ready" as const, snapshot: { snapshot_id: "snapshot-1", - body_md: "# Resident context\n\nUse the **current** committed record.", + body_md: + "# Resident context\n\nUse the **current** committed record.", memory_refs: [{ memory_id: "memory-1", revision: 3 }], built_from_store_revision: 9, created_at: "2026-01-02T03:04:05Z", @@ -308,6 +317,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s } as never); expect(screen.getByText("Resident context is current but empty.")).not .toBeNull(); + expect(screen.getByText(/current empty surface exists/)).not.toBeNull(); expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); await view.rerender( From b489a59057f7a7ac489da2a18ded0c6dfcb0dd09 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 14:05:45 +0900 Subject: [PATCH 09/24] fix: handle configuration schema bodies and editor load failures --- .../ConfigSourceEditor.browser.spec.ts | 231 ++++++++++++++++++ .../config-source/ConfigSourceEditor.svelte | 51 +++- .../src/lib/workspace/config-source/api.ts | 7 +- web/workspace/test/config-source/api.test.ts | 91 ++++++- 4 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts new file mode 100644 index 000000000..6355eaa4d --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; + +const toolchain = vi.hoisted(() => ({ + construct: vi.fn(), + setSnapshot: vi.fn(), + analyze: vi.fn(), + complete: vi.fn(), + close: vi.fn(), +})); +vi.mock("./toolchain.ts", () => ({ + ConfigSourceToolchain: class { + constructor() { + toolchain.construct(); + } + setSnapshot = toolchain.setSnapshot; + analyze = toolchain.analyze; + complete = toolchain.complete; + close = toolchain.close; + }, +})); + +const schemaSource = "schema body\n".repeat(6000); +const tree = { + snapshot: { + revision: 7, + digest: "sha256:tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: "WorkspaceConfigSchema({})\n", + content_digest: "sha256:main", + }, + }, + }, + contract: { + contract_version: 1, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: { + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: schemaSource, + source_digest: "sha256:contribution", + }], + source: schemaSource, + fingerprint: "sha256:schema", + }, + fingerprint: "sha256:toolchain", + }, + projection_digest: "sha256:projection", +}; +const fetcher = vi.fn(); +const response = () => new Response(JSON.stringify(tree)); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("fetch", fetcher); + fetcher.mockImplementation(async () => response()); + toolchain.setSnapshot.mockResolvedValue(undefined); + toolchain.analyze.mockResolvedValue([]); + toolchain.complete.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderEditor(workspaceId = "w") { + return render(ConfigSourceEditor, { workspaceId }); +} + +function expectNoEditor(view: ReturnType) { + expect(view.queryByText("Select or create a source")).toBeNull(); + expect(view.queryByRole("button", { name: "Create local source" })) + .toBeNull(); + expect(view.queryByRole("button", { name: "Commit" })).toBeNull(); + expect(view.container.querySelector(".cm-editor")).toBeNull(); +} + +async function expectReady(view: ReturnType) { + await waitFor(() => + expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not + .toBeNull() + ); + expect(view.container.querySelector('.cm-content[contenteditable="true"]')) + .not.toBeNull(); + expect(view.container.textContent).toContain("WorkspaceConfigSchema({})"); + expect(view.queryByRole("alert")).toBeNull(); +} + +test("loading is not an empty source tree; editing waits for schema installation", async () => { + let finishFetch!: (response: Response) => void; + let finishSetup!: () => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + finishSetup = resolve; + }), + ); + const view = render(ConfigSourceEditor, { workspaceId: "w/one" }); + expect(view.getByRole("status").textContent).toContain("Loading source tree"); + expectNoEditor(view); + finishFetch(response()); + await waitFor(() => + expect(toolchain.setSnapshot).toHaveBeenCalledWith( + tree.snapshot, + tree.contract.schema_bundle, + ) + ); + expectNoEditor(view); + finishSetup(); + await expectReady(view); + expect(fetcher).toHaveBeenCalledWith( + "/api/w/w%2Fone/config/source-tree", + expect.anything(), + ); +}); + +test.each(["http", "invalid"])( + "%s failure has a manual reload, not creation controls or automatic retries", + async (failure) => { + fetcher.mockResolvedValueOnce( + failure === "http" + ? new Response("Backend unavailable", { status: 503 }) + : new Response(JSON.stringify({ ...tree, unexpected: true })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Unable to load workspace configuration", + ) + ); + expect(view.getByRole("alert").textContent).toContain( + failure === "http" ? "Backend unavailable" : "invalid response", + ); + expectNoEditor(view); + expect(toolchain.setSnapshot).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(fetcher).toHaveBeenCalledOnce(); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); + +test.each(["construct", "setSnapshot"] as const)( + "toolchain %s failure is caught and manual reload initializes a fresh toolchain", + async (phase) => { + if (phase === "construct") { + toolchain.construct.mockImplementationOnce(() => { + throw new Error("Worker unavailable"); + }); + } else {toolchain.setSnapshot.mockRejectedValueOnce( + new Error("Schema setup failed"), + );} + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + phase === "construct" ? "Worker unavailable" : "Schema setup failed", + ) + ); + expectNoEditor(view); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(toolchain.construct).toHaveBeenCalledTimes(2); + }, +); + +test("a successfully loaded empty tree still offers source creation", async () => { + fetcher.mockResolvedValueOnce( + new Response(JSON.stringify({ + ...tree, + snapshot: { ...tree.snapshot, entries: {} }, + })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByText("Select or create a source")).not.toBeNull() + ); + expect(view.getByRole("button", { name: "Create local source" })).not + .toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("unmount during schema setup closes the toolchain", async () => { + let failSetup!: (error: Error) => void; + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((_, reject) => { + failSetup = reject; + }), + ); + toolchain.close.mockImplementationOnce(() => + failSetup(new Error("toolchain closed")) + ); + const view = renderEditor(); + await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce()); + view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.close).toHaveBeenCalledOnce(); +}); + +test("unmount during fetch does not start a toolchain", async () => { + let finishFetch!: (response: Response) => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + const view = renderEditor(); + view.unmount(); + finishFetch(response()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.construct).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 0cf4471d4..78f9edb44 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -21,6 +21,8 @@ let newPath = $state("module.dcdl"); let diagnostics = $state([]); let status = $state("Loading source tree…"); + let loadState = $state<"loading" | "error" | "ready">("loading"); + let mounted = false; let busy = $state(false); let workingChanges = $state([]); let baseRevision = $state(0); @@ -42,9 +44,12 @@ const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); onMount(() => { - toolchain = new ConfigSourceToolchain(); + mounted = true; void reload(); - return () => toolchain?.close(); + return () => { + mounted = false; + toolchain?.close(); + }; }); $effect(() => { @@ -70,9 +75,20 @@ }); async function reload() { + loadState = "loading"; + status = "Loading source tree…"; analysisReady = false; + analysisGeneration += 1; try { - treeState = await fetchConfigTree(workspaceId); + const remote = await fetchConfigTree(workspaceId); + if (!mounted) return; + toolchain?.close(); + toolchain = null; + toolchain = new ConfigSourceToolchain(); + await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); + if (!mounted) return; + // Publish the new baseline only after both fetch and toolchain setup succeed. + treeState = remote; if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } @@ -80,15 +96,19 @@ baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); analysisReady = true; workingChanges = []; renamePath = selectedPath; diagnostics = []; conflict = false; status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; + loadState = "ready"; } catch (error) { + if (!mounted) return; + toolchain?.close(); + toolchain = null; status = String(error); + loadState = "error"; } } @@ -213,8 +233,6 @@ } async function discardAndReload() { - workingChanges = []; - source = ""; await reload(); } @@ -287,7 +305,18 @@ } -
    +
    + {#if loadState === "loading"} +
    Loading source tree…
    + {:else if loadState === "error"} +
    +
    + Unable to load workspace configuration +

    {status}

    +
    + +
    + {:else}
    + + diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 479ba0b9b..c277ccc5d 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -11,6 +11,9 @@ import type { } from "./types.ts"; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Schema bodies are documents, not short metadata. Keep them within the +// response budget; readJson also bounds the combined bundle and contributions. +const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES; const MAX_ERROR_BYTES = 4 * 1024; const MAX_ENTRY_COUNT = 256; const MAX_ENTRY_BYTES = 256 * 1024; @@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution { provider_id: boundedString(item.provider_id), namespace: boundedString(item.namespace), version: boundedString(item.version), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), ...(projection === undefined ? {} : { projection_validator: projection }), source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), }; @@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle { const item = record(value, ["contributions", "source", "fingerprint"]); return { contributions: boundedArray(item.contributions).map(parseContribution), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), }; } diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 6c5771099..d0269c215 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn entrypoints: [], }, fetcher); - assertEquals(calls.map((call) => call.url), [ + assertEquals(calls.map((call) => call.url), [ "/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/source-tree/revisions/7", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/commit", ]); - assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[3].init?.method, "POST"); assert( String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), ); }); +function withSchema(source: unknown, contributionSource: unknown = "builtin") { + return { + ...tree, + contract: { + ...tree.contract, + schema_bundle: { + ...tree.contract.schema_bundle, + source, + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: contributionSource, + source_digest: "sha256:contribution", + }], + }, + }, + }; +} + +for (const field of ["bundle", "contribution"] as const) { + Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => { + const source = "schema body\n".repeat(6000); + const body = field === "bundle" + ? withSchema(source) + : withSchema("builtin", source); + const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; + assertEquals(await fetchConfigTree("w", fetcher), body); + assertEquals( + await commitConfigTree("w", { + base_revision: 7, + base_digest: snapshot.digest, + changes: [], + entrypoints: ["main.dcdl"], + }, fetcher), + body, + ); + }); + + Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => { + const body = (source: unknown) => + field === "bundle" ? withSchema(source) : withSchema("builtin", source); + const limit = 8 * 1024 * 1024; + const atLimit = "é".repeat(limit / 2); + assertEquals( + parseWorkspaceConfigTreeResponse(body(atLimit)), + body(atLimit), + ); + for (const invalid of [atLimit + "x", null, 42, {}]) { + assertThrows( + () => parseWorkspaceConfigTreeResponse(body(invalid)), + ConfigSourceApiError, + ); + } + }); +} + +Deno.test("config source API retains metadata and total response limits", async () => { + const invalid = withSchema("builtin"); + invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat( + 4097, + ); + assertThrows( + () => parseWorkspaceConfigTreeResponse(invalid), + ConfigSourceApiError, + ); + for ( + const headers of [ + new Headers(), + new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }), + ] + ) { + const fetcher = (() => + Promise.resolve( + new Response( + JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), + { headers }, + ), + )) as typeof fetch; + await assertRejects( + () => fetchConfigTree("w", fetcher), + ConfigSourceApiError, + "too large", + ); + } +}); + Deno.test("config source API rejects unknown response fields", async () => { const fetcher = (() => From 90c74df87979bfe449bfcc767706a541dca577b1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 14:05:45 +0900 Subject: [PATCH 10/24] fix: enable indentation keys in Decodal editor --- .../DecodalSourceEditor.browser.spec.ts | 153 ++++++++++++++++++ .../settings/DecodalSourceEditor.svelte | 7 + 2 files changed, 160 insertions(+) create mode 100644 web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts new file mode 100644 index 000000000..baf7c6a9f --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { + type CompletionResult, + completionStatus, + startCompletion, +} from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import DecodalSourceEditor from "./DecodalSourceEditor.svelte"; + +afterEach(cleanup); + +function setup( + value: string, + props: { + readonly?: boolean; + fixedSchemaWrapper?: boolean; + onComplete?: ( + source: string, + offset: number, + explicit: boolean, + ) => Promise; + } = {}, +) { + const onChange = vi.fn(); + const result = render(DecodalSourceEditor, { value, onChange, ...props }); + const content = result.container.querySelector(".cm-content")!; + const editor = EditorView.findFromDOM(content)!; + editor.focus(); + return { ...result, content, editor, onChange }; +} + +test.each([ + { name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" }, + { + name: "object body", + value: "{\n name = 1", + anchor: 12, + expected: "{\n name = 1\n ", + }, + { name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" }, + { + name: "nested object", + value: "{\n nested = {}\n}", + anchor: 14, + expected: "{\n nested = {\n \n }\n}", + }, +])( + "Enter inserts an indented newline in $name", + async ({ value, anchor, expected }) => { + const { content, editor, onChange } = setup(value); + editor.dispatch({ selection: { anchor } }); + expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" })) + .toBe(false); + expect(editor.state.doc.toString()).toBe(expected); + expect(onChange).toHaveBeenLastCalledWith(expected); + }, +); + +test("Enter indents inside the fixed wrapper without altering its assertion", async () => { + const { content, editor } = setup("{} as WorkspaceConfigSchema\n", { + fixedSchemaWrapper: true, + }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe( + "{\n \n} as WorkspaceConfigSchema\n", + ); + expect(editor.state.selection.main.head).toBe(4); +}); + +test("Enter does not mutate readonly sources", async () => { + const value = "{\n name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + editor.dispatch({ selection: { anchor: value.length } }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Enter adds an indented newline instead of accepting a visible completion", async () => { + const value = "{\n na"; + const { content, editor } = setup(value, { + onComplete: async () => ({ + from: 4, + options: [{ label: "name", type: "property" }], + }), + }); + editor.dispatch({ selection: { anchor: value.length } }); + startCompletion(editor); + await waitFor(() => expect(completionStatus(editor.state)).toBe("active")); + // Wait past CodeMirror's completion interaction delay, so accepting a + // suggestion would be observable rather than ignored as an early keypress. + await new Promise((resolve) => setTimeout(resolve, 100)); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe("{\n na\n "); + await waitFor(() => expect(completionStatus(editor.state)).toBeNull()); +}); + +test("Tab indents the current line and Shift+Tab removes indentation", async () => { + const { content, editor, onChange } = setup("name = 1"); + editor.dispatch({ selection: { anchor: 4 } }); + expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe( + false, + ); + expect(editor.state.doc.toString()).toBe(" name = 1"); + expect(onChange).toHaveBeenLastCalledWith(" name = 1"); + expect(editor.hasFocus).toBe(true); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe("name = 1"); + expect(onChange).toHaveBeenLastCalledWith("name = 1"); +}); + +test("Tab and Shift+Tab indent every selected line", async () => { + const value = "first = 1\nsecond = 2"; + const { content, editor } = setup(value); + editor.dispatch({ selection: { anchor: 0, head: value.length } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2"); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); +}); + +test("Tab inserts indentation on a blank line", async () => { + const { content, editor } = setup(""); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" "); +}); + +test("Tab and Shift+Tab do not mutate readonly sources", async () => { + const value = " name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Tab indents the schema body without modifying the fixed wrapper", async () => { + const value = "{\n name = 1\n} as WorkspaceConfigSchema\n"; + const { content, editor } = setup(value, { fixedSchemaWrapper: true }); + editor.dispatch({ selection: { anchor: value.indexOf("name") } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe( + "{\n name = 1\n} as WorkspaceConfigSchema\n", + ); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + editor.dispatch({ selection: { anchor: 0 } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(value); +}); diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 5fd974cec..7bb39e679 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -9,6 +9,7 @@ type CompletionContext, type CompletionResult, } from '@codemirror/autocomplete'; + import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view'; @@ -129,6 +130,8 @@ decodal({ highlight: false }), syntaxHighlighting(syntaxTheme), autocompletion({ + // Use the explicit keymap below so Enter never accepts a suggestion. + defaultKeymap: false, activateOnTyping: false, override: [ async (context: CompletionContext) => { @@ -139,6 +142,10 @@ ], }), keymap.of(completionKeymapWithoutEnter), + keymap.of([ + { key: 'Enter', run: insertNewlineAndIndent }, + indentWithTab, + ]), fixedSchemaWrapperCompartment.of( initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], ), From bc7b2f894c8f780393b5d47790d6fbce5935a621 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 15:33:43 +0900 Subject: [PATCH 11/24] feat: expose WIP objects from host namespaces --- crates/worker/src/controller.rs | 24 +- .../src/feature/builtin/merge_request.rs | 29 +- .../worker/src/feature/builtin/objective.rs | 45 +- crates/worker/src/feature/builtin/ticket.rs | 35 +- crates/worker/src/wip.rs | 914 ++++++++++++++++-- docs/README.md | 2 + docs/design/merge-request-wip-projection.md | 8 +- docs/design/objective-wip-projection.md | 8 +- docs/design/ticket-wip-projection.md | 8 +- docs/design/worker-wip-mode.md | 29 +- 10 files changed, 914 insertions(+), 188 deletions(-) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 950a81a55..9b959f8b6 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1593,12 +1593,12 @@ where let engine = worker.engine_mut(); if feature_config.merge_request.any() && wip_mode { - let feature_route = wip_mount_registry - .allocate_feature_route("merge-request") + let namespace_route = wip_mount_registry + .allocate_namespace("merge-request", "merge-requests") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Merge Request WIP route: {error}"), + format!("allocate Merge Request WIP namespace: {error}"), ) })?; crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( @@ -1606,7 +1606,7 @@ where workspace_client.clone(), feature_config.merge_request, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1623,12 +1623,12 @@ where intake: feature_config.ticket.intake, workflow: feature_config.ticket.workflow, }; - let feature_route = wip_mount_registry - .allocate_feature_route("ticket") + let namespace_route = wip_mount_registry + .allocate_namespace("ticket", "tickets") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Ticket WIP route: {error}"), + format!("allocate Ticket WIP namespace: {error}"), ) })?; crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( @@ -1636,7 +1636,7 @@ where workspace_client.clone(), ticket_access, wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( @@ -1657,19 +1657,19 @@ where engine.register_tool(definition); } if wip_mode { - let feature_route = wip_mount_registry - .allocate_feature_route("objective") + let namespace_route = wip_mount_registry + .allocate_namespace("objective", "objectives") .map_err(|error| { std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!("allocate Objective WIP route: {error}"), + format!("allocate Objective WIP namespace: {error}"), ) })?; crate::feature::builtin::objective::mount_workspace_http_objective_wip( &mut wip_mount_registry, workspace_client.clone(), wip_permissions.clone(), - &feature_route, + &namespace_route, ) .map_err(|error| { std::io::Error::new( diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index afc5e08f7..88460b2d7 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -5,9 +5,9 @@ use crate::feature::{ }; use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -529,7 +529,7 @@ fn native_merge_request_tools( let (meta, tool) = definition(client.clone(), kind)(); jsonschema::validator_for(&meta.input_schema).map_err(|error| { WipMountError::InvalidProjection { - route: "/features/merge-request/merge-requests".into(), + route: "/merge-requests".into(), message: format!("Merge Request tool schema cannot be retained: {error}"), } })?; @@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip( client: Arc, config: MergeRequestFeatureConfig, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("merge-requests")?; + let collection_route = namespace_route.root().to_string(); let tools = native_merge_request_tools(client, config)?; let claimed_tools = tools .iter() @@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration( .get("properties") .and_then(Value::as_object) .ok_or_else(|| WipMountError::InvalidProjection { - route: "/features/merge-request/merge-requests".into(), + route: "/merge-requests".into(), message: format!( "Merge Request tool `{}` schema has no properties", tool.name @@ -1474,7 +1474,9 @@ mod tests { #[test] fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() { let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("merge-request").unwrap(); + let namespace_route = registry + .allocate_namespace("merge-request", "merge-requests") + .unwrap(); mount_workspace_http_merge_request_wip( &mut registry, Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")), @@ -1484,17 +1486,14 @@ mod tests { ..Default::default() }, None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/merge-request/merge-requests"] - ); + assert_eq!(registry.routes().collect::>(), ["/merge-requests"]); let resolver = MergeRequestItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/merge-request/merge-requests".into(), + collection_route: "/merge-requests".into(), revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())), }; assert!( @@ -1645,7 +1644,7 @@ mod tests { let resolver = MergeRequestItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/merge-request/merge-requests".into(), + collection_route: "/merge-requests".into(), revisions: Arc::clone(&revisions), }; let first = resolver.resolve("MR-1").unwrap().object.validator; diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 9f30111c6..3b86d0935 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -21,9 +21,9 @@ use wip_protocol::{ use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; @@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip( registry: &mut WipMountRegistry, client: Arc, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("objectives")?; + let collection_route = namespace_route.root().to_string(); let backend = WorkspaceHttpObjectiveBackend::new(client); let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default())); let collection_descriptor = objective_collection_descriptor(); @@ -1775,7 +1775,9 @@ mod tests { })); let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("objective").unwrap(); + let namespace_route = registry + .allocate_namespace("objective", "objectives") + .unwrap(); mount_workspace_http_objective_wip( &mut registry, Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -1783,18 +1785,16 @@ mod tests { "http://backend", )), None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/objective/objectives"] + assert_eq!(registry.routes().collect::>(), ["/objectives"]); + assert_eq!(namespace_route.root(), "/objectives"); + assert!( + registry + .allocate_namespace("objective", "../tickets") + .is_err() ); - assert_eq!( - feature_route.child("objectives").unwrap(), - "/features/objective/objectives" - ); - assert!(feature_route.child("../tickets").is_err()); } #[test] @@ -1805,7 +1805,7 @@ mod tests { crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), )), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let initial = resolver.resolve("O-3").unwrap(); @@ -1971,7 +1971,7 @@ mod tests { let collection = ObjectiveCollectionWipHandler { backend, permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let result = collection @@ -2013,7 +2013,7 @@ mod tests { let collection = ObjectiveCollectionWipHandler { backend: backend.clone(), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let context = || WipCallContext { @@ -2170,7 +2170,7 @@ mod tests { crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), )), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())), }; let output = match handler @@ -2191,10 +2191,7 @@ mod tests { let output = crate::wip::wip_to_json(&output.value).unwrap(); assert_eq!(output["next_cursor"], "next-page"); assert_eq!(output["has_more"], true); - assert_eq!( - output["objectives"][0]["path"], - "/features/objective/objectives/O-3" - ); + assert_eq!(output["objectives"][0]["path"], "/objectives/O-3"); assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7"); assert!(!output.to_string().contains("00001OBJECTIVE")); assert!(!output.to_string().contains("00001TICKET")); @@ -2243,7 +2240,7 @@ mod tests { let resolver = ObjectiveItemResolver { backend: backend.clone(), permissions: None, - collection_route: "/features/objective/objectives".into(), + collection_route: "/objectives".into(), revisions: Arc::clone(&revisions), }; let before = resolver.resolve("O-3").unwrap().object.validator; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 8a82ab6f3..f740ae6d0 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -34,9 +34,9 @@ use crate::feature::{ }; use crate::permission::permission_action_for; use crate::wip::{ - WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute, - WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput, - WipProjection, WipProjectionKind, json_to_wip, wip_to_json, + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError, + WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json, }; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -1377,7 +1377,7 @@ fn native_ticket_tools( let (meta, tool) = definition(); let projection = native_ticket_operation(&meta.name).ok_or_else(|| { WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!( "enabled Ticket tool `{}` has no native projection", meta.name @@ -1386,7 +1386,7 @@ fn native_ticket_tools( })?; jsonschema::validator_for(&meta.input_schema).map_err(|error| { WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!("Ticket tool schema cannot be retained: {error}"), } })?; @@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip( client: Arc, access: TicketFeatureAccess, permissions: Option, - feature_route: &WipFeatureRoute, + namespace_route: &WipNamespaceRoute, ) -> Result<(), WipMountError> { - let collection_route = feature_route.child("tickets")?; + let collection_route = namespace_route.root().to_string(); let tools = native_ticket_tools(client, access)?; let claimed_tools = tools .iter() @@ -1920,7 +1920,7 @@ fn ticket_operation_declaration( .get("properties") .and_then(Value::as_object) .ok_or_else(|| WipMountError::InvalidProjection { - route: "/features/ticket/tickets".into(), + route: "/tickets".into(), message: format!("Ticket tool `{}` schema has no properties", tool.name), })?; let required = tool @@ -2103,7 +2103,7 @@ mod tests { #[test] fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() { let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("ticket").unwrap(); + let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap(); mount_workspace_http_ticket_wip( &mut registry, Arc::new(crate::worker::TestWorkspaceHttpClient::new( @@ -2112,20 +2112,21 @@ mod tests { )), TicketFeatureAccess::review(), None, - &feature_route, + &namespace_route, ) .unwrap(); - assert_eq!( - registry.routes().collect::>(), - ["/features/ticket/tickets"] + assert_eq!(registry.routes().collect::>(), ["/tickets"]); + assert!( + registry + .allocate_namespace("ticket", "../objectives") + .is_err() ); - assert!(feature_route.child("../objectives").is_err()); let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions, }; assert!(resolver.resolve("T-42").is_some()); @@ -2189,7 +2190,7 @@ mod tests { let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions: Arc::clone(&revisions), }; let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator; @@ -2243,7 +2244,7 @@ mod tests { let resolver = TicketItemResolver { tools: HashMap::new(), permissions: None, - collection_route: "/features/ticket/tickets".into(), + collection_route: "/tickets".into(), revisions: Arc::clone(&revisions), }; let target_v1 = resolver.resolve("T-43").unwrap().object.validator; diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index a489cf36e..0e772dafa 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -68,6 +68,14 @@ pub enum WipMountError { RouteCollision { route: String, existing: String }, #[error("WIP interface `{interface}` has conflicting descriptors")] InterfaceCollision { interface: String }, + #[error("WIP operation `{operation}` on `{route}` is already contributed by `{existing}`")] + OperationCollision { + route: String, + operation: String, + existing: String, + }, + #[error("WIP operation contribution target `{route}` is not a mounted Object")] + OperationTargetNotFound { route: String }, #[error("invalid WIP projection for `{route}`: {message}")] InvalidProjection { route: String, message: String }, } @@ -139,9 +147,21 @@ pub struct WipProjection { pub handler: Arc, } -/// One dynamically resolved direct-child object beneath a Feature-owned -/// collection route. The Host retains route allocation and descriptor authority; -/// the Feature only binds a validated child segment to an object and handler. +/// An additional Feature's operations for an Object whose existence and route +/// are already owned by the Host registry. Contributions may extend the mounted +/// interface, but cannot replace Object resolution or another operation. +#[derive(Clone)] +pub struct WipOperationContribution { + pub route: String, + pub contributor: String, + pub interface: String, + pub descriptor: InterfaceDescriptor, + pub handler: Arc, +} + +/// One dynamically resolved direct-child object beneath a Host-owned collection +/// route. Object resolution stays unique; operation contribution is independent +/// for statically mounted Objects and can be extended without remounting them. pub struct WipDynamicItem { pub object: Object, pub handler: Arc, @@ -160,43 +180,89 @@ pub struct WipDynamicMount { pub resolver: Arc, } +pub trait WipDynamicOperationResolver: Send + Sync { + fn handler(&self, item_reference: &str) -> Arc; +} + +pub struct WipDynamicOperationContribution { + pub collection_route: String, + pub contributor: String, + pub interface: String, + pub descriptor: InterfaceDescriptor, + pub resolver: Arc, +} + +struct MountedDynamicOperationContribution { + contributor: String, + operations: Vec, + resolver: Arc, +} + #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WipFeatureRoute { +pub struct WipNamespaceRoute { root: String, } -impl WipFeatureRoute { +impl WipNamespaceRoute { pub fn root(&self) -> &str { &self.root } - - pub fn child(&self, segment: &str) -> Result { - if segment.is_empty() - || segment == "." - || segment == ".." - || segment.contains('/') - || !segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(WipMountError::InvalidRoute { - route: format!("{}/{}", self.root, segment), - message: "Feature child must be one canonical path segment".into(), - }); - } - Ok(format!("{}/{}", self.root, segment)) - } } struct MountedProjection { projection: WipProjection, + operation_handlers: BTreeMap)>, +} + +impl MountedProjection { + fn resolved(&self) -> WipProjection { + let mut projection = self.projection.clone(); + projection.handler = Arc::new(OperationDispatchHandler { + handlers: self + .operation_handlers + .iter() + .map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler))) + .collect(), + }); + projection + } +} + +struct OperationDispatchHandler { + handlers: BTreeMap>, +} + +#[async_trait] +impl WipOperationHandler for OperationDispatchHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let handler = self.handlers.get(operation).ok_or_else(|| { + WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::OperationNotFound, + "operation is not published by the selected interface", + )) + })?; + handler.call(operation, arguments, context).await + } + + async fn cancel(&self, context: &ToolExecutionContext) -> Result<(), ToolError> { + for handler in self.handlers.values() { + handler.cancel(context).await?; + } + Ok(()) + } } #[derive(Default)] pub struct WipMountRegistry { mounts: BTreeMap, dynamic_mounts: Vec, - feature_routes: BTreeSet, + dynamic_operation_contributions: BTreeMap>, + namespaces: BTreeMap, replaced_compatibility_capabilities: BTreeMap, } @@ -205,30 +271,57 @@ impl WipMountRegistry { Self::default() } - /// Allocate the Host namespace owned by one enabled Feature. Features join - /// checked relative segments to this route instead of selecting global paths. - pub fn allocate_feature_route( + /// Allocate one Host-owned root namespace to its unique Object provider. + /// The provider identity is registry metadata, never a public path segment. + pub fn allocate_namespace( &mut self, - feature: &str, - ) -> Result { - if feature.is_empty() - || !feature - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { + owner: &str, + namespace: &str, + ) -> Result { + let valid_segment = |value: &str| { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }; + if !valid_segment(owner) { return Err(WipMountError::InvalidRoute { - route: format!("/features/{feature}"), - message: "Feature name must use lowercase ASCII letters, digits, or '-'".into(), + route: format!("/{namespace}"), + message: "namespace owner must use lowercase ASCII letters, digits, or '-'".into(), }); } - let root = format!("/features/{feature}"); - if !self.feature_routes.insert(root.clone()) { + if !valid_segment(namespace) { + return Err(WipMountError::InvalidRoute { + route: format!("/{namespace}"), + message: "root namespace must be one lowercase ASCII path segment".into(), + }); + } + if matches!(namespace, "features" | "tools") { + return Err(WipMountError::InvalidRoute { + route: format!("/{namespace}"), + message: "the Host reserves this root namespace".into(), + }); + } + let root = format!("/{namespace}"); + if let Some(existing) = self.namespaces.get(&root) { return Err(WipMountError::RouteCollision { route: root, - existing: format!("feature:{feature}"), + existing: format!("namespace owner `{existing}`"), }); } - Ok(WipFeatureRoute { root }) + if self.mounts.contains_key(&root) + || self + .mounts + .keys() + .any(|route| route.starts_with(&format!("{root}/"))) + { + return Err(WipMountError::RouteCollision { + route: root, + existing: "already mounted route".into(), + }); + } + self.namespaces.insert(root.clone(), owner.to_string()); + Ok(WipNamespaceRoute { root }) } pub fn mount( @@ -236,6 +329,30 @@ impl WipMountRegistry { projection: WipProjection, ) -> Result { validate_projection(&projection)?; + if projection.kind == WipProjectionKind::Native + && !projection.route.starts_with(&format!("{WIP_TOOLS_ROOT}/")) + { + let namespace_root = projection + .route + .strip_prefix('/') + .and_then(|path| path.split('/').next()) + .map(|segment| format!("/{segment}")) + .unwrap_or_default(); + let Some(owner) = self.namespaces.get(&namespace_root) else { + return Err(WipMountError::InvalidProjection { + route: projection.route, + message: "native Object route requires an allocated Host namespace".into(), + }); + }; + if projection.capability.split(':').next() != Some(owner.as_str()) { + return Err(WipMountError::InvalidProjection { + route: projection.route, + message: format!( + "native Object capability does not belong to namespace owner `{owner}`" + ), + }); + } + } if self.dynamic_mounts.iter().any(|dynamic| { projection .route @@ -256,8 +373,15 @@ impl WipMountRegistry { match (existing.projection.kind, projection.kind) { (WipProjectionKind::Compatibility, WipProjectionKind::Native) => { self.ensure_interface_available(&projection, Some(&existing.projection.route))?; - self.mounts - .insert(projection.route.clone(), MountedProjection { projection }); + let operation_handlers = + operation_handlers(&projection, &projection.capability); + self.mounts.insert( + projection.route.clone(), + MountedProjection { + projection, + operation_handlers, + }, + ); return Ok(WipMountDisposition::ReplacedCompatibility); } (WipProjectionKind::Native, WipProjectionKind::Compatibility) => { @@ -273,10 +397,99 @@ impl WipMountRegistry { } self.ensure_interface_available(&projection, None)?; let route = projection.route.clone(); - self.mounts.insert(route, MountedProjection { projection }); + let operation_handlers = operation_handlers(&projection, &projection.capability); + self.mounts.insert( + route, + MountedProjection { + projection, + operation_handlers, + }, + ); Ok(WipMountDisposition::Mounted) } + /// Add operations from another Feature without changing Object ownership or + /// resolution. Interface shape and operation names are global protocol + /// contracts, so incompatible declarations fail at registration time. + pub fn contribute_operations( + &mut self, + contribution: WipOperationContribution, + ) -> Result<(), WipMountError> { + contribution + .descriptor + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.route.clone(), + message: error.to_string(), + })?; + let Some(mounted) = self.mounts.get(&contribution.route) else { + return Err(WipMountError::OperationTargetNotFound { + route: contribution.route, + }); + }; + if mounted.projection.interface != contribution.interface { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + if !same_interface_shape(&mounted.projection.descriptor, &contribution.descriptor) { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + for operation in &contribution.descriptor.operations { + if let Some((existing, _)) = mounted.operation_handlers.get(&operation.name) { + return Err(WipMountError::OperationCollision { + route: contribution.route, + operation: operation.name.clone(), + existing: existing.clone(), + }); + } + } + + let mut merged = mounted.projection.descriptor.clone(); + merged + .operations + .extend(contribution.descriptor.operations.iter().cloned()); + merged + .operations + .sort_by(|left, right| left.name.cmp(&right.name)); + merged + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.route.clone(), + message: error.to_string(), + })?; + if self.interface_conflicts( + &contribution.interface, + &merged, + Some(&descriptor_validator(&merged)), + Some(&contribution.route), + ) { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + + let mounted = self + .mounts + .get_mut(&contribution.route) + .expect("operation contribution target was checked"); + for operation in contribution.descriptor.operations { + mounted.operation_handlers.insert( + operation.name, + ( + contribution.contributor.clone(), + Arc::clone(&contribution.handler), + ), + ); + } + mounted.projection.descriptor = merged; + mounted.projection.interface_validator = + Some(descriptor_validator(&mounted.projection.descriptor)); + Ok(()) + } + /// Mount a one-segment item family below an already mounted collection. /// Dynamic resolution never creates authority: the collection route must be /// native and every resolved child is validated before it is exposed. @@ -344,6 +557,128 @@ impl WipMountRegistry { Ok(()) } + /// Add operations to every item resolved by one existing dynamic Object + /// family. The family keeps exactly one Object resolver; each Feature only + /// supplies handlers for its own disjoint operation names. + pub fn contribute_dynamic_operations( + &mut self, + contribution: WipDynamicOperationContribution, + ) -> Result<(), WipMountError> { + contribution + .descriptor + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.collection_route.clone(), + message: error.to_string(), + })?; + let Some(index) = self + .dynamic_mounts + .iter() + .position(|mount| mount.collection_route == contribution.collection_route) + else { + return Err(WipMountError::OperationTargetNotFound { + route: contribution.collection_route, + }); + }; + let mounted = &self.dynamic_mounts[index]; + if mounted.interface != contribution.interface + || !same_interface_shape(&mounted.descriptor, &contribution.descriptor) + { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + for operation in &contribution.descriptor.operations { + if mounted + .descriptor + .operations + .iter() + .any(|existing| existing.name == operation.name) + { + let existing = self + .dynamic_operation_contributions + .get(&contribution.collection_route) + .and_then(|contributions| { + contributions.iter().find(|existing| { + existing + .operations + .iter() + .any(|name| name == &operation.name) + }) + }) + .map(|existing| existing.contributor.clone()) + .unwrap_or_else(|| mounted.capability.clone()); + return Err(WipMountError::OperationCollision { + route: contribution.collection_route, + operation: operation.name.clone(), + existing, + }); + } + } + let mut merged = mounted.descriptor.clone(); + merged + .operations + .extend(contribution.descriptor.operations.iter().cloned()); + merged + .operations + .sort_by(|left, right| left.name.cmp(&right.name)); + merged + .validate() + .map_err(|error| WipMountError::InvalidProjection { + route: contribution.collection_route.clone(), + message: error.to_string(), + })?; + let validator = descriptor_validator(&merged); + let conflicts_static = self.registry_interface_conflicts_static( + &contribution.interface, + &merged, + Some(&validator), + ); + let conflicts_dynamic = self.dynamic_mounts.iter().any(|other| { + other.collection_route != contribution.collection_route + && other.interface == contribution.interface + && (other.descriptor != merged + || other.interface_validator.as_deref() != Some(validator.as_slice())) + }); + if conflicts_static || conflicts_dynamic { + return Err(WipMountError::InterfaceCollision { + interface: contribution.interface, + }); + } + + let operations = contribution + .descriptor + .operations + .into_iter() + .map(|operation| operation.name) + .collect(); + let mounted = &mut self.dynamic_mounts[index]; + mounted.descriptor = merged; + mounted.interface_validator = Some(validator); + self.dynamic_operation_contributions + .entry(contribution.collection_route) + .or_default() + .push(MountedDynamicOperationContribution { + contributor: contribution.contributor, + operations, + resolver: contribution.resolver, + }); + Ok(()) + } + + fn registry_interface_conflicts_static( + &self, + interface: &str, + descriptor: &InterfaceDescriptor, + validator: Option<&[u8]>, + ) -> bool { + self.mounts.values().any(|mounted| { + mounted.projection.interface == interface + && (mounted.projection.descriptor != *descriptor + || mounted.projection.interface_validator.as_deref() != validator) + }) + } + /// Declare ordinary tool capabilities fully represented by native mounts. /// Claimed compatibility projections are removed from WIP mode while the /// normal Tool-mode registration remains unchanged. @@ -433,6 +768,35 @@ impl WipMountRegistry { } } +fn operation_handlers( + projection: &WipProjection, + contributor: &str, +) -> BTreeMap)> { + projection + .descriptor + .operations + .iter() + .map(|operation| { + ( + operation.name.clone(), + (contributor.to_string(), Arc::clone(&projection.handler)), + ) + }) + .collect() +} + +fn same_interface_shape(left: &InterfaceDescriptor, right: &InterfaceDescriptor) -> bool { + left.format == right.format + && left.documentation == right.documentation + && left.types == right.types +} + +fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec { + let mut digest = Sha256::new(); + digest.update(format!("{descriptor:?}").as_bytes()); + digest.finalize().to_vec() +} + fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError> { wip_protocol::validate_path(&projection.route).map_err(|error| { WipMountError::InvalidRoute { @@ -508,7 +872,7 @@ impl WipHost { fn projection(&self, path: &str) -> Option { if let Some(mounted) = self.registry.mounts.get(path) { - return Some(mounted.projection.clone()); + return Some(mounted.resolved()); } self.registry.dynamic_mounts.iter().find_map(|mounted| { let item_reference = path.strip_prefix(&format!("{}/", mounted.collection_route))?; @@ -516,6 +880,28 @@ impl WipHost { return None; } let item = mounted.resolver.resolve(item_reference)?; + let contributions = self + .registry + .dynamic_operation_contributions + .get(&mounted.collection_route); + let contributed_names = contributions + .into_iter() + .flatten() + .flat_map(|contribution| contribution.operations.iter()) + .collect::>(); + let mut handlers = mounted + .descriptor + .operations + .iter() + .filter(|operation| !contributed_names.contains(&operation.name)) + .map(|operation| (operation.name.clone(), Arc::clone(&item.handler))) + .collect::>(); + for contribution in contributions.into_iter().flatten() { + let handler = contribution.resolver.handler(item_reference); + for operation in &contribution.operations { + handlers.insert(operation.clone(), Arc::clone(&handler)); + } + } let projection = WipProjection { route: path.to_string(), capability: mounted.capability.clone(), @@ -524,7 +910,7 @@ impl WipHost { interface: mounted.interface.clone(), descriptor: mounted.descriptor.clone(), interface_validator: mounted.interface_validator.clone(), - handler: item.handler, + handler: Arc::new(OperationDispatchHandler { handlers }), }; validate_projection(&projection).ok()?; Some(projection) @@ -590,9 +976,9 @@ impl WipHost { } fn observe(&self, path: &str, depth: u32) -> Result { - let object = self.object_at(path).ok_or_else(|| { - protocol_error(ProtocolErrorCode::NotFound, "target path is not published") - })?; + let object = self + .object_at(path) + .ok_or_else(|| unpublished_path_error(path))?; let children = if depth == 0 { None } else { @@ -625,10 +1011,7 @@ impl WipHost { context: WipCallContext, ) -> Result { let projection = self.projection(&request.target.path).ok_or_else(|| { - WipOperationError::Protocol(protocol_error( - ProtocolErrorCode::NotFound, - "target path is not published", - )) + WipOperationError::Protocol(unpublished_path_error(&request.target.path)) })?; let expected_object_validator = projection.object.validator.as_deref(); match ( @@ -707,6 +1090,15 @@ impl WipHost { } } +fn unpublished_path_error(path: &str) -> ProtocolError { + let message = if path == "/features" || path.starts_with("/features/") { + "legacy /features/... routes are no longer published; rediscover from `/` and use the root namespace" + } else { + "target path is not published" + }; + protocol_error(ProtocolErrorCode::NotFound, message) +} + fn protocol_error(code: ProtocolErrorCode, message: impl Into) -> ProtocolError { ProtocolError { code, @@ -1139,9 +1531,8 @@ impl WipRuntime { ) .map_err(ToolError::InvalidArgument)?, None => { - return Err(ToolError::InvalidArgument(format!( - "WIP target `{path}` is not published" - ))); + let error = unpublished_path_error(&path); + return Err(ToolError::InvalidArgument(error.message)); } }; let prepared = { @@ -1956,7 +2347,7 @@ mod tests { use agen::llm_client::{ClientError, Request as LlmRequest, ResponseStream}; use futures::stream; use std::collections::VecDeque; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use wip_protocol::{EnumCase, FieldDeclaration, TypeDeclaration, UnionCase}; struct EchoTool { @@ -2184,6 +2575,259 @@ mod tests { )); } + struct ContributionHandler { + calls: Arc, + allowed: Arc, + } + + #[async_trait] + impl WipOperationHandler for ContributionHandler { + async fn call( + &self, + _operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + if !self.allowed.load(Ordering::SeqCst) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::PermissionDenied, + "current subject is not authorized", + ))); + } + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::Unit)) + } + } + + fn contributed_operation(name: &str) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: documentation(name), + parameters: Vec::new(), + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + } + } + + fn contribution_projection(handler: Arc) -> WipProjection { + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("shared object interface"), + types: Vec::new(), + operations: vec![contributed_operation("read")], + }; + WipProjection { + route: "/assets/A-1".into(), + capability: "asset:object".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "A-1".into(), + description: Some("host-resolved asset".into()), + interfaces: vec!["test.asset/item/v1".into()], + r#ref: Some("asset:A-1".into()), + validator: Some(vec![1]), + }, + interface: "test.asset/item/v1".into(), + interface_validator: Some(descriptor_validator(&descriptor)), + descriptor, + handler, + } + } + + #[tokio::test] + async fn host_namespace_object_resolution_and_operation_contributions_are_independent() { + let read_calls = Arc::new(AtomicUsize::new(0)); + let manage_calls = Arc::new(AtomicUsize::new(0)); + let read_allowed = Arc::new(AtomicBool::new(true)); + let manage_allowed = Arc::new(AtomicBool::new(false)); + let mut registry = WipMountRegistry::new(); + assert!(matches!( + registry.allocate_namespace("host", "features"), + Err(WipMountError::InvalidRoute { .. }) + )); + assert!(matches!( + registry.allocate_namespace("host", "tools"), + Err(WipMountError::InvalidRoute { .. }) + )); + let namespace = registry.allocate_namespace("asset", "assets").unwrap(); + assert_eq!(namespace.root(), "/assets"); + assert!(matches!( + registry.allocate_namespace("other", "assets"), + Err(WipMountError::RouteCollision { .. }) + )); + registry + .mount(contribution_projection(Arc::new(ContributionHandler { + calls: Arc::clone(&read_calls), + allowed: Arc::clone(&read_allowed), + }))) + .unwrap(); + + let host_without_management = WipHost::new(registry); + let resolved = host_without_management.projection("/assets/A-1").unwrap(); + assert_eq!(resolved.object.r#ref.as_deref(), Some("asset:A-1")); + assert_eq!( + resolved + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["read"] + ); + + let mut registry = host_without_management.registry; + let contribution_descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("shared object interface"), + types: Vec::new(), + operations: vec![contributed_operation("manage")], + }; + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "asset-management".into(), + interface: "test.asset/item/v1".into(), + descriptor: contribution_descriptor.clone(), + handler: Arc::new(ContributionHandler { + calls: Arc::clone(&manage_calls), + allowed: Arc::clone(&manage_allowed), + }), + }) + .unwrap(); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "duplicate-management".into(), + interface: "test.asset/item/v1".into(), + descriptor: contribution_descriptor, + handler: Arc::new(ContributionHandler { + calls: Arc::clone(&manage_calls), + allowed: Arc::clone(&manage_allowed), + }), + }), + Err(WipMountError::OperationCollision { .. }) + )); + let host = WipHost::new(registry); + let projection = host.projection("/assets/A-1").unwrap(); + assert_eq!( + projection + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["manage", "read"] + ); + + let request = |operation: &str| CallOperationRequest { + target: wip_protocol::Target { + path: "/assets/A-1".into(), + validator: projection.object.validator.clone(), + }, + interface: wip_protocol::InterfaceTarget { + reference: projection.interface.clone(), + validator: projection.interface_validator.clone(), + }, + operation: operation.into(), + arguments: BTreeMap::new(), + }; + let context = || WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + }; + assert!(matches!( + host.call(request("manage"), context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + manage_allowed.store(true, Ordering::SeqCst); + host.call(request("manage"), context()) + .await + .unwrap_or_else(|_| panic!("authorized management contribution should run")); + manage_allowed.store(false, Ordering::SeqCst); + assert!(matches!( + host.call(request("manage"), context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + host.call(request("read"), context()) + .await + .unwrap_or_else(|_| panic!("independent read contribution should run")); + assert_eq!(manage_calls.load(Ordering::SeqCst), 1); + assert_eq!(read_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn operation_contributions_reject_interface_and_target_conflicts() { + let calls = Arc::new(AtomicUsize::new(0)); + let allowed = Arc::new(AtomicBool::new(true)); + let handler: Arc = + Arc::new(ContributionHandler { calls, allowed }); + let mut registry = WipMountRegistry::new(); + let projection = contribution_projection(Arc::clone(&handler)); + assert!(matches!( + registry.mount(projection.clone()), + Err(WipMountError::InvalidProjection { .. }) + )); + registry.allocate_namespace("asset", "assets").unwrap(); + registry.mount(projection).unwrap(); + let mut inconsistent = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: documentation("different interface contract"), + types: Vec::new(), + operations: vec![contributed_operation("manage")], + }; + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor: inconsistent.clone(), + handler: Arc::clone(&handler), + }), + Err(WipMountError::InterfaceCollision { .. }) + )); + inconsistent.documentation = documentation("shared object interface"); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/assets/A-2".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor: inconsistent, + handler, + }), + Err(WipMountError::OperationTargetNotFound { .. }) + )); + } + + #[tokio::test] + async fn legacy_feature_routes_fail_with_rediscovery_guidance() { + let host = WipHost::new(WipMountRegistry::new()); + let error = host.observe("/features/ticket/tickets/T-1", 0).unwrap_err(); + assert_eq!(error.code, ProtocolErrorCode::NotFound); + assert!(error.message.contains("rediscover from `/`")); + assert!(error.message.contains("no longer published")); + + let runtime = WipRuntime::new(host, SecurityContext::new("worker-a"), 0).unwrap(); + let error = runtime + .call( + "/features/ticket/tickets/T-1".into(), + "yoi.ticket/item/v1".into(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("rediscover from `/`")); + } + struct NativeTyped; #[async_trait] @@ -2338,6 +2982,7 @@ mod tests { #[tokio::test] async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() { let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("native", "native").unwrap(); registry.mount(native_typed_projection()).unwrap(); let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); @@ -2600,10 +3245,26 @@ mod tests { } } + struct DynamicContributionResolver { + calls: Arc, + allowed: Arc, + } + + impl WipDynamicOperationResolver for DynamicContributionResolver { + fn handler(&self, _item_reference: &str) -> Arc { + Arc::new(ContributionHandler { + calls: Arc::clone(&self.calls), + allowed: Arc::clone(&self.allowed), + }) + } + } + fn dynamic_registry(revision: Arc, calls: Arc) -> WipMountRegistry { let mut registry = WipMountRegistry::new(); - let feature = registry.allocate_feature_route("objective").unwrap(); - let collection_route = feature.child("objectives").unwrap(); + let namespace = registry + .allocate_namespace("objective", "objectives") + .unwrap(); + let collection_route = namespace.root().to_string(); let mut collection = compatibility_projection( meta("Dynamic"), Arc::new(EchoTool { @@ -2616,8 +3277,8 @@ mod tests { collection.capability = "objective:collection".into(); collection.kind = WipProjectionKind::Native; collection.object.name = "objectives".into(); - collection.object.interfaces = vec!["test.dynamic/v1".into()]; - collection.interface = "test.dynamic/v1".into(); + collection.object.interfaces = vec!["test.dynamic.collection/v1".into()]; + collection.interface = "test.dynamic.collection/v1".into(); collection.handler = Arc::new(DynamicHandler { calls: Arc::clone(&calls), }); @@ -2642,17 +3303,9 @@ mod tests { let revision = Arc::new(AtomicUsize::new(1)); let calls = Arc::new(AtomicUsize::new(0)); let host = WipHost::new(dynamic_registry(Arc::clone(&revision), Arc::clone(&calls))); - let initial = host - .projection("/features/objective/objectives/O-3") - .unwrap(); - assert!( - host.projection("/features/objective/objectives/O-3/other") - .is_none() - ); - assert!( - host.projection("/features/objective/objectives/T-3") - .is_none() - ); + let initial = host.projection("/objectives/O-3").unwrap(); + assert!(host.projection("/objectives/O-3/other").is_none()); + assert!(host.projection("/objectives/T-3").is_none()); assert!(host.projection("/hidden/objectives/O-3").is_none()); revision.store(2, Ordering::SeqCst); @@ -2660,7 +3313,7 @@ mod tests { .call( CallOperationRequest { target: wip_protocol::Target { - path: "/features/objective/objectives/O-3".into(), + path: "/objectives/O-3".into(), validator: initial.object.validator, }, interface: wip_protocol::InterfaceTarget { @@ -2689,6 +3342,65 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn dynamic_object_resolver_accepts_disjoint_feature_operations() { + let revision = Arc::new(AtomicUsize::new(1)); + let base_calls = Arc::new(AtomicUsize::new(0)); + let contribution_calls = Arc::new(AtomicUsize::new(0)); + let allowed = Arc::new(AtomicBool::new(true)); + let mut registry = dynamic_registry(revision, base_calls); + let mounted = registry + .dynamic_mounts + .first() + .expect("dynamic object family is mounted"); + let mut descriptor = mounted.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry + .contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: "/objectives".into(), + contributor: "objective-management".into(), + interface: "test.dynamic/v1".into(), + descriptor, + resolver: Arc::new(DynamicContributionResolver { + calls: Arc::clone(&contribution_calls), + allowed, + }), + }) + .unwrap(); + let host = WipHost::new(registry); + let projection = host.projection("/objectives/O-7").unwrap(); + assert_eq!( + projection + .descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect::>(), + ["call", "manage"] + ); + host.call( + CallOperationRequest { + target: wip_protocol::Target { + path: "/objectives/O-7".into(), + validator: projection.object.validator, + }, + interface: wip_protocol::InterfaceTarget { + reference: projection.interface, + validator: projection.interface_validator, + }, + operation: "manage".into(), + arguments: BTreeMap::new(), + }, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker-a".into(), + }, + ) + .await + .unwrap_or_else(|_| panic!("dynamic contributed operation should run")); + assert_eq!(contribution_calls.load(Ordering::SeqCst), 1); + } + #[test] fn native_capability_claim_hides_only_claimed_compatibility_route() { let calls = Arc::new(AtomicUsize::new(0)); @@ -2699,18 +3411,13 @@ mod tests { let revision = Arc::new(AtomicUsize::new(1)); let mut registry = dynamic_registry(revision, calls); registry - .replace_compatibility_tools("/features/objective/objectives", ["Echo"]) + .replace_compatibility_tools("/objectives", ["Echo"]) .unwrap(); let runtime = install_wip_mode_with_mounts(&mut engine, None, "worker-a".into(), registry).unwrap(); assert!(runtime.host.projection("/tools/Echo").is_none()); assert!(runtime.host.projection("/tools/Other").is_some()); - assert!( - runtime - .host - .projection("/features/objective/objectives") - .is_some() - ); + assert!(runtime.host.projection("/objectives").is_some()); } #[tokio::test] @@ -2752,7 +3459,9 @@ mod tests { authoring_engine.register_tool(definition); } let mut authoring_registry = WipMountRegistry::new(); - let route = authoring_registry.allocate_feature_route("ticket").unwrap(); + let route = authoring_registry + .allocate_namespace("ticket", "tickets") + .unwrap(); crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( &mut authoring_registry, authoring_client.clone(), @@ -2771,14 +3480,15 @@ mod tests { let discovered = authoring.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/ticket/tickets")); + assert!(discovered.contains("/tickets")); + assert!(!discovered.contains("/features")); assert!(!discovered.contains("/tools/QueryTicket")); assert!(!discovered.contains("/tools/TicketCreate")); authoring .inspect("yoi.ticket/collection/v1".into(), false) .await .unwrap(); - let collection = "/features/ticket/tickets"; + let collection = "/tickets"; authoring .call( collection.into(), @@ -2799,13 +3509,8 @@ mod tests { ) .await .unwrap(); - assert!( - created - .content - .unwrap() - .contains("/features/ticket/tickets/T-9") - ); - let item = "/features/ticket/tickets/T-9"; + assert!(created.content.unwrap().contains("/tickets/T-9")); + let item = "/tickets/T-9"; authoring.discover(item.into(), 0, false).await.unwrap(); authoring .inspect("yoi.ticket/item/v1".into(), false) @@ -2862,7 +3567,9 @@ mod tests { workflow_engine.register_tool(definition); } let mut workflow_registry = WipMountRegistry::new(); - let route = workflow_registry.allocate_feature_route("ticket").unwrap(); + let route = workflow_registry + .allocate_namespace("ticket", "tickets") + .unwrap(); crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( &mut workflow_registry, workflow_client.clone(), @@ -2963,7 +3670,7 @@ mod tests { } let mut coder_registry = WipMountRegistry::new(); let route = coder_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut coder_registry, @@ -2982,14 +3689,14 @@ mod tests { .unwrap(); let discovered = coder.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/merge-request/merge-requests")); + assert!(discovered.contains("/merge-requests")); assert!(!discovered.contains("/tools/OpenMergeRequest")); assert!(!discovered.contains("/tools/ShowMergeRequest")); coder .inspect("yoi.merge-request/collection/v1".into(), false) .await .unwrap(); - let collection = "/features/merge-request/merge-requests"; + let collection = "/merge-requests"; let opened = coder .call( collection.into(), @@ -3085,7 +3792,7 @@ mod tests { } let mut reviewer_registry = WipMountRegistry::new(); let route = reviewer_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut reviewer_registry, @@ -3174,7 +3881,7 @@ mod tests { } let mut orchestrator_registry = WipMountRegistry::new(); let route = orchestrator_registry - .allocate_feature_route("merge-request") + .allocate_namespace("merge-request", "merge-requests") .unwrap(); crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( &mut orchestrator_registry, @@ -3348,12 +4055,14 @@ mod tests { }], }); let mut registry = WipMountRegistry::new(); - let feature_route = registry.allocate_feature_route("objective").unwrap(); + let namespace_route = registry + .allocate_namespace("objective", "objectives") + .unwrap(); crate::feature::builtin::objective::mount_workspace_http_objective_wip( &mut registry, client.clone(), permissions.clone(), - &feature_route, + &namespace_route, ) .unwrap(); let runtime = @@ -3362,13 +4071,13 @@ mod tests { let discovered = runtime.discover("/".into(), 3, false).await.unwrap(); let discovered = discovered.content.unwrap(); - assert!(discovered.contains("/features/objective/objectives")); + assert!(discovered.contains("/objectives")); assert!(!discovered.contains("/tools/QueryObjective")); runtime .inspect("yoi.objective/collection/v1".into(), false) .await .unwrap(); - let collection_path = "/features/objective/objectives"; + let collection_path = "/objectives"; runtime .call( collection_path.into(), @@ -3401,14 +4110,9 @@ mod tests { ) .await .unwrap(); - assert!( - created - .content - .unwrap() - .contains("/features/objective/objectives/O-3") - ); + assert!(created.content.unwrap().contains("/objectives/O-3")); - let item_path = "/features/objective/objectives/O-3"; + let item_path = "/objectives/O-3"; runtime.discover(item_path.into(), 0, false).await.unwrap(); runtime .inspect("yoi.objective/item/v1".into(), false) diff --git a/docs/README.md b/docs/README.md index 91da1a8d8..3dbc518d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,8 @@ It is not a dumping ground for external research, old plans, API inventories, or 27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。 28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts. 29. [`design/objective-wip-projection.md`](design/objective-wip-projection.md) — Host-assigned Objective collection/item routes, operation mapping, Backend authority, bounds, and compatibility suppression. +30. [`design/ticket-wip-projection.md`](design/ticket-wip-projection.md) — Host-assigned Ticket collection/item routes, role-specific operations, route identity, and typed Backend preservation. +31. [`design/merge-request-wip-projection.md`](design/merge-request-wip-projection.md) — Host-assigned Merge Request routes and preserved review/integration authority gates. ## What belongs here diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index 3879b8054..4d314a7a2 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and its Merge Request Feature has at least one enabled operation, the Worker Host projects that exact operation set as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Merge Request tools and Backend routes. It does not create a second Merge Request store, provider integration path, review grant, approval rule, or completion authority. -The Host allocates `/features/merge-request` and the Feature mounts: +The Host assigns the Merge Request provider the root `merge-requests` namespace through `WipMountRegistry` and mounts: ```text -/features/merge-request/merge-requests -/features/merge-request/merge-requests/ +/merge-requests +/merge-requests/ ``` +There is no Feature Object or `/features/merge-request` compatibility alias. Legacy paths remain historical display data only and fail as current input with rediscovery guidance. + An item identity is a single canonical path segment. Nested paths and percent-encoded target substitution do not resolve. Item operations remove `merge_request_id` from their descriptor and reconstruct it only from the selected object route. Discovery does not mint authority: every call still uses the Worker's scoped `WorkspaceClient`, the enabled Feature flags, ordinary Tool permission rules, and the Backend's Workspace, repository, assignment, provider-ref, review, and integration checks. ## Current Feature inventory diff --git a/docs/design/objective-wip-projection.md b/docs/design/objective-wip-projection.md index a9316e5cc..20c0fe6e3 100644 --- a/docs/design/objective-wip-projection.md +++ b/docs/design/objective-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and the Objective Feature is enabled, the Worker Host projects Objectives as native Worldspace objects. The projection is an adapter over the existing scoped Workspace API. It does not introduce another Objective store, state machine, reference resolver, or audit path. -The Host allocates `/features/objective` to the Feature through `WipMountRegistry`. The Objective Feature then mounts: +The Host assigns the Objective provider the root `objectives` namespace through `WipMountRegistry` and mounts: ```text -/features/objective/objectives -/features/objective/objectives/ +/objectives +/objectives/ ``` +There is no Feature Object or `/features/objective` compatibility alias. Legacy paths are retained only as historical text and fail as current inputs with rediscovery guidance. + `` is either a canonical `O-` key or an existing canonical internal alphanumeric ID. Only one direct child segment is accepted. Nested suffixes, Ticket keys, and unrelated or hidden prefixes do not resolve as Objective objects. The Backend remains authoritative for existence, `O-*`/internal-ID resolution, Workspace scope, validation, audit events, revision generation, pagination, body/event bounds, and linked-Ticket validation. A discovered route is not independent authority; every operation still uses the Worker's current `WorkspaceClient`. diff --git a/docs/design/ticket-wip-projection.md b/docs/design/ticket-wip-projection.md index 34f021108..6a76194c2 100644 --- a/docs/design/ticket-wip-projection.md +++ b/docs/design/ticket-wip-projection.md @@ -4,13 +4,15 @@ When a Worker explicitly runs in `wip` mode and its Ticket Feature is enabled, the Worker Host projects the enabled Ticket surface as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Ticket API and typed Ticket tools. It does not add a Ticket store, resolver, audit path, state machine, readiness rule, queue path, or relation implementation. -The Host allocates `/features/ticket` and the Feature mounts: +The Host assigns the Ticket provider the root `tickets` namespace through `WipMountRegistry` and mounts: ```text -/features/ticket/tickets -/features/ticket/tickets/ +/tickets +/tickets/ ``` +There is no Feature Object or `/features/ticket` compatibility alias. A legacy path supplied as current input fails with rediscovery guidance; saved history containing that text is left unchanged. + `` is either a canonical `T-` key or an existing canonical internal alphanumeric ID. Only a direct child is accepted. Nested suffixes, Objective keys, unrelated prefixes, and hidden routes do not resolve. Every call retains the Worker's scoped `WorkspaceClient`, current Ticket Feature access, and ordinary Tool permission policy. Discovery is not mutation authority. A direct call to an unpublished operation fails even when the caller knows its name. `deny` and interactive `ask` permission outcomes fail closed before Backend dispatch. diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index 012a5d768..6c7a472c2 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -52,21 +52,36 @@ The interface descriptor uses WIP `Json` for compatibility input and includes th ## Native projection extension and collisions -`worker::wip::WipMountRegistry` is the Host-owned mount allocator. A Feature can independently construct a `WipProjection` with an async `WipOperationHandler`, then pass its aggregate registry through `install_wip_mode_with_mounts` before compatibility finalization. A Feature first requests a Host-owned `/features/` namespace with `allocate_feature_route`; checked direct-child joins prevent a Feature from claiming an ambient global route. +`worker::wip::WipMountRegistry` is the Host-owned namespace, Object, and operation registry. A provider requests a root namespace with `allocate_namespace(owner, namespace)`; the owner is retained as Host metadata and is not inserted into the public path. It then mounts the collection Object directly at `/` and may install a bounded direct-child resolver. Features do not appear as intermediate Objects. -- Routes must be canonical absolute WIP paths. +Object resolution and operations are separate contracts. One mounted `WipProjection` owns a static Object route and its initial operations. Another Feature may add operations with `contribute_operations` without replacing that Object. Dynamic families likewise keep one `WipDynamicItemResolver`, while `contribute_dynamic_operations` supplies disjoint handlers for every resolved item without a second resolver. The Host merges only contributions with the same interface format, documentation, and type declarations; duplicate operation names, inconsistent interfaces/validators, missing targets, duplicate namespace owners, and unrelated route claims fail deterministically at registration. The merged interface validator changes with the effective operation set, so stale Known Space cannot authorize a newly contributed operation. + +- Routes must be canonical absolute WIP paths, and allocated namespaces are one lowercase root segment. - Object names must match the final route segment. -- A projected object exposes exactly its declared interface. -- Unrelated duplicate routes and conflicting interface descriptors or validators fail startup. +- Object existence and resolution have one owner; operation contributors cannot remount or substitute the Object. +- Multiple Features may contribute disjoint operations to the same Object interface. +- Unrelated duplicate routes, duplicate operation names, and conflicting interface shapes fail startup. - A bounded dynamic mount may resolve exactly one item segment below an already mounted native collection; deeper paths never fall through to the resolver. - A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route. - Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry. -`WipInspect` renders the complete descriptor, including descriptor-local named declarations and recursive record, list, enum, and union shapes with required flags and declaration documentation. The unit samples `mount_collision_and_native_replacement_are_explicit` and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate independent native registration, deterministic replacement, collision rejection, complete descriptor inspection, and a descriptor-typed native invocation. +`WipInspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation. + +The built-in native route migration is: + +| Historical route | Canonical route | +| --- | --- | +| `/features/ticket/tickets[/]` | `/tickets[/]` | +| `/features/objective/objectives[/]` | `/objectives[/]` | +| `/features/merge-request/merge-requests[/]` | `/merge-requests[/]` | + +`/repositories` and `/workdirs` are reserved for a later provider using the same registration contract; this layer does not implement those domains. `/checkouts` content projection is likewise outside this contract. ## Authority and execution -Discovery is not authority. The Worldspace contains only the tools already registered from enabled Features and the Worker's current execution context. Calling a compatibility operation: +Discovery is not authority. Object publication, operation implementation, target capability, and subject permission are independent checks. An Object may remain discoverable through an enabled read provider when a management Feature contributes no operations; conversely, publishing an operation never bypasses the target's current capability or the subject's current permission. Every handler re-evaluates permission at call time, so revocation after discovery is rejected before provider dispatch. Feature enablement selects implementations and does not itself grant read or mutation authority. + +The Worldspace contains only the operations registered from enabled Features and the Worker's current execution context. Calling a compatibility operation: 1. resolves the exact mounted route; 2. verifies object and interface membership and validators; @@ -85,6 +100,8 @@ The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions a Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority. +Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features//...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel. + ## Errors, cancellation, and retries The adapter keeps these outcomes distinct: From 0c644c13e5651e82a6864207265be80a952c5f9e Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 15:43:06 +0900 Subject: [PATCH 12/24] fix: preserve failed reviewer handoff evidence --- ...05-reviewer-subworker-stopped-before-run.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md diff --git a/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md b/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md new file mode 100644 index 000000000..38f6fd395 --- /dev/null +++ b/docs/report/2026-10-05-reviewer-subworker-stopped-before-run.md @@ -0,0 +1,18 @@ +# Reviewer SubWorkerがreview開始前に停止する + +## 状況 + +T-698の公開済みMerge Requestに対して、`builtin:reviewer`を指定し、対象Ticket/Merge Request、writable delegated scope、command grantを明示してReviewer SubWorkerを3回起動した。 + +いずれも`review_requested` eventは正しいsource refとTicket revisionを記録したが、SubWorkerは直後に`Stopped`となった。session observationには最初のuser taskだけが存在し、assistant output、tool call、typed review verdictは記録されなかった。Merge Request threadにもapprove/request_changesは追加されなかった。 + +## 障壁 + +CoderはReviewer approvalを代行できず、親Workerには`ReviewMergeRequest` authorityがないため、実装・公開・検証が完了していてもfresh approvalを取得できない。同じ入力を再送するだけでは診断情報が増えず、review requestだけが複数残る。 + +## 改善案 + +- Reviewer起動がmodel run前に停止した場合、停止理由を親へ構造化して通知する。 +- `review_requested`を記録した後にReviewer sessionの開始に失敗した場合、request eventと失敗eventを関連付け、未解決reviewとして明示する。 +- profile/provider初期化、review worktree準備、capability bindingのどの段階で失敗したかを、credentialやhost pathを露出しないbounded diagnosticとして提供する。 +- 同一sourceへの再試行時は、先行Reviewerが一度も実行されていないことを識別できるようにする。 From 8891236db348ff35c06b735f34e53e6c38b0ced5 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 16:27:23 +0900 Subject: [PATCH 13/24] Add persistent Subject behavior context --- crates/server-api/src/lib.rs | 53 ++- crates/session-store/src/system_item.rs | 30 ++ crates/tui/src/app.rs | 1 + crates/worker/src/controller.rs | 1 + crates/worker/src/feature/builtin/memory.rs | 415 +++++++++++++++--- crates/worker/src/hook.rs | 14 + crates/worker/src/ipc/interceptor.rs | 160 ++++++- crates/worker/src/prompt/catalog.rs | 18 + crates/worker/src/worker.rs | 62 ++- crates/workspace-server/src/server.rs | 245 ++++++++++- crates/workspace-server/src/subjektiv.rs | 374 +++++++++++++--- openapi/server-api.json | 153 ++++++- resources/prompts/catalog.dcdl | 2 + .../resident_memory_restore_section.md | 4 +- .../internal/subjektiv_resident_context.md | 9 + .../memory_document_fixture_server.ts | 51 ++- .../memory_document_shell_test.ts | 53 ++- .../src/lib/generated/companion-api.ts | 2 +- web/workspace/src/lib/generated/memory-api.ts | 10 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- web/workspace/src/lib/workspace/memory/api.ts | 112 ++++- .../w/[workspaceId]/memory/+page.svelte | 62 ++- .../memory/[subjectId]/+page.svelte | 175 +++++++- .../memory/memory-page.browser.spec.ts | 98 ++++- web/workspace/tests/memory-api.test.ts | 77 +++- 28 files changed, 1999 insertions(+), 190 deletions(-) create mode 100644 resources/prompts/internal/subjektiv_resident_context.md diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index a100ca901..ee8521c16 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -237,6 +237,7 @@ impl_openapi_schema!( MemoryConsolidateStagingRequest, MemoryConsolidationResponse, SubjektivSubjectCreateRequest, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -1163,6 +1164,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[path] subject_id: String, ) -> Result; + #[patch( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_behavior_update( + &self, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: SubjektivSubjectBehaviorUpdateRequest, + ) -> Result; #[get( "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface", status = 200, @@ -4579,9 +4594,20 @@ pub enum SubjektivSubjectState { #[serde(deny_unknown_fields)] pub struct SubjektivSubjectCreateRequest { pub role: String, + #[serde(default)] + pub behavior_md: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSubjectBehaviorUpdateRequest { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub expected_behavior_revision: u64, + pub behavior_md: String, } pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100; +pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -4609,6 +4635,9 @@ pub struct SubjektivSubjectListResponse { pub struct SubjektivSubjectResponse { pub id: String, pub role: String, + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, pub state: SubjektivSubjectState, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub store_revision: u64, @@ -5442,10 +5471,22 @@ pub struct SubjektivSurfaceFailureResponse { pub status: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivResidentContextOutput { + /// Exact user-managed document. Empty means explicitly unset. + pub behavior_md: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub behavior_revision: u64, + /// Independently-fresh generated Memory projection. + pub memory_surface: memory::backend::MemoryResidentSummaryOutput, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", content = "input", rename_all = "snake_case")] pub enum SubjektivMemoryBackendOperation { ResidentSummary(memory::backend::MemoryResidentSummaryOperation), + ResidentContext(memory::backend::MemoryResidentSummaryOperation), Query(SubjektivMemoryQueryRequest), Read(SubjektivMemoryReadRequest), ListRevisions(SubjektivMemoryListRevisionsRequest), @@ -5618,6 +5659,7 @@ pub struct SubjektivMemoryStageExplicitResponse { #[serde(tag = "result", content = "data", rename_all = "snake_case")] pub enum SubjektivMemoryBackendResponse { ResidentSummary(memory::backend::MemoryResidentSummaryOutput), + ResidentContext(SubjektivResidentContextOutput), Query(SubjektivMemoryQueryResponse), Read(SubjektivMemoryReadResponse), ListRevisions(SubjektivMemoryListRevisionsResponse), @@ -9861,9 +9903,11 @@ pub fn memory_api_typescript() -> String { // `ts-rs` solely for this read-only projection. let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; @@ -9896,8 +9940,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_ let limits = format!( "export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;" ); + let subjektiv_limits = format!( + "export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;" + ); format!( - "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 4d4a28b6a..4bbc56075 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -167,6 +167,15 @@ pub enum SystemItem { prompt_provenance: Option, }, + /// A Host-fetched change to user-managed Subject behavior. Each changed + /// revision is appended rather than rewriting prior prompt/session content. + SubjectBehaviorRefresh { + body: String, + behavior_revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt_provenance: Option, + }, + /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -192,6 +201,7 @@ impl SystemItem { } SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), + SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -214,6 +224,7 @@ impl SystemItem { SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", + SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh", SystemItem::Interrupt { .. } => "interrupt", } } @@ -270,6 +281,25 @@ mod tests { )); } + #[test] + fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() { + let item = SystemItem::SubjectBehaviorRefresh { + body: "current Subject context".to_string(), + behavior_revision: 7, + prompt_provenance: None, + }; + let raw = serde_json::to_string(&item).unwrap(); + let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed.history_text(), "current Subject context"); + assert!(matches!( + parsed, + SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + .. + } + )); + } + #[test] fn notification_history_text_returns_stored_body() { let item = SystemItem::Notification { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 1a1f7f156..5871884ef 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2618,6 +2618,7 @@ impl App { session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::ResidentSummaryRefresh { body, .. } + | session_store::SystemItem::SubjectBehaviorRefresh { body, .. } | session_store::SystemItem::Interrupt { body, .. } => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 950a81a55..aa839d3ae 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1375,6 +1375,7 @@ where crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( worker.manifest(), worker.workspace_client_handle(), + worker.prompts(), )? { let workspace_id = worker diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 62411f540..9a2da3fca 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -4,7 +4,8 @@ //! authority. In that case model-visible Memory tools must go through the //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use arc_swap::ArcSwap; @@ -23,11 +24,15 @@ use crate::feature::{ FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, HookDeclaration, ToolContribution, ToolDeclaration, }; -use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored}; +use crate::hook::{ + BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError, + HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext, + WorkerRestored, +}; use crate::prompt::catalog::{PromptCatalog, WorkerPrompt}; use crate::worker::{ - SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient, - WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource, + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; #[derive(Clone, Debug)] @@ -73,6 +78,8 @@ pub enum WorkspaceMemoryBackendError { Decode(#[from] serde_json::Error), #[error("workspace memory backend rejected operation: {0}")] Backend(String), + #[error("workspace memory backend returned an invalid response: {0}")] + InvalidResponse(String), } impl dyn WorkspaceClient + '_ { @@ -653,12 +660,17 @@ struct WorkspaceResidentSummarySource { client: Arc, } -struct WorkspaceSubjektivResidentSummarySource { - client: Arc, +#[derive(Default)] +struct SubjectBehaviorRefreshState { + applied: Option<(u64, String)>, + pending: HashMap, } -const READY_EMPTY_RESIDENT_SURFACE: &str = - "The current ready resident memory surface is intentionally empty."; +struct WorkspaceSubjektivResidentSummarySource { + client: Arc, + prompts: Arc>, + behavior_state: Mutex, +} fn resident_summary_contribution( output: memory::backend::MemoryResidentSummaryOutput, @@ -696,36 +708,132 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } } +impl WorkspaceSubjektivResidentSummarySource { + async fn fetch( + &self, + ) -> Result { + match execute_subjektiv_memory_operation( + self.client.as_ref(), + server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()), + ) + .await? + { + server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output), + other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!( + "unexpected subject resident context response: {other:?}" + ))), + } + } + + fn render( + &self, + output: &server_api::SubjektivResidentContextOutput, + ) -> Result { + use memory::backend::MemoryResidentSummaryAvailability as Availability; + let availability = match output.memory_surface.availability { + Availability::Ready => "ready", + Availability::Ungenerated => "ungenerated", + Availability::Stale => "stale", + Availability::Failed => "failed", + Availability::Unavailable => "unavailable", + }; + self.prompts + .load() + .subjektiv_resident_context( + &output.behavior_md, + availability, + output.memory_surface.content.as_deref(), + ) + .map_err(|error| error.to_string()) + } + + fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + state.applied = Some((output.behavior_revision, output.behavior_md.clone())); + state.pending.remove(&output.behavior_revision); + } +} + #[async_trait] impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { async fn load(&self) -> SystemPromptContribution { - match execute_subjektiv_memory_operation( - self.client.as_ref(), - server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()), - ) - .await - { - Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => { - match resident_summary_contribution(output) { - SystemPromptContribution::Ready(content) if content.trim().is_empty() => { - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string()) - } - contribution => contribution, + match self.fetch().await { + Ok(output) => match self.render(&output) { + Ok(rendered) => { + self.remember_behavior(&output); + SystemPromptContribution::Ready(rendered) } - } - Ok(other) => { - tracing::debug!(?other, "unexpected subject resident Memory response"); - SystemPromptContribution::Unavailable - } + Err(error) => { + tracing::error!(%error, "subject resident context render failed"); + SystemPromptContribution::Ready( + "The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) + } + }, Err(error) => { - tracing::debug!(%error, "subject resident Memory unavailable"); - SystemPromptContribution::Unavailable + tracing::error!(%error, "subject resident context fetch failed"); + SystemPromptContribution::Ready( + "The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." + .to_string(), + ) } } } + + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + let output = self.fetch().await.map_err(|error| error.to_string())?; + let changed = self + .behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied + .as_ref() + .is_none_or(|(revision, body)| { + *revision != output.behavior_revision || body != &output.behavior_md + }); + if !changed { + return Ok(None); + } + let rendered = self.render(&output)?; + let revision = output.behavior_revision; + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .pending + .insert(revision, output.behavior_md); + Ok(Some(ResidentContextRefresh { + body: rendered, + revision, + })) + } + + fn confirm_resident_context_revision(&self, revision: u64) { + let mut state = self + .behavior_state + .lock() + .expect("subject behavior state poisoned"); + if let Some(body) = state.pending.remove(&revision) { + state.applied = Some((revision, body)); + } + } + + fn invalidate_resident_context_representation(&self) { + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied = None; + } } const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore"; +const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request"; +const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite"; /// subjektiv-owned restore behavior. Initial prompt contribution remains a /// shared host facility, while loading and rendering a post-restore refresh is @@ -773,6 +881,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { SUBJEKTIV_RESTORE_REFRESH_HOOK, FeatureHookPoint::WorkerRestored, )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + .with_hook(HookDeclaration::new( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + FeatureHookPoint::BeforeSessionRewrite, + )) } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { @@ -784,10 +900,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature { prompts: Arc::clone(&self.prompts), workspace_id: self.workspace_id.clone(), }, + )?; + context.hooks().add_pre_llm_request( + SUBJEKTIV_BEHAVIOR_REFRESH_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRefreshHook { + source: Arc::clone(&self.source), + }, + )?; + context.hooks().add_before_session_rewrite( + SUBJEKTIV_BEHAVIOR_REWRITE_HOOK, + HookExecutionPolicy::fail_closed(), + SubjektivBehaviorRewriteHook { + source: Arc::clone(&self.source), + }, ) } } +struct SubjektivBehaviorRewriteHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRewriteHook { + async fn call( + &self, + _context: &BeforeSessionRewriteContext, + ) -> Result { + self.source.invalidate_resident_context_representation(); + Ok(BeforeSessionRewriteAction::Continue) + } +} + +struct SubjektivBehaviorRefreshHook { + source: Arc, +} + +#[async_trait] +impl Hook for SubjektivBehaviorRefreshHook { + async fn call(&self, context: &PreRequestContext) -> Result { + let refresh = self + .source + .load_changed_resident_context() + .await + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?; + if let Some(refresh) = refresh { + let system_items = context.system_items().ok_or_else(|| { + HookError::new( + HookErrorCategory::Dependency, + "Subject behavior refresh requires durable session append authority", + ) + })?; + system_items.append_subject_behavior_refresh(refresh.body, refresh.revision); + } + Ok(HookPreRequestAction::Continue) + } +} + struct SubjektivResidentRestoreRefreshHook { source: Arc, prompts: Arc>, @@ -931,6 +1101,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan pub(crate) fn ordinary_subjektiv_resident_summary_source( manifest: &manifest::WorkerManifest, client: Arc, + prompts: Arc>, ) -> std::io::Result>> { if !ordinary_subjektiv_features_enabled(manifest) { return Ok(None); @@ -968,6 +1139,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source( } Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { client, + prompts, + behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), }))) } @@ -1100,6 +1273,10 @@ mod tests { use agen::tool::ToolDefinition; use std::sync::Mutex; + fn test_prompts() -> Arc> { + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) + } + fn test_client() -> Arc { Arc::new(crate::worker::TestWorkspaceHttpClient::new( "workspace", @@ -1112,6 +1289,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability, content: Option, scope_allowed: bool, + behavior: Mutex<(u64, String)>, paths: Mutex>, } @@ -1124,6 +1302,7 @@ mod tests { availability, content: content.map(str::to_string), scope_allowed: true, + behavior: Mutex::new((3, "Be deliberate.".to_string())), paths: Mutex::new(Vec::new()), } } @@ -1133,6 +1312,7 @@ mod tests { availability: memory::backend::MemoryResidentSummaryAvailability::Ready, content: Some("must not be injected".into()), scope_allowed: false, + behavior: Mutex::new((3, "must not be injected".to_string())), paths: Mutex::new(Vec::new()), } } @@ -1161,7 +1341,7 @@ mod tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); if !self.scope_allowed { return Ok(WorkspaceResponse { @@ -1169,13 +1349,18 @@ mod tests { body: "subject scope unavailable".into(), }); } + let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone(); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) @@ -1479,13 +1664,19 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, Some("current subject surface"), )); - let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone()) - .unwrap() - .unwrap(); - assert_eq!( - ready.load().await, - SystemPromptContribution::Ready("current subject surface".into()) - ); + let ready = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_client.clone(), + test_prompts(), + ) + .unwrap() + .unwrap(); + let loaded = ready.load().await; + let SystemPromptContribution::Ready(loaded) = loaded else { + panic!("subject resident context must be present"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(loaded.contains("current subject surface")); assert_eq!( ready_client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] @@ -1495,13 +1686,18 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, None, )); - let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client) - .unwrap() - .unwrap(); - assert_eq!( - ready_empty.load().await, - SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into()) - ); + let ready_empty = ordinary_subjektiv_resident_summary_source( + &manifest, + ready_empty_client, + test_prompts(), + ) + .unwrap() + .unwrap(); + let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else { + panic!("empty surface must retain Subject behavior"); + }; + assert!(ready_empty.contains("Be deliberate.")); + assert!(ready_empty.contains("intentionally empty")); for availability in [ memory::backend::MemoryResidentSummaryAvailability::Ungenerated, @@ -1514,22 +1710,101 @@ permission = "write" availability, Some("must not be injected"), )), + test_prompts(), ) .unwrap() .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(loaded) = source.load().await else { + panic!("Subject behavior must survive unavailable surfaces"); + }; + assert!(loaded.contains("Be deliberate.")); + assert!(!loaded.contains("must not be injected")); } } + #[tokio::test] + async fn subject_resident_source_emits_only_changed_behavior_revisions() { + let manifest = subject_manifest("workspace"); + let client = Arc::new(SubjectResidentClient::new( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + )); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); + + let initial = source.load().await; + assert!(matches!(initial, SystemPromptContribution::Ready(_))); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string()); + let refresh = source + .load_changed_resident_context() + .await + .unwrap() + .expect("changed behavior must produce one refresh"); + assert_eq!(refresh.revision, 4); + assert!(refresh.body.contains("Ask when uncertain.")); + source.confirm_resident_context_revision(refresh.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + *client.behavior.lock().unwrap() = (5, String::new()); + let cleared = source + .load_changed_resident_context() + .await + .unwrap() + .expect("cleared behavior must produce one refresh"); + assert_eq!(cleared.revision, 5); + assert!(cleared.body.contains("No user-managed behavior is set")); + source.confirm_resident_context_revision(cleared.revision); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_none() + ); + + source.invalidate_resident_context_representation(); + let replay_after_rewrite = source + .load_changed_resident_context() + .await + .unwrap() + .expect("history rewrite must require a durable current representation"); + assert_eq!(replay_after_rewrite.revision, 5); + assert!( + replay_after_rewrite + .body + .contains("No user-managed behavior is set") + ); + } + #[tokio::test] async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { let manifest = subject_manifest("workspace"); let client = Arc::new(SubjectResidentClient::denied()); - let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); - assert_eq!(source.load().await, SystemPromptContribution::Unavailable); + let SystemPromptContribution::Ready(failure) = source.load().await else { + panic!("fetch failure must be explicit in resident context"); + }; + assert!(failure.contains("could not fetch")); assert_eq!( client.paths.lock().unwrap().clone(), vec!["/api/w/workspace/subjektiv/memory".to_string()] @@ -1557,7 +1832,7 @@ permission = "write" ) .unwrap(); assert!( - ordinary_subjektiv_resident_summary_source(&disabled, test_client()) + ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts()) .unwrap() .is_none() ); @@ -1569,22 +1844,30 @@ permission = "write" Some("policy-only surface must not load"), )); assert!( - ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone()) - .unwrap() - .is_none() + ordinary_subjektiv_resident_summary_source( + &policy_only, + unattached_client.clone(), + test_prompts() + ) + .unwrap() + .is_none() ); assert!(unattached_client.paths.lock().unwrap().is_empty()); let foreign = subject_manifest("other-workspace"); - assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err()); + assert!( + ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts()) + .is_err() + ); } #[test] fn subject_restore_refresh_is_suppressed_for_internal_hosts() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let prompts = Arc::new(ArcSwap::from( crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), )); @@ -1598,9 +1881,10 @@ permission = "write" #[test] fn subject_restore_refresh_feature_declares_only_restore_lifecycle() { let manifest = subject_manifest("workspace"); - let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client()) - .unwrap() - .unwrap(); + let source = + ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let feature = SubjektivResidentRestoreRefreshFeature::new( source, Arc::new(ArcSwap::from( @@ -1611,8 +1895,13 @@ permission = "write" let descriptor = feature.descriptor(); assert!(descriptor.tools.is_empty()); - assert_eq!(descriptor.hooks.len(), 1); + assert_eq!(descriptor.hooks.len(), 3); assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored); + assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest); + assert_eq!( + descriptor.hooks[2].point, + FeatureHookPoint::BeforeSessionRewrite + ); } #[test] diff --git a/crates/worker/src/hook.rs b/crates/worker/src/hook.rs index 6e0445d87..82f93d469 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -281,6 +281,20 @@ impl SystemItemAppendHandle { .expect("system-item append queue poisoned") .push(item); } + + /// Queue a revisioned Subject behavior refresh. The caller receives this + /// narrow operation only at a pre-request boundary, and the host commits it + /// before exposing it to the model. + pub fn append_subject_behavior_refresh(&self, body: impl Into, behavior_revision: u64) { + self.pending + .lock() + .expect("system-item append queue poisoned") + .push(SystemItem::SubjectBehaviorRefresh { + body: body.into(), + behavior_revision, + prompt_provenance: None, + }); + } } /// Host-created narrow append authority exposed only at a restore lifecycle diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 2b403cd39..e3378f3f2 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -38,7 +38,7 @@ use crate::hook::{ use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::prompt::catalog::PromptCatalog; use crate::session_history::SessionHistoryMetadata; -use crate::worker::SystemItemCommitter; +use crate::worker::{SystemItemCommitter, SystemPromptContributionSource}; use agen::HistoryEntry; use agen::token_counter::total_tokens; @@ -109,6 +109,7 @@ pub(crate) struct WorkerInterceptor { /// worker. `None` in tests / `Worker::new` paths where no writer is /// attached. log_writer: Option>, + resident_context_source: Option>, pending_committed_history: Arc>>>, /// Next turn index assigned by `on_prompt_submit`. next_turn_index: AtomicUsize, @@ -163,6 +164,7 @@ impl WorkerInterceptor { prompts, prompt_workspace_id: None, log_writer, + resident_context_source: None, pending_committed_history, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), @@ -179,11 +181,31 @@ impl WorkerInterceptor { self } + pub(crate) fn with_resident_context_source( + mut self, + source: Option>, + ) -> Self { + self.resident_context_source = source; + self + } + pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option) -> Self { self.prompt_workspace_id = workspace_id; self } + fn confirm_resident_context_item(&self, item: &SystemItem) { + let Some(source) = self.resident_context_source.as_ref() else { + return; + }; + if let SystemItem::SubjectBehaviorRefresh { + behavior_revision, .. + } = item + { + source.confirm_resident_context_revision(*behavior_revision); + } + } + /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -211,6 +233,7 @@ impl WorkerInterceptor { .lock() .expect("pending committed history poisoned") .push_back(entry); + self.confirm_resident_context_item(item); } Ok(()) } @@ -350,6 +373,11 @@ impl WorkerInterceptor { } if prompt_provenance.is_none() => { *prompt_provenance = Some(provenance("internal.task_reminder")); } + SystemItem::SubjectBehaviorRefresh { + prompt_provenance, .. + } if prompt_provenance.is_none() => { + *prompt_provenance = Some(provenance("internal.subjektiv_resident_context")); + } SystemItem::Interrupt { prompt_provenance, .. } if prompt_provenance.is_none() => { @@ -763,6 +791,7 @@ mod tests { HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, }; use crate::session_history::{WorkerHistoryProvenance, history_entry}; + use crate::worker::SystemPromptContribution; fn test_prompts() -> Arc> { Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) @@ -859,6 +888,53 @@ mod tests { } } + struct AppendingSubjectBehaviorHook; + + #[async_trait] + impl Hook for AppendingSubjectBehaviorHook { + async fn call( + &self, + input: &PreRequestContext, + ) -> Result { + input + .system_items() + .expect("test interceptor has durable append authority") + .append_subject_behavior_refresh("current Subject context", 7); + Ok(HookPreRequestAction::Continue) + } + } + + struct ConfirmRecordingResidentSource { + confirmed: Arc>>, + } + + #[async_trait] + impl SystemPromptContributionSource for ConfirmRecordingResidentSource { + async fn load(&self) -> SystemPromptContribution { + SystemPromptContribution::Unavailable + } + + fn confirm_resident_context_revision(&self, revision: u64) { + self.confirmed.lock().unwrap().push(revision); + } + } + + struct FailingSystemItemCommitter; + + impl SystemItemCommitter for FailingSystemItemCommitter { + fn commit_log_entry( + &self, + entry: session_store::LogEntry, + ) -> Result<(), session_store::StoreError> { + if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) { + return Err(session_store::StoreError::Io(std::io::Error::other( + "synthetic system-item failure", + ))); + } + Ok(()) + } + } + fn task_tool_call_info( name: &str, input: serde_json::Value, @@ -1346,6 +1422,88 @@ mod tests { } } + #[tokio::test] + async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let committed = Arc::new(Mutex::new(Vec::new())); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(RecordingSystemItemCommitter { + committed: Arc::clone(&committed), + })), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!(action, PreRequestAction::ContinueWith(_))); + assert_eq!(*confirmed.lock().unwrap(), [7]); + assert!(matches!( + committed.lock().unwrap().as_slice(), + [SystemItem::SubjectBehaviorRefresh { + behavior_revision: 7, + prompt_provenance: Some(_), + .. + }] + )); + } + + #[tokio::test] + async fn failed_subject_behavior_persistence_does_not_confirm_revision() { + let mut builder = HookRegistryBuilder::new(); + builder.add_pre_llm_request(AppendingSubjectBehaviorHook); + let confirmed = Arc::new(Mutex::new(Vec::new())); + let source: Arc = + Arc::new(ConfirmRecordingResidentSource { + confirmed: Arc::clone(&confirmed), + }); + let interceptor = WorkerInterceptor::new( + Arc::new(builder.build()), + None, + None, + NotifyBuffer::new(), + Arc::new(Mutex::new(Vec::new())), + test_prompts(), + Some(Arc::new(FailingSystemItemCommitter)), + ) + .with_resident_context_source(Some(source)); + + let mut ctx = Vec::new(); + let action = interceptor + .pre_llm_request(PreLlmRequestContext { + invocation: Default::default(), + items: &mut ctx, + history: &[], + }) + .await + .unwrap(); + + assert!(matches!( + action, + PreRequestAction::Cancel(reason) if reason.contains("session persistence failed") + )); + assert!(confirmed.lock().unwrap().is_empty()); + } + #[tokio::test] async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() { let saw_handle = Arc::new(AtomicBool::new(false)); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index f8c52cc47..9b3e30459 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -105,6 +105,7 @@ pub enum WorkerPrompt { AgentsMdSection, ResidentMemorySummarySection, ResidentMemoryRestoreSection, + SubjektivResidentContext, WorkerOrchestrationGuidanceSection, SubWorkerSpawnToolDescription, } @@ -128,6 +129,7 @@ impl WorkerPrompt { Self::AgentsMdSection => "internal.agents_md_section", Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section", + Self::SubjektivResidentContext => "internal.subjektiv_resident_context", Self::WorkerOrchestrationGuidanceSection => { "internal.worker_orchestration_guidance_section" } @@ -150,6 +152,7 @@ impl WorkerPrompt { WorkerPrompt::AgentsMdSection, WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemoryRestoreSection, + WorkerPrompt::SubjektivResidentContext, WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::SubWorkerSpawnToolDescription, ]; @@ -518,6 +521,21 @@ impl PromptCatalog { }, ) } + pub fn subjektiv_resident_context( + &self, + behavior_md: &str, + surface_availability: &str, + surface_body: Option<&str>, + ) -> Result { + self.render( + WorkerPrompt::SubjektivResidentContext, + minijinja::context! { + behavior_md => behavior_md, + surface_availability => surface_availability, + surface_body => surface_body.unwrap_or_default(), + }, + ) + } pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 353bb234c..3914573d4 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2346,9 +2346,30 @@ pub(crate) enum SystemPromptContribution { Unavailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResidentContextRefresh { + pub body: String, + pub revision: u64, +} + #[async_trait::async_trait] pub(crate) trait SystemPromptContributionSource: Send + Sync { async fn load(&self) -> SystemPromptContribution; + + /// Returns a durable model-visible refresh only when user-managed resident + /// context changed since the last successful load. + async fn load_changed_resident_context( + &self, + ) -> Result, String> { + Ok(None) + } + + /// Acknowledges that the revision's typed refresh was durably committed. + fn confirm_resident_context_revision(&self, _revision: u64) {} + + /// Invalidates the in-memory representation fence before history may be + /// compacted or rewound. + fn invalidate_resident_context_representation(&self) {} } #[derive(Debug, Clone)] @@ -3825,6 +3846,7 @@ impl Worker { ) .with_usage_tracker(self.usage_tracker.clone()) .with_metrics_tracker(self.metrics_tracker.clone()) + .with_resident_context_source(self.feature_resident_summary_source.clone()) .with_prompt_workspace_id( self.workspace_context .workspace_id() @@ -9191,16 +9213,20 @@ mod build_summary_prompt_tests { serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( request.operation, - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) )); self.load_count.fetch_add(1, Ordering::SeqCst); Ok(WorkspaceResponse { status: 200, body: serde_json::to_string( - &server_api::SubjektivMemoryBackendResponse::ResidentSummary( - memory::backend::MemoryResidentSummaryOutput { - availability: self.availability, - content: self.content.clone(), + &server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: "restore behavior".to_string(), + behavior_revision: 1, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: self.availability, + content: self.content.clone(), + }, }, ), ) @@ -9221,7 +9247,9 @@ mod build_summary_prompt_tests { load_count: Arc::clone(&load_count), }); let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( - manifest, client, + manifest, + client, + Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), ) .unwrap() .unwrap(); @@ -12186,9 +12214,10 @@ permission = "write" .unwrap() .to_string(); assert!( - empty_boundary.contains("current ready resident memory surface is intentionally empty") + empty_boundary + .contains("current generated Memory surface is ready and intentionally empty") ); - assert!(!empty_boundary.contains("No current ready resident memory surface")); + assert!(!empty_boundary.contains("No generated Memory surface exists yet")); drop(empty_restore); for availability in [ @@ -12240,7 +12269,22 @@ permission = "write" .as_text() .unwrap() .to_string(); - assert!(tombstone.contains("No current ready resident memory surface")); + let unavailable_message = match availability { + memory::backend::MemoryResidentSummaryAvailability::Ungenerated => { + "No generated Memory surface exists yet" + } + memory::backend::MemoryResidentSummaryAvailability::Stale => { + "generated Memory surface is stale" + } + memory::backend::MemoryResidentSummaryAvailability::Failed => { + "Generation of the Memory surface failed" + } + memory::backend::MemoryResidentSummaryAvailability::Unavailable => { + "Host could not fetch the Subject resident context" + } + memory::backend::MemoryResidentSummaryAvailability::Ready => unreachable!(), + }; + assert!(tombstone.contains(unavailable_message)); assert!(!tombstone.contains("latest subject surface")); assert!(!tombstone.contains("LEGACY_MEMORY_SENTINEL_MUST_NOT_REFRESH")); let entries = store.read_all(session_id, segment_id).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d851e0bc2..0ab083230 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5760,6 +5760,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_subject_get( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone())) .merge(server_api::server_api_axum::subjektiv_resident_surface( service.clone(), )) @@ -7435,6 +7436,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_subject_behavior_update( + &self, + workspace_id: String, + subject_id: String, + request: server_api::SubjektivSubjectBehaviorUpdateRequest, + ) -> std::result::Result + { + scoped_update_subjektiv_subject_behavior( + State(self.workspace_api()?.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id, + subject_id, + }), + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_resident_surface( &self, workspace_id: String, @@ -17328,6 +17349,8 @@ fn subjektiv_subject_response( server_api::SubjektivSubjectResponse { id: subject.id, role: subject.role.as_str().to_string(), + behavior_md: subject.behavior_md, + behavior_revision: subject.behavior_revision, state: match subject.state { crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active, crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired, @@ -17347,9 +17370,11 @@ async fn scoped_create_subjektiv_subject( validate_workspace_scope(&api, &path.workspace_id)?; let role = crate::subjektiv::SubjectRole::new(request.role) .map_err(|error| Error::InvalidInput(error.to_string()))?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; let subject = open_subjektiv_store(&api)? - .create_subject(role) - .map_err(|error| Error::Store(error.to_string()))?; + .create_subject_with_behavior(role, request.behavior_md) + .map_err(subjektiv_store_error)?; Ok(( StatusCode::CREATED, Json(subjektiv_subject_response(&api, subject)), @@ -17441,6 +17466,24 @@ async fn scoped_get_subjektiv_subject( Ok(Json(subjektiv_subject_response(&api, subject))) } +async fn scoped_update_subjektiv_subject_behavior( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .update_subject_behavior( + &path.subject_id, + request.expected_behavior_revision, + request.behavior_md, + ) + .map_err(subjektiv_store_error)?; + Ok(Json(subjektiv_subject_response(&api, subject))) +} + fn require_subjektiv_subject( store: &crate::subjektiv::SubjektivStore, subject_id: &str, @@ -19047,6 +19090,23 @@ async fn scoped_subjektiv_memory_backend( resident, )) } + server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => { + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "resident subject context read", + )?; + let resident = store + .resident_context(&subject_id) + .map_err(subjektiv_store_error)?; + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md: resident.subject.behavior_md, + behavior_revision: resident.subject.behavior_revision, + memory_surface: resident_summary_output(resident.surface), + }, + ) + } server_api::SubjektivMemoryBackendOperation::Query(input) => { server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( &store, @@ -20421,6 +20481,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } crate::subjektiv::SubjektivError::RevisionConflict { .. } + | crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. } | crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => { Error::RepositoryConflict(format!("revision_conflict: {error}")) } @@ -39008,18 +39069,80 @@ mod tests { app.clone(), "POST", &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), - Some(serde_json::json!({ "role": " Browser author " })), + Some(serde_json::json!({ + "role": " Browser author ", + "behavior_md": "Prefer explicit evidence.\nAsk when uncertain." + })), &owner_token, StatusCode::CREATED, ) .await; assert_eq!(created["role"], " Browser author "); + assert_eq!( + created["behavior_md"], + "Prefer explicit evidence.\nAsk when uncertain." + ); + assert_eq!(created["behavior_revision"], 0); assert_eq!(created["state"], "active"); assert_eq!(created["store_revision"], 0); assert!(created.get("current_worker").is_none()); let created_id = created["id"].as_str().unwrap(); let persisted = store.subject(created_id).unwrap().unwrap(); assert_eq!(persisted.role.as_str(), " Browser author "); + assert_eq!(persisted.behavior_revision, 0); + let behavior_uri = + format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior"); + request_json( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + StatusCode::UNAUTHORIZED, + ) + .await; + let updated = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "Updated without rewriting history." + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(updated["behavior_md"], "Updated without rewriting history."); + assert_eq!(updated["behavior_revision"], 1); + request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 0, + "behavior_md": "stale write" + })), + &owner_token, + StatusCode::CONFLICT, + ) + .await; + let cleared = request_json_authenticated( + app.clone(), + "PATCH", + &behavior_uri, + Some(serde_json::json!({ + "expected_behavior_revision": 1, + "behavior_md": "" + })), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(cleared["behavior_md"], ""); + assert_eq!(cleared["behavior_revision"], 2); assert!( api.store .current_worker_singleton_owner( @@ -39120,6 +39243,90 @@ mod tests { ); } + #[tokio::test] + async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + + let (status, Json(created)) = scoped_create_subjektiv_subject( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "companion".to_string(), + behavior_md: "Prefer explicit evidence.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created.behavior_revision, 0); + assert_eq!(created.behavior_md, "Prefer explicit evidence."); + + let Json(updated) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Ask when uncertain.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.behavior_md, "Ask when uncertain."); + + let conflict = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id.clone(), + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 0, + behavior_md: "Stale edit".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT); + + let Json(cleared) = scoped_update_subjektiv_subject_behavior( + State(api.clone()), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + subject_id: created.id, + }), + Json(server_api::SubjektivSubjectBehaviorUpdateRequest { + expected_behavior_revision: 1, + behavior_md: String::new(), + }), + ) + .await + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + + let oversized = scoped_create_subjektiv_subject( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "reviewer".to_string(), + behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1), + }), + ) + .await + .unwrap_err(); + assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() { let workspace = tempfile::tempdir().unwrap(); @@ -39517,7 +39724,10 @@ mod tests { assert_eq!(missing.status(), StatusCode::NOT_FOUND); let subject = open_subjektiv_store(&api) .unwrap() - .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .create_subject_with_behavior( + crate::subjektiv::SubjectRole::new("companion").unwrap(), + "Prefer explicit evidence.".to_string(), + ) .unwrap(); let request = CreateWorkspaceWorkerRequest { runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), @@ -39636,6 +39846,33 @@ mod tests { } ) )); + let Json(resident_context) = scoped_subjektiv_memory_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentContext( + Default::default(), + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + resident_context, + server_api::SubjektivMemoryBackendResponse::ResidentContext( + server_api::SubjektivResidentContextOutput { + behavior_md, + behavior_revision: 0, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + }, + } + ) if behavior_md == "Prefer explicit evidence." + )); let missing_legacy = scoped_record_subjektiv_session( State(api.clone()), AxumPath(ScopedWorkspacePath { diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 1e9564954..7172f8736 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use chrono::{SecondsFormat, Utc}; use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; use memory::schema::{SourceEvidenceRef, SourceRef}; -use rusqlite::{OptionalExtension, Transaction, params}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; @@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; pub const MAX_STAGING_ANCHORS: usize = 10; +/// Maximum UTF-8 size of the user-managed Subject behavior document. +pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES; /// Deterministic surface-generation policy. Input estimates use the repository's /// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material /// payload is capped below the whole-input budget to reserve room for the fixed @@ -74,6 +76,14 @@ pub enum SubjektivError { expected: u64, actual: u64, }, + #[error( + "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" + )] + SubjectBehaviorConflict { + subject_id: String, + expected: u64, + actual: u64, + }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -136,6 +146,14 @@ pub struct SubjectRecord { pub schema_version: u32, pub id: String, pub role: SubjectRole, + /// User-managed, host-injected behavior document. This is not Memory and + /// Memory lifecycle operations must preserve it byte-for-byte. + #[serde(default)] + pub behavior_md: String, + /// Monotonic CAS generation for behavior updates, independent of Memory's + /// `store_revision`. + #[serde(default)] + pub behavior_revision: u64, pub state: SubjectState, /// Monotonic generation incremented exactly once for every committed Memory /// creation or revision for this subject. @@ -500,6 +518,12 @@ pub struct ResidentSurface { pub snapshot: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectResidentContext { + pub subject: SubjectRecord, + pub surface: ResidentSurface, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryRevisionTarget { @@ -986,6 +1010,17 @@ END; Ok(()) } +fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { + transaction.execute_batch( + r#" +ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; +ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 + CHECK (behavior_revision >= 0); +"#, + )?; + Ok(()) +} + static MIGRATIONS: &[FeatureMigration] = &[ FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), FeatureMigration::new( @@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add bounded surface generation state and runs", add_surface_generation_state, ), + FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), ]; pub const REGISTRATION: FeatureRegistration = @@ -1070,11 +1106,23 @@ impl SubjektivStore { /// Issues a random host-owned subject id. It is never derived from Worker, /// Profile, Session, or Memory content. pub fn create_subject(&self, role: SubjectRole) -> Result { + self.create_subject_with_behavior(role, String::new()) + } + + /// Creates a Subject with its optional user-managed behavior document. + pub fn create_subject_with_behavior( + &self, + role: SubjectRole, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; let timestamp = now(); let record = SubjectRecord { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: issued_id("subject"), role, + behavior_md, + behavior_revision: 0, state: SubjectState::Active, store_revision: 0, created_at: timestamp.clone(), @@ -1084,11 +1132,13 @@ impl SubjektivStore { self.database.try_transaction(|transaction| { transaction.execute( "INSERT INTO subjects ( - subject_id, role, state, store_revision, record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + subject_id, role, behavior_md, behavior_revision, state, + store_revision, record_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", params![ record.id, record.role.as_str(), + record.behavior_md, raw, record.created_at, record.updated_at @@ -1111,6 +1161,51 @@ impl SubjektivStore { }) } + /// Replaces or clears the user-managed behavior document using its own CAS + /// generation. Memory revisions do not participate in this generation. + pub fn update_subject_behavior( + &self, + subject_id: &str, + expected_behavior_revision: u64, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + self.database.try_transaction(|transaction| { + let mut subject = require_subject(transaction, subject_id)?; + if subject.behavior_revision != expected_behavior_revision { + return Err(SubjektivError::SubjectBehaviorConflict { + subject_id: subject_id.to_string(), + expected: expected_behavior_revision, + actual: subject.behavior_revision, + }); + } + if subject.behavior_md == behavior_md { + return Ok(subject); + } + subject.behavior_md = behavior_md; + subject.behavior_revision = + subject.behavior_revision.checked_add(1).ok_or_else(|| { + SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) + })?; + subject.updated_at = now(); + let raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET behavior_md = ?2, behavior_revision = ?3, + record_json = ?4, updated_at = ?5 + WHERE subject_id = ?1", + params![ + subject.id, + subject.behavior_md, + to_i64(subject.behavior_revision)?, + raw, + subject.updated_at + ], + )?; + Ok(subject) + }) + } + /// Validates an explicit product launch connection against this Workspace's /// subject store and the already-resolved Profile policy. Subject knowledge /// alone is insufficient: only an active local record produces host launch material. @@ -2021,60 +2116,17 @@ impl SubjektivStore { pub fn resident_surface(&self, subject_id: &str) -> Result { self.database.try_with_connection(|connection| { let subject = require_subject_in_connection(connection, subject_id)?; - let state = connection - .query_row( - "SELECT store_revision, status, snapshot_id - FROM surface_generation_state WHERE subject_id = ?1", - [subject_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ) - .optional()?; - let Some((revision, status, snapshot_id)) = state else { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Ungenerated, - snapshot: None, - }); - }; - let revision = u64::try_from(revision).map_err(|_| { - SubjektivError::InvalidRecord("surface state revision is negative".into()) - })?; - if revision != subject.store_revision || status == "dirty" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Stale, - snapshot: None, - }); - } - if status == "failed" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Failed, - snapshot: None, - }); - } - let snapshot_id = snapshot_id.ok_or_else(|| { - SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) - })?; - let raw = connection.query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND snapshot_id = ?2", - params![subject_id, snapshot_id], - |row| row.get::<_, String>(0), - )?; - let snapshot = parse_surface_snapshot(&raw)?; - if snapshot.built_from_store_revision != subject.store_revision { - return Err(SubjektivError::InvalidRecord( - "ready surface snapshot generation does not match subject".into(), - )); - } - Ok(ResidentSurface { - availability: SurfaceAvailability::Ready, - snapshot: Some(snapshot), - }) + resident_surface_in_connection(connection, &subject) + }) + } + + /// Atomically reads user-managed behavior and the independently-fresh + /// generated Memory surface from one SQLite connection snapshot. + pub fn resident_context(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_subject(transaction, subject_id)?; + let surface = resident_surface_in_connection(transaction, &subject)?; + Ok(SubjectResidentContext { subject, surface }) }) } @@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> { } } +fn resident_surface_in_connection( + connection: &Connection, + subject: &SubjectRecord, +) -> Result { + let state = connection + .query_row( + "SELECT store_revision, status, snapshot_id + FROM surface_generation_state WHERE subject_id = ?1", + [&subject.id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((revision, status, snapshot_id)) = state else { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Ungenerated, + snapshot: None, + }); + }; + let revision = u64::try_from(revision) + .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; + if revision != subject.store_revision || status == "dirty" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Stale, + snapshot: None, + }); + } + if status == "failed" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Failed, + snapshot: None, + }); + } + let snapshot_id = snapshot_id.ok_or_else(|| { + SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) + })?; + let raw = connection.query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject.id, snapshot_id], + |row| row.get::<_, String>(0), + )?; + let snapshot = parse_surface_snapshot(&raw)?; + if snapshot.built_from_store_revision != subject.store_revision { + return Err(SubjektivError::InvalidRecord( + "ready surface snapshot generation does not match subject".into(), + )); + } + Ok(ResidentSurface { + availability: SurfaceAvailability::Ready, + snapshot: Some(snapshot), + }) +} + +pub fn validate_subject_behavior(value: &str) -> Result<()> { + if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES { + return Err(SubjektivError::InvalidRecord(format!( + "subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes" + ))); + } + if !value.is_empty() && value.trim().is_empty() { + return Err(SubjektivError::InvalidRecord( + "subject behavior must be empty or contain non-whitespace text".to_string(), + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(SubjektivError::InvalidRecord( + "subject behavior contains an unsupported control character".to_string(), + )); + } + Ok(()) +} + fn validate_label(kind: &str, value: &str) -> Result<()> { validate_nonempty(kind, value)?; if value.len() > 256 || value.chars().any(char::is_control) { @@ -3668,6 +3801,67 @@ mod tests { assert!(!complete.has_more); } + #[test] + fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store + .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) + .unwrap(); + assert_eq!(subject.behavior_revision, 0); + assert_eq!(subject.store_revision, 0); + + let updated = store + .update_subject_behavior( + &subject.id, + 0, + "Prefer explicit evidence.\nAsk when uncertain.".to_string(), + ) + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.store_revision, 0); + + store + .create_memory( + &subject.id, + draft( + "A confirmed observation", + "exercise Memory lifecycle isolation", + ), + ) + .unwrap(); + let after_memory = store.subject(&subject.id).unwrap().unwrap(); + assert_eq!(after_memory.behavior_md, updated.behavior_md); + assert_eq!(after_memory.behavior_revision, 1); + assert_eq!(after_memory.store_revision, 1); + + let (_reopened_manager, _reopened_workspace, reopened) = + open_store(temp.path(), "workspace-a"); + let persisted = reopened.subject(&subject.id).unwrap().unwrap(); + assert_eq!(persisted.behavior_md, updated.behavior_md); + assert_eq!(persisted.behavior_revision, 1); + assert!(matches!( + store.update_subject_behavior(&subject.id, 0, String::new()), + Err(SubjektivError::SubjectBehaviorConflict { + expected: 0, + actual: 1, + .. + }) + )); + + let unchanged = store + .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) + .unwrap(); + assert_eq!(unchanged.behavior_revision, 1); + let cleared = store + .update_subject_behavior(&subject.id, 1, String::new()) + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); + assert!(validate_subject_behavior(" ").is_err()); + } + #[test] fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { let temp = tempfile::tempdir().unwrap(); @@ -3892,10 +4086,38 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - store.create_subject(role()).unwrap().id + let timestamp = now(); + let subject_id = issued_id("subject"); + let mut raw = serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp.clone(), + "updated_at": timestamp.clone(), + }); + let raw = serde_json::to_string(&raw.take()).unwrap(); + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![subject_id, role().as_str(), raw, timestamp, timestamp], + )?; + Ok(()) + }) + .unwrap(); + subject_id }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); + assert!(migrated_subject.behavior_md.is_empty()); + assert_eq!(migrated_subject.behavior_revision, 0); let lifecycle_attribution = attribution( &subject_id, "runtime-1", @@ -3935,26 +4157,50 @@ mod tests { )) .unwrap(); let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject = store.create_subject(role()).unwrap(); + let subject_id = issued_id("subject"); + let timestamp = now(); + let subject_json = serde_json::to_string(&serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp, + "updated_at": timestamp, + })) + .unwrap(); let snapshot = SurfaceSnapshot { schema_version: SUBJEKTIV_SCHEMA_VERSION, id: "legacy-surface".into(), - subject_id: subject.id.clone(), + subject_id: subject_id.clone(), body_md: "Legacy summary without generation-policy evidence.".into(), memory_refs: Vec::new(), - built_from_store_revision: subject.store_revision, + built_from_store_revision: 0, created_at: now(), }; store .database .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![ + subject_id, + role().as_str(), + subject_json, + timestamp, + timestamp + ], + )?; transaction.execute( "INSERT INTO surface_snapshots ( subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ - subject.id, + subject_id, snapshot.id, to_i64(snapshot.built_from_store_revision)?, serde_json::to_string(&snapshot)?, @@ -3964,12 +4210,12 @@ mod tests { transaction.execute( "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", - params![subject.id, snapshot.id], + params![subject_id, snapshot.id], )?; Ok(()) }) .unwrap(); - (subject.id, snapshot.id) + (subject_id, snapshot.id) }; let (_manager, _workspace, store) = open_store(&root, "workspace-a"); diff --git a/openapi/server-api.json b/openapi/server-api.json index 9fa559d03..c8c5cbd92 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -9315,9 +9315,32 @@ ], "type": "object" }, + "SubjektivSubjectBehaviorUpdateRequest": { + "additionalProperties": false, + "properties": { + "behavior_md": { + "type": "string" + }, + "expected_behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "expected_behavior_revision", + "behavior_md" + ], + "type": "object" + }, "SubjektivSubjectCreateRequest": { "additionalProperties": false, "properties": { + "behavior_md": { + "default": "", + "type": "string" + }, "role": { "type": "string" } @@ -9383,6 +9406,15 @@ "SubjektivSubjectResponse": { "additionalProperties": false, "properties": { + "behavior_md": { + "type": "string" + }, + "behavior_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, "created_at": { "type": "string" }, @@ -9419,6 +9451,8 @@ "required": [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -14794,7 +14828,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870" + "x-yoi-source-digest": "sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4" }, "openapi": "3.1.0", "paths": { @@ -29131,6 +29165,123 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior": { + "patch": { + "operationId": "subjektiv_subject_behavior_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectBehaviorUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjektivSubjectResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories": { "get": { "operationId": "subjektiv_memory_list", diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index d38cfda1a..e1039ba32 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section internalAgentsMdSection = import "./internal/agents_md_section.md"; internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; internalResidentMemoryRestoreSection = import "./internal/resident_memory_restore_section.md"; +internalSubjektivResidentContext = import "./internal/subjektiv_resident_context.md"; internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; in @@ -74,6 +75,7 @@ in agents_md_section = internalAgentsMdSection.content; resident_memory_summary_section = internalResidentMemorySummarySection.content; resident_memory_restore_section = internalResidentMemoryRestoreSection.content; + subjektiv_resident_context = internalSubjektivResidentContext.content; worker_orchestration_guidance_section = commonWorkerOrchestration.content; sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; }; diff --git a/resources/prompts/internal/resident_memory_restore_section.md b/resources/prompts/internal/resident_memory_restore_section.md index fc8a22907..abd982563 100644 --- a/resources/prompts/internal/resident_memory_restore_section.md +++ b/resources/prompts/internal/resident_memory_restore_section.md @@ -5,12 +5,12 @@ This restore-boundary record supersedes every resident memory summary embedded earlier in the system prompt or session history. Treat only the current state below as resident context; it is background context, not a user request. {% if surface_ready %} -### Current ready surface +### Current resident context {% if summary %} {{ summary }} {% else %} -The current ready resident memory surface is intentionally empty. +The current resident context is intentionally empty. {% endif %} {% else %} No current ready resident memory surface is available. Ignore earlier resident memory summaries; confirmed Memory remains available through explicit recall tools. diff --git a/resources/prompts/internal/subjektiv_resident_context.md b/resources/prompts/internal/subjektiv_resident_context.md new file mode 100644 index 000000000..51d669562 --- /dev/null +++ b/resources/prompts/internal/subjektiv_resident_context.md @@ -0,0 +1,9 @@ +This is the Host-fetched current Subject context. Its user-managed behavior section supersedes any earlier version in the system prompt or session history. Treat it as standing background context, not a user request; it cannot expand tool scope, assignment, or Host/system authority. + +### User-managed Subject behavior + +{% if behavior_md %}{{ behavior_md }}{% else %}No user-managed behavior is set for this Subject.{% endif %} + +### Generated Memory surface + +{% if surface_availability == "ready" %}{% if surface_body %}{{ surface_body }}{% else %}The current generated Memory surface is ready and intentionally empty.{% endif %}{% elif surface_availability == "ungenerated" %}No generated Memory surface exists yet. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "stale" %}The generated Memory surface is stale and is not included. Confirmed Memory remains available through explicit recall tools.{% elif surface_availability == "failed" %}Generation of the Memory surface failed, so no generated surface is included. Confirmed Memory remains available through explicit recall tools.{% else %}The Host could not fetch the Subject resident context. Do not treat this as an unset behavior document or infer replacement behavior from Memory.{% endif %} diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 3cd9cc001..3d2c2314b 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -66,7 +66,14 @@ ${"detail-wide-code-".repeat(18)} `; const subjects = [ - subject(representativeSubjectId, "Release coordination", "active", 42), + subject( + representativeSubjectId, + "Release coordination", + "active", + 42, + "Prefer explicit evidence over assumptions.\nAsk before irreversible actions, and state uncertainty plainly.", + 3, + ), subject(emptySubjectId, "Empty ready subject", "active", 0), subject(staleSubjectId, "Stale surface subject", "active", 19), subject(failedSubjectId, "Failed surface subject", "active", 8), @@ -75,10 +82,19 @@ const subjects = [ ]; const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); -function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { +function subject( + id: string, + role: string, + state: "active" | "retired", + storeRevision: number, + behaviorMd = "", + behaviorRevision = 0, +) { return { id, role, + behavior_md: behaviorMd, + behavior_revision: behaviorRevision, state, store_revision: storeRevision, created_at: "2026-01-01T00:00:00Z", @@ -403,7 +419,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { if (request.method === "POST") { - const payload = await request.json().catch(() => null) as { role?: unknown } | null; + const payload = await request.json().catch(() => null) as { + role?: unknown; + behavior_md?: unknown; + } | null; subjectCreateRequests.push(payload); if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) { return json({ error: "Bad Request", message: "subject role must not be empty" }, 400); @@ -411,9 +430,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (payload.role === "Permission denied") { return json({ error: "Forbidden", message: "workspace permission denied" }, 403); } + if (typeof payload.behavior_md !== "string") { + return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400); + } createdSubjectCount += 1; const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; - const created = subject(id, payload.role, "active", 0); + const created = subject(id, payload.role, "active", 0, payload.behavior_md); createdSubjectIds.add(id); subjects.unshift(created); return json(created, 201); @@ -442,6 +464,27 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const foundSubject = subjects.find((item) => item.id === subjectId); if (!foundSubject) return json({}, 404); + if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") { + const payload = await request.json().catch(() => null) as { + expected_behavior_revision?: unknown; + behavior_md?: unknown; + } | null; + if ( + !payload || + typeof payload.expected_behavior_revision !== "number" || + typeof payload.behavior_md !== "string" + ) { + return json({ error: "Bad Request", message: "invalid behavior update" }, 400); + } + if (payload.expected_behavior_revision !== foundSubject.behavior_revision) { + return json({ error: "Conflict", message: "subject behavior revision conflict" }, 409); + } + foundSubject.behavior_md = payload.behavior_md; + foundSubject.behavior_revision += 1; + foundSubject.updated_at = "2026-01-03T04:05:06Z"; + return json(foundSubject); + } + if (request.method !== "GET") return new Response("method not allowed", { status: 405 }); if (parts.length === 1) return json(foundSubject); if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId)); if (parts.length === 2 && parts[1] === "memories") { diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index c0913082a..602cab596 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -16,7 +16,7 @@ const memoryId = const subjectPageCursor = "fixture-subject-page-2"; const createInteractionArtifacts = join( repositoryRoot, - "target/web-ux/t-689-browser-interactions", + "target/web-ux/t-693-subject-behavior", ); async function freePort(): Promise { @@ -111,6 +111,7 @@ async function openSubjectPage( await hideSidebar.click(); await showSidebar.waitFor(); + await page.waitForTimeout(250); assertEquals(await showSidebar.getAttribute("aria-expanded"), "false"); assertEquals( await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert), @@ -217,6 +218,13 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco }); page.on("pageerror", (error) => errors.push(String(error))); await openSubjectPage(page, baseUrl, scenario.mobile); + assertEquals( + await page.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + await page.screenshot({ + path: join(createInteractionArtifacts, `subject-${scenario.label}.png`), + }); const initial = await mainScrollState(page); assert(initial.scrollHeight > initial.clientHeight); @@ -425,7 +433,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const createPage = await createContext.newPage(); const mutationPaths: string[] = []; createPage.on("request", (request) => { - if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname); + if (["POST", "PATCH"].includes(request.method())) { + mutationPaths.push(new URL(request.url()).pathname); + } }); await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`); await createPage.getByText("Subject on the next page").waitFor(); @@ -444,9 +454,21 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.getByRole("button", { name: "Cancel" }).click(); await createPage.getByRole("button", { name: "New Subject" }).click(); await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward "); + await createPage.getByRole("textbox", { name: /Behavior/ }).fill( + "Challenge unsupported assumptions before release.", + ); await createPage.getByRole("button", { name: "Create Subject" }).click(); await createPage.waitForURL("**/memory/created-subject-0001"); await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor(); + assertEquals( + await createPage.getByRole("article", { name: "User-managed Subject behavior" }).count(), + 1, + ); + assert( + (await createPage.getByRole("article", { + name: "User-managed Subject behavior", + }).textContent())?.includes("Challenge unsupported assumptions before release."), + ); assertEquals( await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, @@ -455,15 +477,40 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco hasText: "Current worker", }); assert((await currentWorker.textContent())?.includes("None")); + await createPage.getByRole("button", { name: "Edit" }).click(); + const behaviorEditor = createPage.getByRole("textbox", { name: "Subject behavior" }); + await behaviorEditor.fill( + "Challenge unsupported assumptions before release.\nName the evidence and any remaining uncertainty.", + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-edit-desktop-light.png"), + }); + await createPage.getByRole("button", { name: "Save behavior" }).click(); + await createPage.getByText(/Behavior storage confirmed at revision 1/).waitFor(); + assert( + (await createPage.getByRole("status").filter({ + hasText: "This page does not confirm application", + }).textContent())?.includes("does not confirm application"), + ); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "behavior-saved-desktop-light.png"), + }); assertEquals(mutationPaths, [ `/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`, + `/api/w/${workspaceId}/subjektiv/subjects/created-subject-0001/behavior`, ]); const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)) .json(); assertEquals(createEvidence, { count: 2, - requests: [{ role: "Permission denied" }, { role: " Release steward " }], + requests: [ + { role: "Permission denied", behavior_md: "" }, + { + role: " Release steward ", + behavior_md: "Challenge unsupported assumptions before release.", + }, + ], }); await assertNoPageWideOverflow(createPage); await createContext.close(); diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ddba41954..9c2a4da54 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index f91d69fb5..e4154515b 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = { maxIdentifierBytes: 512, } as const; +export const SUBJEKTIV_API_LIMITS = { + maxBehaviorBytes: 16384, +} as const; + export type DiagnosticSeverity = "info" | "warning" | "error"; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; @@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number, export type SubjektivSubjectState = "active" | "retired"; -export type SubjektivSubjectCreateRequest = { role: string, }; +export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, }; -export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; +export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, }; + +export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, }; export type SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 052d8f330..fcd0ea24a 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 2a3b02082..80d28518d 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 24d0a4a79..7676bf9ad 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 42881f6ce..e8d3aec3a 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870) +// Input contract: 0.1.0 (sha256:025ccf848b72bbadd2144980dbcd5f94f9d0d06e25de34fa2b643de042d4dcb4) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 65153d589..c97dea9d4 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -1,4 +1,7 @@ -import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; +import { + MEMORY_API_LIMITS, + SUBJEKTIV_API_LIMITS, +} from "#lib/generated/memory-api.ts"; import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import type { Diagnostic, @@ -26,6 +29,7 @@ import type { SubjektivResidentSurfaceAvailability, SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceSnapshot, + SubjektivSubjectBehaviorUpdateRequest, SubjektivSubjectCreateRequest, SubjektivSubjectListResponse, SubjektivSubjectResponse, @@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = { } as const; const MAX_SUBJECT_ROLE_BYTES = 256; +export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes; const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024; export type SubjektivSubjectCreateErrorKind = @@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest( "Subject role must be at most 256 bytes and contain no control characters.", ); } - return { role: request.role }; + const behaviorMd = request.behavior_md ?? ""; + validateSubjectBehavior(behaviorMd); + return { role: request.role, behavior_md: behaviorMd }; +} + +function validateSubjectBehavior(behaviorMd: string): void { + const bytes = new TextEncoder().encode(behaviorMd).byteLength; + if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) { + throw new SubjektivSubjectCreateError( + "validation", + `Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`, + ); + } + if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior must be empty or contain non-whitespace text.", + ); + } + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior contains an unsupported control character.", + ); + } } export async function createSubjektivSubject( @@ -170,8 +199,11 @@ export async function createSubjektivSubject( const subject = parseSubjektivSubjectResponse( await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), ); - if (subject.role !== validated.role) { - throw new Error("created Subject role does not match the request"); + if ( + subject.role !== validated.role || + subject.behavior_md !== validated.behavior_md + ) { + throw new Error("created Subject does not match the request"); } return subject; } catch { @@ -182,6 +214,68 @@ export async function createSubjektivSubject( } } +export async function updateSubjektivSubjectBehavior( + fetchFn: typeof fetch, + workspaceId: string, + subjectId: string, + request: SubjektivSubjectBehaviorUpdateRequest, +): Promise { + validateSubjectBehavior(request.behavior_md); + if ( + !Number.isSafeInteger(request.expected_behavior_revision) || + request.expected_behavior_revision < 0 + ) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject behavior revision is invalid.", + ); + } + let response: Response; + try { + response = await fetchFn( + workspaceApiPath( + workspaceId, + `/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`, + ), + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ); + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The save outcome is unknown. Reload the Subject before retrying.", + ); + } + if (!response.ok) { + const message = await subjektivSubjectCreateErrorMessage(response); + throw new SubjektivSubjectCreateError( + response.status >= 500 ? "unknown_outcome" : "rejected", + response.status === 409 + ? "The behavior changed elsewhere. Reload the Subject and review the latest text." + : message, + response.status, + ); + } + try { + const subject = parseSubjektivSubjectResponse( + await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes), + subjectId, + ); + if (subject.behavior_md !== request.behavior_md) { + throw new Error("saved Subject behavior does not match the request"); + } + return subject; + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The behavior may have been saved, but the response could not be confirmed. Reload the Subject.", + ); + } +} + async function subjektivSubjectCreateErrorMessage( response: Response, ): Promise { @@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage( } catch { // A rejected request still has a known outcome even when its body is invalid. } - return `Subject creation failed with HTTP ${response.status}.`; + return `Subject request failed with HTTP ${response.status}.`; } export function parseMemoryDocumentResponse( @@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse( [ "id", "role", + "behavior_md", + "behavior_revision", "state", "store_revision", "created_at", @@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse( const result: SubjektivSubjectResponse = { id, role: requiredString(record, "role"), + behavior_md: requiredString( + record, + "behavior_md", + MAX_SUBJECT_BEHAVIOR_BYTES, + ), + behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"), state, store_revision: requiredNonNegativeInteger(record, "store_revision"), created_at: requiredString(record, "created_at"), diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 9483ad3fc..b65e624e3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -14,13 +14,16 @@ const subjects = $derived(data.subjects.data?.items ?? []); let createExpanded = $state(false); let role = $state(''); + let behaviorMd = $state(''); let submitting = $state(false); let createError = $state(null); let roleInvalid = $state(false); + let behaviorInvalid = $state(false); let createOutcomeUnknown = $state(false); let createdSubject = $state(null); let createButton = $state(); let roleInput = $state(); + let behaviorInput = $state(); function subjectHref(subjectId: string): string { return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`); @@ -35,6 +38,7 @@ createExpanded = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -45,8 +49,10 @@ if (submitting) return; createExpanded = false; role = ''; + behaviorMd = ''; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; createdSubject = null; await tick(); @@ -58,9 +64,13 @@ submitting = true; createError = null; roleInvalid = false; + behaviorInvalid = false; createOutcomeUnknown = false; try { - const subject = await createSubjektivSubject(fetch, data.workspaceId, { role }); + const subject = await createSubjektivSubject(fetch, data.workspaceId, { + role, + behavior_md: behaviorMd, + }); createdSubject = subject; try { await goto(subjectHref(subject.id)); @@ -69,14 +79,16 @@ } } catch (error) { if (error instanceof SubjektivSubjectCreateError) { - roleInvalid = error.kind === 'validation'; + 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) { + if (roleInvalid || behaviorInvalid) { await tick(); - roleInput?.focus(); + if (behaviorInvalid) behaviorInput?.focus(); + else roleInput?.focus(); } } else { createOutcomeUnknown = true; @@ -145,9 +157,22 @@ disabled={submitting || createdSubject !== null} onkeydown={createFormKeydown} aria-invalid={roleInvalid ? 'true' : undefined} - aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'} + aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'} /> + {#if createError}
    + 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 } from '#lib/generated/memory-api.ts'; 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(null); + let behaviorSaveConfirmed = $state(false); + + async function saveBehavior(): Promise { + if (!subject || behaviorSaving) return; + behaviorSaving = true; + behaviorError = null; + behaviorSaveConfirmed = false; + try { + subject = await updateSubjektivSubjectBehavior(fetch, data.workspaceId, data.subjectId, { + expected_behavior_revision: subject.behavior_revision, + behavior_md: behaviorDraft, + }); + behaviorDraft = subject.behavior_md; + editingBehavior = false; + behaviorSaveConfirmed = true; + } catch (error) { + 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 { + 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 ?? []); @@ -39,7 +89,7 @@ - {data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace + {subject?.role ?? data.subjectId} · Memory · Yoi Workspace @@ -49,19 +99,19 @@

    Memory subject

    -

    {data.subject.data?.role ?? data.subjectId}

    +

    {subject?.role ?? data.subjectId}

    {data.subjectId}
    - Read-only + Behavior editable
    - {#if data.subject.data} + {#if subject}
    -
    Role
    {data.subject.data.role}
    -
    State
    {data.subject.data.state}
    -
    Store revision
    {data.subject.data.store_revision}
    -
    Updated
    -
    Current worker
    {data.subject.data.current_worker?.display_name ?? 'None'}
    +
    Role
    {subject.role}
    +
    State
    {subject.state}
    +
    Store revision
    {subject.store_revision}
    +
    Updated
    +
    Current worker
    {subject.current_worker?.display_name ?? 'None'}
    {:else if data.subject.error}