From 3369dbd1eb4052df274427a5a0f3b683f6e13b36 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 26 Jul 2026 16:45:12 +0900 Subject: [PATCH] web: add memory document view --- crates/workspace-server/src/server.rs | 58 ++++++++- .../console/worker-console.ui.test.ts | 42 ++++-- .../workspace/sidebar/MemoryNavSection.svelte | 6 + .../src/lib/workspace/sidebar/types.ts | 8 ++ .../w/[workspaceId]/memory/+page.svelte | 123 ++++++++++++++++++ .../routes/w/[workspaceId]/memory/+page.ts | 13 ++ 6 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/+page.ts diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 1c3ab73b..618e5404 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -42,7 +42,9 @@ use crate::auth::{ new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, session_set_cookie, token_hash, }; -use crate::authority::{ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority}; +use crate::authority::{ + MemoryAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, +}; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, @@ -485,6 +487,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/backend", post(scoped_ticket_backend_operation), ) + .route( + "/api/w/{workspace_id}/memory", + get(scoped_get_memory_document), + ) .route( "/api/w/{workspace_id}/memory/staging", get(scoped_list_memory_staging), @@ -1456,6 +1462,30 @@ async fn scoped_ticket_backend_operation( Ok(Json(response)) } +#[derive(Debug, Clone, Serialize)] +struct MemoryDocumentResponse { + body_md: String, + created_at: String, + updated_at: String, + bytes: usize, + record_source: String, +} + +async fn scoped_get_memory_document( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let document = api.authority.memory_document()?; + Ok(Json(MemoryDocumentResponse { + bytes: document.body_md.len(), + body_md: document.body_md, + created_at: document.created_at, + updated_at: document.updated_at, + record_source: document.record_source, + })) +} + async fn scoped_list_memory_staging( State(api): State, AxumPath(path): AxumPath, @@ -6603,8 +6633,8 @@ mod tests { WorkerSpawnIntent, }; use crate::store::{ - MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, ObjectiveTicketLinkRecord, - SqliteWorkspaceStore, + MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, + ObjectiveTicketLinkRecord, SqliteWorkspaceStore, }; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; @@ -8423,6 +8453,15 @@ mod tests { updated_at: "2026-01-02T00:00:00Z".to_string(), }) .unwrap(); + sqlite_store + .upsert_memory_document(&MemoryDocumentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + body_md: "# Memory\n\n## Project facts\n\n- Frontend can read this document.\n" + .to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }) + .unwrap(); sqlite_store .upsert_memory_staging_record(&MemoryStagingRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -8559,6 +8598,19 @@ mod tests { "memory-architecture-overview.md" ); + let memory_document = + get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; + assert_eq!(memory_document["created_at"], "2026-01-01T00:00:00Z"); + assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z"); + assert_eq!(memory_document["bytes"], 63); + assert_eq!(memory_document["record_source"], "workspace-sqlite"); + assert!( + memory_document["body_md"] + .as_str() + .unwrap() + .contains("Frontend can read this document.") + ); + let memory_staging = get_json( app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"), 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 da7fcd8a..c47bd87d 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 @@ -191,17 +191,26 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async ); }); -Deno.test("workspace Memory Staging surface uses read-only scoped memory API", async () => { +Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () => { const memoryNav = await Deno.readTextFile( new URL("../sidebar/MemoryNavSection.svelte", import.meta.url), ); - const memoryLoad = await Deno.readTextFile( + const memoryDocumentLoad = await Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url), + ); + const memoryDocumentPage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/memory/+page.svelte", + import.meta.url, + ), + ); + const memoryStagingLoad = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/staging/+page.ts", import.meta.url, ), ); - const memoryPage = await Deno.readTextFile( + const memoryStagingPage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", import.meta.url, @@ -209,17 +218,28 @@ Deno.test("workspace Memory Staging surface uses read-only scoped memory API", a ); assert( - memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") && + memoryNav.includes("workspaceRoute(workspaceId, '/memory')") && + memoryNav.includes("durable workspace memory") && + memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") && memoryNav.includes("pending extraction candidates"), - "Memory sidebar section should link to the workspace Memory Staging surface", + "Memory sidebar section should link to Document and Staging surfaces", ); assert( - memoryLoad.includes("workspaceApiPath(params.workspaceId") && - memoryLoad.includes('"/memory/staging"') && - memoryPage.includes("Memory Staging") && - memoryPage.includes("Workspace Server memory authority") && - memoryPage.includes("data.staging.data.invalid_count") && - memoryPage.includes("entry.record.evidence"), + memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") && + memoryDocumentLoad.includes('"/memory"') && + memoryDocumentPage.includes("Memory Document") && + memoryDocumentPage.includes("This view is read-only") && + memoryDocumentPage.includes("data.memory.data.body_md") && + memoryDocumentPage.includes("data.memory.data.updated_at"), + "Memory Document page should read the scoped API and expose the durable document without mutation controls", + ); + assert( + memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") && + memoryStagingLoad.includes('"/memory/staging"') && + memoryStagingPage.includes("Memory Staging") && + memoryStagingPage.includes("Workspace Server memory authority") && + memoryStagingPage.includes("data.staging.data.invalid_count") && + memoryStagingPage.includes("entry.record.evidence"), "Memory Staging page should read the scoped API and expose staged records without mutation controls", ); }); diff --git a/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte index 6d02df02..30540417 100644 --- a/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte +++ b/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte @@ -7,6 +7,7 @@ }; let { currentPath = '/', workspaceId }: Props = $props(); + let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory') : '/'); let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory/staging') : '/'); @@ -15,6 +16,11 @@ Memory + + Document + durable workspace memory + + Staging pending extraction candidates diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index 1e440ba8..2e3a43f0 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -318,6 +318,14 @@ export type RepositoryLogResponse = { diagnostics: Diagnostic[]; }; +export type MemoryDocumentResponse = { + body_md: string; + created_at: string; + updated_at: string; + bytes: number; + record_source: string; +}; + export type MemoryCandidateKind = | "preference" | "working_assumption" diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte new file mode 100644 index 00000000..99789aaa --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -0,0 +1,123 @@ + + + + Memory Document · Yoi Workspace + + + +
+
+
+

Workspace memory

+

Memory Document

+
+ {#if data.memory.data} + {data.memory.data.bytes} bytes + {/if} +
+ +

+ Durable workspace Memory as a single Markdown document. This view is read-only. +

+ + {#if data.memory.data} +
+
+ Updated + {formatDate(data.memory.data.updated_at)} +
+
+ Created + {formatDate(data.memory.data.created_at)} +
+
+ Lines + {lineCount} +
+
+ Source + {data.memory.data.record_source} +
+
+ + {#if data.memory.data.body_md.trim().length === 0} +

No Memory document content is present.

+ {:else} +
{data.memory.data.body_md}
+ {/if} + {:else if data.memory.error} +

{data.memory.error}

+ {:else} +

Waiting for /api/w/{data.workspaceId}/memory

+ {/if} +
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts new file mode 100644 index 00000000..21ce4191 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts @@ -0,0 +1,13 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import type { MemoryDocumentResponse } from "$lib/workspace/sidebar/types"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + return { + workspaceId: params.workspaceId, + memory: await loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/memory"), + ), + }; +};