diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d6c7d6535..0e911803e 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9851,6 +9851,8 @@ 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 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 SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 801e10e87..fef6c9b7c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -38573,6 +38573,67 @@ mod tests { ) .await; assert_eq!(subjects["items"][0]["id"], subject.id); + + request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + Some(serde_json::json!({ "role": "Browser author" })), + StatusCode::UNAUTHORIZED, + ) + .await; + let created = request_json_authenticated( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + Some(serde_json::json!({ "role": " Browser author " })), + &owner_token, + StatusCode::CREATED, + ) + .await; + assert_eq!(created["role"], " Browser author "); + 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!( + api.store + .current_worker_singleton_owner( + TEST_WORKSPACE_ID, + &subjektiv_singleton_key(created_id).unwrap(), + ) + .unwrap() + .is_none() + ); + let subject_count_after_create = store.list_subjects(100).unwrap().items.len(); + request_json_authenticated( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), + Some(serde_json::json!({ "role": " " })), + &owner_token, + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!( + store.list_subjects(100).unwrap().items.len(), + subject_count_after_create + ); + request_json_authenticated( + app.clone(), + "POST", + "/api/w/another-workspace/subjektiv/subjects", + Some(serde_json::json!({ "role": "Cross Workspace" })), + &owner_token, + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!( + store.list_subjects(100).unwrap().items.len(), + subject_count_after_create + ); for uri in [ subject_root.clone(), format!("{subject_root}/surface"), 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 349033558..0e18e55f7 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -23,8 +23,13 @@ const ungeneratedSubjectId = "ungenerated-subject"; const errorSubjectId = "error-subject"; const pagedSubjectId = "paged-subject"; const subjectPageCursor = "fixture-subject-page-2"; +const emptySubjectPageCursor = "fixture-empty-subject-page"; +const errorSubjectPageCursor = "fixture-error-subject-page"; const memoryPageCursor = "fixture-memory-page-2"; const revisionPageCursor = "fixture-revision-page-2"; +const createdSubjectIds = new Set(); +const subjectCreateRequests: unknown[] = []; +let createdSubjectCount = 0; const paragraphs = Array.from( { length: sectionCount }, @@ -82,6 +87,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev } function memoriesFor(subjectId: string) { + if (createdSubjectIds.has(subjectId)) return []; if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return []; if (subjectId === staleSubjectId) { return [ @@ -314,6 +320,9 @@ async function staticResponse(pathname: string): Promise { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + if (url.pathname === "/fixture/subject-create-requests") { + return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests }); + } if (url.pathname === "/api/workspaces") { return json([{ workspace_id: workspaceId, @@ -361,7 +370,31 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`; if (url.pathname === subjectsPath) { - const continued = url.searchParams.get("cursor") === subjectPageCursor; + if (request.method === "POST") { + const payload = await request.json().catch(() => null) as { role?: 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); + } + if (payload.role === "Permission denied") { + return json({ error: "Forbidden", message: "workspace permission denied" }, 403); + } + createdSubjectCount += 1; + const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`; + const created = subject(id, payload.role, "active", 0); + createdSubjectIds.add(id); + subjects.unshift(created); + return json(created, 201); + } + if (request.method !== "GET") return new Response("method not allowed", { status: 405 }); + const cursor = url.searchParams.get("cursor"); + if (cursor === errorSubjectPageCursor) { + return json({ error: "Service Unavailable", message: "subject list unavailable" }, 503); + } + if (cursor === emptySubjectPageCursor) { + return json({ limit: 100, items: [], has_more: false }); + } + const continued = cursor === subjectPageCursor; return json({ limit: 100, items: continued ? [pagedSubject] : subjects, 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 978d39a02..c359bfb6e 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -13,6 +13,11 @@ const subjectId = "release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review"; const memoryId = "memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001"; +const subjectPageCursor = "fixture-subject-page-2"; +const createInteractionArtifacts = join( + repositoryRoot, + "target/web-ux/t-689-browser-interactions", +); async function freePort(): Promise { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); @@ -158,6 +163,7 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown, `Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`, ); } + await Deno.mkdir(createInteractionArtifacts, { recursive: true }); const port = await freePort(); const baseUrl = `http://127.0.0.1:${port}`; @@ -374,6 +380,93 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown, await assertNoPageWideOverflow(paginationPage); await paginationContext.close(); + const emptyContext = await browser.newContext({ + viewport: { width: 390, height: 844 }, + colorScheme: "dark", + }); + const emptyPage = await emptyContext.newPage(); + await emptyPage.route(`**/api/w/${workspaceId}/subjektiv/subjects?*`, async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ limit: 100, items: [], has_more: false }), + }); + return; + } + await route.continue(); + }); + await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`); + await emptyPage.getByText("No Memory subjects.").waitFor(); + await emptyPage.getByRole("button", { name: "New Subject" }).click(); + const emptyRole = emptyPage.getByRole("textbox", { name: "Role" }); + await emptyRole.waitFor(); + assertEquals( + await emptyRole.evaluate((input) => input === (globalThis as any).document.activeElement), + true, + ); + await assertNoPageWideOverflow(emptyPage); + await emptyPage.screenshot({ + path: join(createInteractionArtifacts, "empty-create-form-mobile-dark.png"), + }); + await emptyPage.getByRole("button", { name: "Cancel" }).click(); + assertEquals( + await emptyPage.getByRole("button", { name: "New Subject" }).evaluate((button) => + button === (globalThis as any).document.activeElement + ), + true, + ); + await emptyContext.close(); + + const createContext = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + colorScheme: "light", + }); + const createPage = await createContext.newPage(); + const mutationPaths: string[] = []; + createPage.on("request", (request) => { + if (request.method() === "POST") 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(); + await createPage.getByRole("button", { name: "New Subject" }).click(); + const createRole = createPage.getByRole("textbox", { name: "Role" }); + await createRole.fill("Permission denied"); + await createPage.getByRole("button", { name: "Create Subject" }).click(); + const permissionAlert = createPage.getByRole("alert"); + await permissionAlert.waitFor(); + assert((await permissionAlert.textContent())?.includes("do not have permission")); + assertEquals(await createRole.inputValue(), "Permission denied"); + await createPage.screenshot({ + path: join(createInteractionArtifacts, "permission-error-desktop-light.png"), + }); + + 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("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.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")); + assertEquals(mutationPaths, [ + `/api/w/${workspaceId}/subjektiv/subjects`, + `/api/w/${workspaceId}/subjektiv/subjects`, + ]); + const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)).json(); + assertEquals(createEvidence, { + count: 2, + requests: [{ role: "Permission denied" }, { role: " Release steward " }], + }); + await assertNoPageWideOverflow(createPage); + await createContext.close(); + const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } }); const stateCases = [ { diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json index c09324b6d..7fbdebfb0 100644 --- a/tools/web-ux/scenarios/memory-subjects-dark.json +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -21,10 +21,98 @@ "id": "subjects", "label": "Subject index", "path": "/w/memory-review/memory", - "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", - "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", - "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, - "capturePoints": [{ "id": "initial", "label": "Subjects" }] + "goal": "Choose an explicit Memory subject or create one with a Backend-validated role.", + "dataState": "Active and retired subjects across a paginated list, plus create-form and permission-rejection states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subjects'] .subject-list" + }, + "capturePoints": [ + { "id": "initial", "label": "Subjects" }, + { + "id": "create-form", + "label": "Create Subject form", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + }, + { + "id": "permission-error", + "label": "Create permission error", + "interaction": [ + { + "action": "fill", + "selector": "#subject-create-panel input", + "value": "Permission denied" + }, + { + "action": "click", + "selector": "#subject-create-panel button[type='submit']" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "#subject-create-error" + } + } + ] + } + ] + }, + { + "id": "subjects-empty", + "label": "Empty Subject index", + "path": "/w/memory-review/memory?cursor=fixture-empty-subject-page", + "goal": "Create the first visible Subject when the current bounded page is empty.", + "dataState": "Successful empty Subject page with a return-to-first-page recovery link.", + "ready": { + "kind": "selector", + "selector": "[data-memory-state='empty']" + }, + "capturePoints": [ + { "id": "initial", "label": "Empty Subjects" }, + { + "id": "create-form", + "label": "Create from empty Subjects", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + } + ] + }, + { + "id": "subjects-error", + "label": "Subject list error", + "path": "/w/memory-review/memory?cursor=fixture-error-subject-page", + "goal": "Keep explicit Subject creation available while the existing list cannot load.", + "dataState": "Intentional Subject-list HTTP 503 with the independent create disclosure still available.", + "ready": { + "kind": "selector", + "selector": "[data-memory-state='error']" + }, + "capturePoints": [ + { "id": "initial", "label": "Subjects unavailable" }, + { + "id": "create-form", + "label": "Create while list unavailable", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + } + ] }, { "id": "representative", @@ -44,7 +132,10 @@ "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", - "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "ready": { + "kind": "selector", + "selector": "[data-memory-view='detail'] .revision-list" + }, "capturePoints": [{ "id": "initial", "label": "Memory detail" }] }, { diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json index 63aebb43e..9ddf6f803 100644 --- a/tools/web-ux/scenarios/memory-subjects-light.json +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -21,10 +21,98 @@ "id": "subjects", "label": "Subject index", "path": "/w/memory-review/memory", - "goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.", - "dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.", - "ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" }, - "capturePoints": [{ "id": "initial", "label": "Subjects" }] + "goal": "Choose an explicit Memory subject or create one with a Backend-validated role.", + "dataState": "Active and retired subjects across a paginated list, plus create-form and permission-rejection states.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subjects'] .subject-list" + }, + "capturePoints": [ + { "id": "initial", "label": "Subjects" }, + { + "id": "create-form", + "label": "Create Subject form", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + }, + { + "id": "permission-error", + "label": "Create permission error", + "interaction": [ + { + "action": "fill", + "selector": "#subject-create-panel input", + "value": "Permission denied" + }, + { + "action": "click", + "selector": "#subject-create-panel button[type='submit']" + }, + { + "action": "wait", + "ready": { + "kind": "selector", + "selector": "#subject-create-error" + } + } + ] + } + ] + }, + { + "id": "subjects-empty", + "label": "Empty Subject index", + "path": "/w/memory-review/memory?cursor=fixture-empty-subject-page", + "goal": "Create the first visible Subject when the current bounded page is empty.", + "dataState": "Successful empty Subject page with a return-to-first-page recovery link.", + "ready": { + "kind": "selector", + "selector": "[data-memory-state='empty']" + }, + "capturePoints": [ + { "id": "initial", "label": "Empty Subjects" }, + { + "id": "create-form", + "label": "Create from empty Subjects", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + } + ] + }, + { + "id": "subjects-error", + "label": "Subject list error", + "path": "/w/memory-review/memory?cursor=fixture-error-subject-page", + "goal": "Keep explicit Subject creation available while the existing list cannot load.", + "dataState": "Intentional Subject-list HTTP 503 with the independent create disclosure still available.", + "ready": { + "kind": "selector", + "selector": "[data-memory-state='error']" + }, + "capturePoints": [ + { "id": "initial", "label": "Subjects unavailable" }, + { + "id": "create-form", + "label": "Create while list unavailable", + "interaction": [ + { "action": "click", "selector": "button.subject-create-trigger" } + ], + "ready": { + "kind": "selector", + "selector": "#subject-create-panel input" + } + } + ] }, { "id": "representative", @@ -44,7 +132,10 @@ "path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001", "goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.", "dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.", - "ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" }, + "ready": { + "kind": "selector", + "selector": "[data-memory-view='detail'] .revision-list" + }, "capturePoints": [{ "id": "initial", "label": "Memory detail" }] }, { diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index 0f0420cc4..f91d69fb5 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -35,6 +35,8 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number, export type SubjektivSubjectState = "active" | "retired"; +export type SubjektivSubjectCreateRequest = { role: 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 SubjektivSubjectListResponse = { limit: number, items: Array, next_cursor?: string | null, has_more: boolean, }; diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts index 2c9a91883..65153d589 100644 --- a/web/workspace/src/lib/workspace/memory/api.ts +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -1,4 +1,5 @@ import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts"; +import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import type { Diagnostic, DiagnosticSeverity, @@ -25,6 +26,7 @@ import type { SubjektivResidentSurfaceAvailability, SubjektivResidentSurfaceResponse, SubjektivResidentSurfaceSnapshot, + SubjektivSubjectCreateRequest, SubjektivSubjectListResponse, SubjektivSubjectResponse, SubjektivSubjectState, @@ -81,6 +83,128 @@ export const MEMORY_API_LOAD_POLICY = { maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes, } as const; +const MAX_SUBJECT_ROLE_BYTES = 256; +const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024; + +export type SubjektivSubjectCreateErrorKind = + | "validation" + | "rejected" + | "unknown_outcome"; + +export class SubjektivSubjectCreateError extends Error { + readonly kind: SubjektivSubjectCreateErrorKind; + readonly status: number | null; + + constructor( + kind: SubjektivSubjectCreateErrorKind, + message: string, + status: number | null = null, + ) { + super(message); + this.name = "SubjektivSubjectCreateError"; + this.kind = kind; + this.status = status; + } +} + +export function validateSubjektivSubjectCreateRequest( + request: SubjektivSubjectCreateRequest, +): SubjektivSubjectCreateRequest { + if (request.role.trim().length === 0) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject role must not be empty.", + ); + } + if ( + new TextEncoder().encode(request.role).byteLength > + MAX_SUBJECT_ROLE_BYTES || + /\p{Cc}/u.test(request.role) + ) { + throw new SubjektivSubjectCreateError( + "validation", + "Subject role must be at most 256 bytes and contain no control characters.", + ); + } + return { role: request.role }; +} + +export async function createSubjektivSubject( + fetchFn: typeof fetch, + workspaceId: string, + request: SubjektivSubjectCreateRequest, +): Promise { + const validated = validateSubjektivSubjectCreateRequest(request); + const path = workspaceApiPath(workspaceId, "/subjektiv/subjects"); + let response: Response; + try { + response = await fetchFn(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validated), + }); + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The request outcome is unknown. A Subject may have been created.", + ); + } + + if (!response.ok) { + const message = await subjektivSubjectCreateErrorMessage(response); + if (response.status >= 400 && response.status < 500) { + throw new SubjektivSubjectCreateError( + "rejected", + message, + response.status, + ); + } + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The request outcome is unknown. A Subject may have been created.", + response.status, + ); + } + + try { + 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"); + } + return subject; + } catch { + throw new SubjektivSubjectCreateError( + "unknown_outcome", + "The Subject may have been created, but the response could not be confirmed.", + ); + } +} + +async function subjektivSubjectCreateErrorMessage( + response: Response, +): Promise { + try { + const payload = await readBoundedJson( + response, + MAX_SUBJECT_CREATE_ERROR_BYTES, + ); + if (typeof payload === "object" && payload !== null) { + const record = payload as Record; + if (typeof record.message === "string" && record.message.length > 0) { + return record.message; + } + if (typeof record.error === "string" && record.error.length > 0) { + return record.error; + } + } + } catch { + // A rejected request still has a known outcome even when its body is invalid. + } + return `Subject creation failed with HTTP ${response.status}.`; +} + export function parseMemoryDocumentResponse( value: unknown, ): MemoryDocumentResponse { diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 47703e028..9483ad3fc 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -1,10 +1,26 @@ @@ -26,13 +105,73 @@

Memory

Subjects

-

Read-only durable context, organized by explicit subject.

+

Durable context, organized by explicit subject.

+
+
+ {#if data.subjects.data} + {subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'} + {/if} +
- {#if data.subjects.data} - {subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'} - {/if} + {#if createExpanded} +
{ event.preventDefault(); void submitCreate(); }} + > +
+

Create Subject

+

Role describes the Subject’s durable identity. The service assigns its ID; creating it does not start or attach a Worker.

+
+ + {#if createError} + + {/if} +
+ + +
+
+ {/if} + {#if data.subjects.data} {#if subjects.length === 0}
@@ -127,6 +266,111 @@ font-size: var(--font-size-compact); } + .memory-header-actions, + .subject-create-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + } + + .memory-header-actions { + justify-content: flex-end; + } + + .subject-create-trigger, + .subject-create-actions button { + min-height: 2.25rem; + border: 0; + border-radius: var(--radius-soft); + padding: var(--space-2) var(--space-4); + background: var(--accent); + color: var(--bg); + font-weight: 700; + cursor: pointer; + } + + .subject-create-trigger:hover, + .subject-create-trigger:focus-visible, + .subject-create-actions button:hover:not(:disabled), + .subject-create-actions button:focus-visible:not(:disabled) { + filter: brightness(1.08); + } + + .subject-create-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .subject-create-actions .subject-create-cancel { + border: 1px solid var(--line); + background: transparent; + color: var(--text-strong); + } + + .subject-create-actions { + grid-column: 3; + grid-row: 1; + } + + .subject-create-panel { + display: grid; + grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto; + align-items: end; + gap: var(--space-4); + padding: var(--space-4); + background: var(--bg-raised); + border-radius: var(--radius-soft); + } + + .subject-create-copy h2, + .subject-create-copy p, + .subject-create-error p { + margin: 0; + } + + .subject-create-copy p, + .subject-create-error { + margin-top: var(--space-1); + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .subject-role-field { + display: grid; + gap: var(--space-1); + color: var(--text-muted); + font-size: var(--font-size-compact); + font-weight: 700; + } + + .subject-role-field input { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-soft); + padding: var(--space-2) var(--space-3); + background: var(--bg); + color: var(--text-strong); + } + + .subject-role-field input[aria-invalid='true'] { + border-color: var(--danger); + } + + .subject-create-error { + grid-column: 2 / -1; + color: var(--danger); + } + + .subject-create-error a { + color: inherit; + } + + .subject-create-error.unknown-outcome { + color: var(--warning); + } + .subject-list { display: grid; border-top: 1px solid var(--line); @@ -231,6 +475,17 @@ } @media (max-width: 900px) { + .subject-create-panel { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } + + .subject-create-error, + .subject-create-actions { + grid-column: 1; + grid-row: auto; + } + .subject-row { grid-template-columns: minmax(0, 1fr); gap: var(--space-3); @@ -243,6 +498,14 @@ gap: var(--space-2); } + .memory-header-actions { + justify-content: flex-start; + } + + .subject-create-panel { + padding: var(--space-3); + } + .subject-row { padding-inline: 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 d163fa182..cc767fa43 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,29 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; -import { afterEach, expect, test } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; + +const { gotoMock } = vi.hoisted(() => ({ + gotoMock: vi.fn(async () => {}), +})); + +vi.mock("$app/navigation", () => ({ goto: gotoMock })); + import SubjectIndexPage from "./+page.svelte"; import SubjectPage from "./[subjectId]/+page.svelte"; import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte"; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); const result = (data: T | null, error: string | null = null) => ({ data, @@ -24,6 +41,18 @@ function subject() { }; } +function subjectIndexData(items = [subject()]) { + return { + workspaceId: "workspace-1", + cursor: null, + subjects: result({ + limit: 100, + items, + has_more: false, + }), + }; +} + test("renders the subject index as the Memory product entry", () => { render(SubjectIndexPage, { data: { @@ -40,6 +69,7 @@ test("renders the subject index as the Memory product entry", () => { expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not .toBeNull(); + expect(screen.getByRole("button", { name: "New Subject" })).not.toBeNull(); expect(screen.getByRole("link", { name: /Release coordinator/ })).not .toBeNull(); expect(screen.getByText("subject-1")).not.toBeNull(); @@ -50,6 +80,134 @@ test("renders the subject index as the Memory product entry", () => { expect(screen.queryByText("Staging")).toBeNull(); }); +test("creates one Subject with the exact role and navigates to its generated detail", async () => { + let resolveRequest!: (response: Response) => void; + const response = new Promise((resolve) => { + resolveRequest = resolve; + }); + const fetchMock = vi.fn(() => response); + vi.stubGlobal("fetch", fetchMock); + render(SubjectIndexPage, { data: subjectIndexData() } as never); + + const trigger = screen.getByRole("button", { name: "New Subject" }); + await fireEvent.click(trigger); + const roleInput = screen.getByRole("textbox", { + name: "Role", + }) as HTMLInputElement; + await waitFor(() => expect(document.activeElement).toBe(roleInput)); + await fireEvent.input(roleInput, { target: { value: " Review lead " } }); + + const submit = screen.getByRole("button", { name: "Create Subject" }); + await fireEvent.click(submit); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect( + screen.getByRole("button", { name: "Creating…" }).hasAttribute("disabled"), + ) + .toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Creating…" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveRequest(Response.json({ + id: "generated-subject-1", + role: " Review lead ", + state: "active", + store_revision: 0, + created_at: "2026-01-03T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }, { status: 201 })); + await waitFor(() => { + expect(gotoMock).toHaveBeenCalledWith( + "/w/workspace-1/memory/generated-subject-1", + ); + }); + + const [path, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe("/api/w/workspace-1/subjektiv/subjects"); + expect(init.method).toBe("POST"); + expect(init.body).toBe('{"role":" Review lead "}'); +}); + +test("keeps creation available for an empty list and restores focus on cancel", async () => { + vi.stubGlobal("fetch", vi.fn()); + render(SubjectIndexPage, { data: subjectIndexData([]) } as never); + + expect(screen.getByText("No Memory subjects.")).not.toBeNull(); + const trigger = screen.getByRole("button", { name: "New Subject" }); + await fireEvent.click(trigger); + const input = screen.getByRole("textbox", { + name: "Role", + }) as HTMLInputElement; + await fireEvent.input(input, { target: { value: "Draft role" } }); + await fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull(); +}); + +test("preserves invalid and rejected role drafts with accessible errors", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(Response.json( + { + error: "Forbidden", + message: "workspace permission denied", + diagnostics: [], + }, + { status: 403 }, + )) + ); + vi.stubGlobal("fetch", fetchMock); + render(SubjectIndexPage, { data: subjectIndexData() } as never); + + await fireEvent.click(screen.getByRole("button", { name: "New Subject" })); + const input = screen.getByRole("textbox", { + name: "Role", + }) as HTMLInputElement; + await fireEvent.input(input, { target: { value: " " } }); + await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); + expect(screen.getByRole("alert").textContent).toContain("must not be empty"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.value).toBe(" "); + expect(fetchMock).not.toHaveBeenCalled(); + + await fireEvent.input(input, { target: { value: "Release reviewer" } }); + await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "do not have permission", + ); + }); + expect(input.value).toBe("Release reviewer"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +test("warns about an unknown create outcome without replaying the POST", async () => { + const fetchMock = vi.fn(() => + Promise.reject(new TypeError("connection reset")) + ); + vi.stubGlobal("fetch", fetchMock); + render(SubjectIndexPage, { data: subjectIndexData() } as never); + + await fireEvent.click(screen.getByRole("button", { name: "New Subject" })); + const input = screen.getByRole("textbox", { + name: "Role", + }) as HTMLInputElement; + await fireEvent.input(input, { target: { value: "Incident coordinator" } }); + await fireEvent.click(screen.getByRole("button", { name: "Create Subject" })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "may have been created", + ); + }); + expect(screen.getByRole("link", { name: /Check the first Subjects page/ })) + .not.toBeNull(); + expect(input.value).toBe("Incident coordinator"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(gotoMock).not.toHaveBeenCalled(); +}); + test("renders a ready resident surface and committed Memory lifecycle states", async () => { render(SubjectPage, { data: { @@ -62,7 +220,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", diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 8d58c8561..97f036e1e 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -3,6 +3,7 @@ declare const Deno: { }; import { + createSubjektivSubject, parseMemoryStagingListResponse, parseSubjektivMemoryListRevisionsResponse, parseSubjektivMemoryQueryResponse, @@ -10,6 +11,8 @@ import { parseSubjektivResidentSurfaceResponse, parseSubjektivSubjectListResponse, parseSubjektivSubjectResponse, + SubjektivSubjectCreateError, + validateSubjektivSubjectCreateRequest, } from "../src/lib/workspace/memory/api.ts"; function stable(value: unknown): unknown { @@ -44,6 +47,30 @@ function assertThrows(fn: () => void, expectedMessage: string): void { throw new Error(`expected function to throw ${expectedMessage}`); } +async function assertSubjectCreateRejects( + fn: () => Promise, + expectedKind: SubjektivSubjectCreateError["kind"], + expectedMessage: string, + expectedStatus: number | null = null, +): Promise { + try { + await fn(); + } catch (error) { + if (!(error instanceof SubjektivSubjectCreateError)) throw error; + assertEquals(error.kind, expectedKind); + assertEquals(error.status, expectedStatus); + if (!error.message.includes(expectedMessage)) { + throw new Error( + `expected ${JSON.stringify(error.message)} to include ${ + JSON.stringify(expectedMessage) + }`, + ); + } + return; + } + throw new Error(`expected Subject creation to reject with ${expectedKind}`); +} + function subject(id = "subject-1") { return { id, @@ -198,6 +225,117 @@ Deno.test("Subject list parser preserves the exact bounded response", () => { ); }); +Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => { + assertEquals( + validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }), + { role: " Release coordinator " }, + ); + assertThrows( + () => validateSubjektivSubjectCreateRequest({ role: " " }), + "must not be empty", + ); + assertThrows( + () => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }), + "control characters", + ); + assertThrows( + () => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }), + "at most 256 bytes", + ); +}); + +Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => { + const requests: Array<{ path: string; init?: RequestInit }> = []; + const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => { + requests.push({ path: String(path), init }); + return Response.json({ + ...subject("subject-created"), + role: " Release coordinator ", + }, { status: 201 }); + }) as typeof fetch; + + const created = await createSubjektivSubject(fetchFn, "workspace one", { + role: " Release coordinator ", + }); + assertEquals(created, { + ...subject("subject-created"), + role: " Release coordinator ", + }); + assertEquals(requests.length, 1); + assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects"); + assertEquals(requests[0].init?.method, "POST"); + assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}'); +}); + +Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => { + let rejectedCalls = 0; + const rejectedFetch = (async () => { + rejectedCalls += 1; + return Response.json( + { + error: "Forbidden", + message: "workspace permission denied", + diagnostics: [], + }, + { status: 403 }, + ); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => + createSubjektivSubject(rejectedFetch, "workspace-1", { + role: "Reviewer", + }), + "rejected", + "workspace permission denied", + 403, + ); + assertEquals(rejectedCalls, 1); + + let serverErrorCalls = 0; + const serverErrorFetch = (async () => { + serverErrorCalls += 1; + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => + createSubjektivSubject(serverErrorFetch, "workspace-1", { + role: "Reviewer", + }), + "unknown_outcome", + "may have been created", + 500, + ); + assertEquals(serverErrorCalls, 1); + + let mismatchCalls = 0; + const mismatchFetch = (async () => { + mismatchCalls += 1; + return Response.json(subject("subject-created"), { status: 201 }); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => + createSubjektivSubject(mismatchFetch, "workspace-1", { + role: "Reviewer", + }), + "unknown_outcome", + "could not be confirmed", + ); + assertEquals(mismatchCalls, 1); + + let unknownCalls = 0; + const unknownFetch = (async () => { + unknownCalls += 1; + throw new TypeError("connection reset"); + }) as typeof fetch; + await assertSubjectCreateRejects( + () => + createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), + "unknown_outcome", + "may have been created", + ); + assertEquals(unknownCalls, 1); +}); + Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => { const readyEmpty = { subject_id: "subject-1",