feat(web): add Subject creation to Memory index

This commit is contained in:
2026-10-04 22:35:21 +09:00
parent 1bbc48dad0
commit 1c5966c535
11 changed files with 1076 additions and 19 deletions
+2
View File
@@ -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<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
+61
View File
@@ -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"),
@@ -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<string>();
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<Response> {
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,
@@ -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<number> {
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 = [
{
@@ -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" }]
},
{
@@ -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" }]
},
{
@@ -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<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -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<SubjektivSubjectResponse> {
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<string> {
try {
const payload = await readBoundedJson(
response,
MAX_SUBJECT_CREATE_ERROR_BYTES,
);
if (typeof payload === "object" && payload !== null) {
const record = payload as Record<string, unknown>;
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 {
@@ -1,10 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import {
createSubjektivSubject,
SubjektivSubjectCreateError,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -14,6 +30,69 @@
const path = workspaceRoute(data.workspaceId, '/memory');
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
roleInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
roleInput?.focus();
}
async function cancelCreate(): Promise<void> {
if (submitting) return;
createExpanded = false;
role = '';
createError = null;
roleInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
createButton?.focus();
}
async function submitCreate(): Promise<void> {
if (submitting || createdSubject) return;
submitting = true;
createError = null;
roleInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
} catch {
createError = 'Subject created, but navigation failed. Open the created Subject below.';
}
} catch (error) {
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation';
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) {
await tick();
roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
createError = 'The request outcome is unknown. A Subject may have been created.';
}
} finally {
submitting = false;
}
}
function createFormKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && !submitting) {
event.preventDefault();
void cancelCreate();
}
}
</script>
<svelte:head>
@@ -26,13 +105,73 @@
<div>
<p class="memory-eyebrow">Memory</p>
<h1 id="memory-subjects-heading">Subjects</h1>
<p>Read-only durable context, organized by explicit subject.</p>
<p>Durable context, organized by explicit subject.</p>
</div>
<div class="memory-header-actions">
{#if data.subjects.data}
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
{/if}
<button
bind:this={createButton}
type="button"
class="subject-create-trigger"
aria-expanded={createExpanded}
aria-controls="subject-create-panel"
onclick={() => void openCreateForm()}
>New Subject</button>
</div>
{#if data.subjects.data}
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
{/if}
</header>
{#if createExpanded}
<form
id="subject-create-panel"
class="subject-create-panel"
aria-labelledby="subject-create-heading"
aria-busy={submitting}
onsubmit={(event) => { event.preventDefault(); void submitCreate(); }}
>
<div class="subject-create-copy">
<h2 id="subject-create-heading">Create Subject</h2>
<p id="subject-role-help">Role describes the Subject’s durable identity. The service assigns its ID; creating it does not start or attach a Worker.</p>
</div>
<label class="subject-role-field">
<span>Role</span>
<input
bind:this={roleInput}
bind:value={role}
type="text"
autocomplete="off"
required
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/>
</label>
{#if createError}
<div
id="subject-create-error"
class:unknown-outcome={createOutcomeUnknown}
class="subject-create-error"
role="alert"
>
<p>{createError}</p>
{#if createOutcomeUnknown}
<p><a href={pageHref()}>Check the first Subjects page before retrying.</a></p>
{:else if createdSubject}
<p><a href={subjectHref(createdSubject.id)}>Open {createdSubject.id}</a></p>
{/if}
</div>
{/if}
<div class="subject-create-actions">
<button type="submit" disabled={submitting || createdSubject !== null}>
{submitting ? 'Creating…' : 'Create Subject'}
</button>
<button type="button" class="subject-create-cancel" disabled={submitting} onclick={() => void cancelCreate()}>Cancel</button>
</div>
</form>
{/if}
{#if data.subjects.data}
{#if subjects.length === 0}
<div class="memory-state" role="status" data-memory-state="empty">
@@ -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;
}
@@ -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 = <T>(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<Response>((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",
+138
View File
@@ -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<unknown>,
expectedKind: SubjektivSubjectCreateError["kind"],
expectedMessage: string,
expectedStatus: number | null = null,
): Promise<void> {
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",