diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 528da8cc..e0988e99 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -9,6 +9,7 @@ pub mod companion; pub mod config; pub mod hosts; pub mod identity; +pub mod memory_staging; pub mod observation; pub mod profile_settings; pub mod records; diff --git a/crates/workspace-server/src/memory_staging.rs b/crates/workspace-server/src/memory_staging.rs new file mode 100644 index 00000000..a9dffc14 --- /dev/null +++ b/crates/workspace-server/src/memory_staging.rs @@ -0,0 +1,186 @@ +use memory::WorkspaceLayout; +use memory::consolidate::{StagingEntry, list_staging_entries_snapshot}; +use memory::extract::StagingRecord; +use memory::schema::{SourceEvidenceRef, SourceRef}; +use serde::{Deserialize, Serialize}; + +const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100; +const MAX_MEMORY_STAGING_LIMIT: usize = 500; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStagingListResponse { + pub limit: usize, + pub returned_count: usize, + pub total_valid_count: usize, + pub invalid_count: usize, + pub truncated: bool, + pub order: String, + pub record_authority: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStagingEntrySummary { + pub id: String, + pub byte_len: u64, + pub record: MemoryStagingRecordSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStagingRecordSummary { + pub schema_version: u32, + pub id: String, + pub extract_run_id: String, + pub source: SourceRef, + pub kind: String, + pub claim: String, + pub why_useful: String, + pub staleness: Option, + pub evidence: Vec, + pub source_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStagingEvidenceSummary { + pub id: String, + pub kind: String, + pub entry_range: Option<[u64; 2]>, + pub excerpt: Option, + pub summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemorySourceEvidenceRefSummary { + pub session_id: Option, + pub segment_id: Option, + pub entry_range: Option<[u64; 2]>, + pub evidence_id: Option, + pub evidence_kind: Option, + pub label: Option, + pub summary: Option, +} + +pub fn list_memory_staging( + layout: &WorkspaceLayout, + requested_limit: Option, +) -> MemoryStagingListResponse { + let limit = requested_limit + .unwrap_or(DEFAULT_MEMORY_STAGING_LIMIT) + .min(MAX_MEMORY_STAGING_LIMIT); + let snapshot = list_staging_entries_snapshot(layout); + let total_valid_count = snapshot.entries.len(); + let items = snapshot + .entries + .into_iter() + .take(limit) + .map(memory_staging_entry_summary) + .collect::>(); + let returned_count = items.len(); + MemoryStagingListResponse { + limit, + returned_count, + total_valid_count, + invalid_count: snapshot.invalid_count, + truncated: total_valid_count > returned_count, + order: "uuidv7_ascending".to_string(), + record_authority: "workspace_memory_staging".to_string(), + items, + } +} + +fn memory_staging_entry_summary(entry: StagingEntry) -> MemoryStagingEntrySummary { + MemoryStagingEntrySummary { + id: entry.id.to_string(), + byte_len: entry.bytes, + record: memory_staging_record_summary(entry.record), + } +} + +fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSummary { + MemoryStagingRecordSummary { + schema_version: record.schema_version, + id: record.id, + extract_run_id: record.extract_run_id, + source: record.source, + kind: record.kind.as_str().to_string(), + claim: record.claim, + why_useful: record.why_useful, + staleness: record.staleness, + evidence: record + .evidence + .into_iter() + .map(|evidence| MemoryStagingEvidenceSummary { + id: evidence.id, + kind: evidence.kind.as_str().to_string(), + entry_range: evidence.entry_range, + excerpt: evidence.excerpt, + summary: evidence.summary, + }) + .collect(), + source_refs: record + .source_refs + .into_iter() + .map(memory_source_evidence_ref_summary) + .collect(), + } +} + +fn memory_source_evidence_ref_summary( + source_ref: SourceEvidenceRef, +) -> MemorySourceEvidenceRefSummary { + MemorySourceEvidenceRefSummary { + session_id: source_ref.session_id, + segment_id: source_ref.segment_id, + entry_range: source_ref.entry_range, + evidence_id: source_ref.evidence_id, + evidence_kind: source_ref + .evidence_kind + .map(|evidence_kind| evidence_kind.as_str().to_string()), + label: source_ref.label, + summary: source_ref.summary, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use memory::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging}; + use tempfile::TempDir; + + fn source() -> SourceRef { + SourceRef { + segment_id: "segment-1".to_string(), + range: [0, 10], + } + } + + fn payload(claim: &str) -> ExtractedPayload { + ExtractedPayload { + candidates: vec![ExtractedCandidate { + kind: CandidateKind::Decision, + claim: claim.to_string(), + why_useful: "useful for future work".to_string(), + staleness: None, + evidence_ids: Vec::new(), + }], + } + } + + #[test] + fn lists_memory_staging_records_with_cap() { + let temp = TempDir::new().unwrap(); + let layout = WorkspaceLayout::new(temp.path().to_path_buf()); + write_staging(&layout, source(), payload("first claim")).unwrap(); + write_staging(&layout, source(), payload("second claim")).unwrap(); + + let response = list_memory_staging(&layout, Some(1)); + + assert_eq!(response.limit, 1); + assert_eq!(response.returned_count, 1); + assert_eq!(response.total_valid_count, 2); + assert!(response.truncated); + assert_eq!(response.record_authority, "workspace_memory_staging"); + assert_eq!(response.items[0].record.kind, "decision"); + assert_eq!(response.items[0].record.claim, "first claim"); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5ff3a4ff..7607c577 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -56,6 +56,7 @@ use crate::hosts::{ WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerWorkspaceSummary, }; use crate::identity::WorkspaceIdentity; +use crate::memory_staging::{MemoryStagingListResponse, list_memory_staging}; use crate::observation::{ BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSource, RuntimeObservationSourceConfig, @@ -479,6 +480,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/staging", + get(scoped_list_memory_staging), + ) .route( "/api/w/{workspace_id}/memory/backend", post(scoped_memory_backend_operation), @@ -1139,6 +1144,11 @@ struct TicketKanbanQuery { limit: Option, } +#[derive(Debug, Deserialize)] +struct MemoryStagingQuery { + limit: Option, +} + #[derive(Debug, Deserialize)] struct TranscriptQuery { start: Option, @@ -1432,6 +1442,17 @@ async fn scoped_ticket_backend_operation( Ok(Json(response)) } +async fn scoped_list_memory_staging( + State(api): State, + AxumPath(path): AxumPath, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let memory_config = manifest::MemoryConfig::default(); + let layout = memory::WorkspaceLayout::resolve(&memory_config, &api.config.workspace_root); + Ok(Json(list_memory_staging(&layout, query.limit))) +} + async fn scoped_memory_backend_operation( State(api): State, AxumPath(path): AxumPath, 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 7b9d8491..308c5e6f 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 @@ -56,6 +56,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () assert( !sidebar.includes("CompanionNavSection") && sidebar.includes("TicketsNavSection") && + sidebar.includes("MemoryNavSection") && sidebar.includes("WorkersNavSection"), "standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation", ); @@ -109,6 +110,32 @@ 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 () => { + const memoryNav = await Deno.readTextFile( + new URL("../sidebar/MemoryNavSection.svelte", import.meta.url), + ); + const memoryLoad = await Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.ts", import.meta.url), + ); + const memoryPage = await Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", import.meta.url), + ); + + assert( + memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") && + memoryNav.includes("pending extraction candidates"), + "Memory sidebar section should link to the workspace Memory Staging surface", + ); + assert( + memoryLoad.includes("workspaceApiPath(params.workspaceId, '/memory/staging')") && + memoryPage.includes("Memory Staging") && + memoryPage.includes("Workspace Server memory authority") && + memoryPage.includes("data.staging.data.invalid_count") && + memoryPage.includes("entry.record.evidence"), + "Memory Staging page should read the scoped API and expose staged records without mutation controls", + ); +}); + Deno.test("root layout does not keep legacy unscoped route compatibility", async () => { const layoutLoad = await Deno.readTextFile( new URL("./../../../routes/+layout.ts", import.meta.url), diff --git a/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte new file mode 100644 index 00000000..6d02df02 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/MemoryNavSection.svelte @@ -0,0 +1,22 @@ + + + diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index cd910725..b808a781 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -2,6 +2,7 @@ import { workspaceRoute } from '$lib/workspace/api/http'; import './sidebar.css'; import ObjectivesNavSection from './ObjectivesNavSection.svelte'; + import MemoryNavSection from './MemoryNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; @@ -78,6 +79,7 @@ diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index b3eceb79..4c3affaf 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -318,6 +318,67 @@ export type RepositoryLogResponse = { diagnostics: Diagnostic[]; }; +export type MemoryCandidateKind = + | 'preference' + | 'working_assumption' + | 'constraint' + | 'decision' + | 'open_question' + | 'lesson'; + +export type MemorySourceRef = { + segment_id: string; + range: [number, number]; +}; + +export type MemoryStagingEvidence = { + id: string; + kind: string; + entry_range?: [number, number] | null; + excerpt?: string | null; + summary?: string | null; +}; + +export type MemorySourceEvidenceRef = { + session_id?: string | null; + segment_id?: string | null; + entry_range?: [number, number] | null; + evidence_id?: string | null; + evidence_kind?: string | null; + label?: string | null; + summary?: string | null; +}; + +export type MemoryStagingRecord = { + schema_version: number; + id: string; + extract_run_id: string; + source: MemorySourceRef; + kind: MemoryCandidateKind; + claim: string; + why_useful: string; + staleness?: string | null; + evidence?: MemoryStagingEvidence[]; + source_refs?: MemorySourceEvidenceRef[]; +}; + +export type MemoryStagingEntry = { + id: string; + byte_len: number; + record: MemoryStagingRecord; +}; + +export type MemoryStagingListResponse = { + limit: number; + returned_count: number; + total_valid_count: number; + invalid_count: number; + truncated: boolean; + order: string; + record_authority: string; + items: MemoryStagingEntry[]; +}; + export type TicketSummary = { id: string; title: string; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte new file mode 100644 index 00000000..3cc98778 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte @@ -0,0 +1,298 @@ + + + + Memory Staging · Yoi Workspace + + + +
+
+
+

Workspace memory

+

Memory Staging

+
+ {#if data.staging.data} + {data.staging.data.returned_count} / {data.staging.data.total_valid_count} staged + {/if} +
+ +

+ Pending Memory extraction candidates staged for consolidation. This view is read-only and uses the Workspace Server memory authority. +

+ + {#if data.staging.data} +
+
+ Valid records + {data.staging.data.total_valid_count} +
+
+ Invalid records + {data.staging.data.invalid_count} +
+
+ Order + {data.staging.data.order} +
+
+ Authority + {data.staging.data.record_authority} +
+
+ + {#if data.staging.data.truncated} +

Showing first {data.staging.data.limit} staged record(s).

+ {/if} + + {#if entries.length === 0} +

No Memory Staging records are present.

+ {:else} +
+ {#each entries as entry (entry.id)} +
+
+
+ {kindLabel(entry.record.kind)} +

{entry.record.claim}

+
+ {entry.id} +
+ +

{entry.record.why_useful}

+ + {#if entry.record.staleness} +

Staleness: {entry.record.staleness}

+ {/if} + +
+
+
Extract run
+
{entry.record.extract_run_id}
+
+
+
Source
+
{sourceLabel(entry.record)}
+
+
+
Evidence
+
{evidenceCount(entry)}
+
+
+
Source refs
+
{sourceRefCount(entry)}
+
+
+
Bytes
+
{entry.byte_len}
+
+
+ + {#if entry.record.evidence && entry.record.evidence.length > 0} +
+ Evidence +
    + {#each entry.record.evidence as evidence (evidence.id)} +
  • + {evidence.id} + {evidence.kind} + {#if evidence.summary} +

    {evidence.summary}

    + {/if} + {#if evidence.excerpt} +
    {evidence.excerpt}
    + {/if} +
  • + {/each} +
+
+ {/if} +
+ {/each} +
+ {/if} + {:else if data.staging.error} +

{data.staging.error}

+ {:else} +

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

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