feat: unify Memory REST DTO authority

This commit is contained in:
2026-09-03 15:54:48 +09:00
parent 8022128993
commit 5cec2eef60
15 changed files with 1232 additions and 162 deletions
+91 -4
View File
@@ -10,10 +10,10 @@ use ticket::{
};
use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail,
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkerLaunchOptionsResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -241,6 +241,17 @@ impl BackendWorkspaceProductClient {
)
}
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory")
}
pub fn list_memory_staging(
&self,
limit: usize,
) -> Result<MemoryStagingListResponse, BackendWorkspaceClientError> {
self.get_json(&format!("/memory/staging?limit={limit}"))
}
pub fn launch_ticket_intake(
&self,
ticket_id: &str,
@@ -668,6 +679,82 @@ mod tests {
(format!("http://{address}"), receiver, handle)
}
#[test]
fn memory_document_uses_shared_workspace_scoped_response() {
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.memory_document().unwrap();
assert_eq!(response.record_source, "workspace-sqlite");
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory ")
);
handle.join().unwrap();
}
#[test]
fn memory_staging_uses_shared_dto_with_typed_origin() {
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"imported_at_desc_candidate_id_asc","record_authority":"sqlite_workspace_authority.memory_staging","items":[{"id":"candidate-1","byte_len":128,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"Keep typed provenance.","why_useful":"Prevents trust loss.","staleness":null,"evidence":[],"source_refs":[{"session_id":"session-1","segment_id":"segment-1","entry_range":[1,2],"evidence_id":"evidence-1","origin":{"kind":"worker_input","workspace_id":"workspace-a","runtime_id":"runtime-1","worker_id":"worker-1"},"evidence_kind":"worker_session_entry","label":null,"summary":null}]}}],"diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.list_memory_staging(10).unwrap();
assert_eq!(
response.items[0].record.source_refs[0]
.origin
.as_ref()
.unwrap()
.kind,
workspace_api::MemoryEvidenceOriginKind::WorkerInput
);
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
);
handle.join().unwrap();
}
#[test]
fn memory_staging_rejects_unknown_origin_kind() {
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"order","record_authority":"authority","items":[{"id":"candidate-1","byte_len":1,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"claim","why_useful":"useful","staleness":null,"evidence":[],"source_refs":[{"session_id":null,"segment_id":null,"entry_range":null,"evidence_id":null,"origin":{"kind":"future_origin"},"evidence_kind":null,"label":null,"summary":null}]}}],"diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let error = client.list_memory_staging(10).unwrap_err();
assert!(matches!(error, BackendWorkspaceClientError::Http(_)));
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
);
handle.join().unwrap();
}
#[test]
fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
+2
View File
@@ -74,6 +74,7 @@ impl ExtractedPayload {
/// Bounded evidence snippet copied into a flat staging record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StagingEvidence {
pub id: String,
pub kind: EvidenceKind,
@@ -89,6 +90,7 @@ pub struct StagingEvidence {
/// One flat staging record. One record is one consolidation decision unit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StagingRecord {
pub schema_version: u32,
pub id: String,
+3
View File
@@ -22,6 +22,7 @@ impl<'de> Deserialize<'de> for SourceRef {
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSourceRef {
#[serde(default)]
segment_id: Option<String>,
@@ -83,6 +84,7 @@ pub enum EvidenceOriginKind {
/// Bounded origin snapshot attached to extraction evidence. This is audit
/// metadata only and cannot authorize Workspace operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvidenceOrigin {
pub kind: EvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -107,6 +109,7 @@ pub struct EvidenceOrigin {
/// ranges, and short labels/summaries. It must not carry raw message bodies or
/// full tool result content.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SourceEvidenceRef {
/// Stable session id when the anchor crosses or disambiguates segments.
#[serde(default, skip_serializing_if = "Option::is_none")]
+4
View File
@@ -33,6 +33,10 @@ required-features = ["typescript"]
name = "generate_companion_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_memory_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_repository_access_types"
required-features = ["typescript"]
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::memory_api_typescript());
}
+267
View File
@@ -1291,6 +1291,197 @@ pub struct WorkerRestoreResponse {
pub result: WorkerRestoreResult,
}
/// Public Workspace Memory document projection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemoryDocumentResponse {
pub body_md: String,
pub created_at: String,
pub updated_at: String,
pub bytes: usize,
pub record_source: String,
}
/// Candidate kinds exposed by the Memory staging resource.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum MemoryCandidateKind {
Preference,
WorkingAssumption,
Constraint,
Decision,
OpenQuestion,
Lesson,
}
/// Typed, bounded provenance classification for public Memory evidence anchors.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum MemoryEvidenceOriginKind {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
/// Bounded origin metadata copied from one typed Memory evidence anchor.
///
/// This is provenance only. It carries no message body, prompt, reasoning,
/// secret, tool output, or authorization authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))]
#[serde(deny_unknown_fields)]
pub struct MemoryEvidenceOrigin {
pub kind: MemoryEvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
pub flow_definition_revision: Option<u64>,
}
/// Record-level source range for one Memory staging candidate.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemorySourceRef {
pub segment_id: String,
#[cfg_attr(feature = "typescript", ts(type = "[number, number]"))]
pub range: [u64; 2],
}
/// Bounded evidence snippet included in one Memory staging record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemoryStagingEvidence {
pub id: String,
pub kind: String,
#[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "typescript",
ts(optional, type = "MemoryEvidenceOrigin | null")
)]
pub origin: Option<MemoryEvidenceOrigin>,
pub excerpt: Option<String>,
pub summary: Option<String>,
}
/// Bounded source anchor included in one Memory staging record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemorySourceEvidenceRef {
pub session_id: Option<String>,
pub segment_id: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))]
pub entry_range: Option<[u64; 2]>,
pub evidence_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "typescript",
ts(optional, type = "MemoryEvidenceOrigin | null")
)]
pub origin: Option<MemoryEvidenceOrigin>,
pub evidence_kind: Option<String>,
pub label: Option<String>,
pub summary: Option<String>,
}
/// Public projection of one valid Memory staging record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemoryStagingRecord {
pub schema_version: u32,
pub id: String,
pub extract_run_id: String,
pub source: MemorySourceRef,
pub kind: MemoryCandidateKind,
pub claim: String,
pub why_useful: String,
pub staleness: Option<String>,
pub evidence: Vec<MemoryStagingEvidence>,
pub source_refs: Vec<MemorySourceEvidenceRef>,
}
/// Public list entry for one valid Memory staging record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct MemoryStagingEntry {
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub byte_len: u64,
pub record: MemoryStagingRecord,
}
/// Public response returned by the Workspace Memory staging list resource.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
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<MemoryStagingEntry>,
pub diagnostics: Vec<Diagnostic>,
}
#[cfg(feature = "typescript")]
pub fn memory_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
DiagnosticSeverity::decl(&config),
Diagnostic::decl(&config),
MemoryDocumentResponse::decl(&config),
MemoryCandidateKind::decl(&config),
MemoryEvidenceOriginKind::decl(&config),
MemoryEvidenceOrigin::decl(&config),
MemorySourceRef::decl(&config),
MemoryStagingEvidence::decl(&config),
MemorySourceEvidenceRef::decl(&config),
MemoryStagingRecord::decl(&config),
MemoryStagingEntry::decl(&config),
MemoryStagingListResponse::decl(&config),
];
format!(
"// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
/// Workspace-owned Memory settings returned by the shared Server API.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
@@ -1603,6 +1794,36 @@ mod worker_launch_typescript_tests {
}
}
#[cfg(all(test, feature = "typescript"))]
mod memory_typescript_tests {
#[test]
fn generated_memory_api_contract_is_current() {
let expected = super::memory_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/memory-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
assert_eq!(
normalize(&actual),
normalize(&expected),
"regenerate Memory API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts` and format the generated file",
);
}
fn normalize(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect::<String>()
.replace("=|", "=")
.replace(";}", "}")
}
}
#[cfg(all(test, feature = "typescript"))]
mod workdir_typescript_tests {
#[test]
@@ -1636,6 +1857,52 @@ mod workdir_typescript_tests {
mod tests {
use super::*;
#[test]
fn memory_evidence_origins_round_trip_as_typed_provenance() {
let kinds = [
MemoryEvidenceOriginKind::HumanInput,
MemoryEvidenceOriginKind::WorkerInput,
MemoryEvidenceOriginKind::FlowInstruction,
MemoryEvidenceOriginKind::BackendInstruction,
MemoryEvidenceOriginKind::ModelOutput,
MemoryEvidenceOriginKind::ToolOutput,
MemoryEvidenceOriginKind::DerivedSummary,
MemoryEvidenceOriginKind::LegacyUnknown,
];
for kind in kinds {
let origin = MemoryEvidenceOrigin {
kind,
account_id: Some("account-1".to_string()),
workspace_id: Some("workspace-1".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: Some("worker-1".to_string()),
flow_selector: Some("builtin:coder-review".to_string()),
flow_definition_id: Some("flow-1".to_string()),
flow_definition_revision: Some(7),
};
let encoded = serde_json::to_value(&origin).unwrap();
let decoded: MemoryEvidenceOrigin = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, origin);
}
}
#[test]
fn memory_evidence_origin_rejects_unknown_kind_and_fields() {
assert!(
serde_json::from_value::<MemoryEvidenceOrigin>(
serde_json::json!({"kind": "future_origin"})
)
.is_err()
);
assert!(
serde_json::from_value::<MemoryEvidenceOrigin>(serde_json::json!({
"kind": "human_input",
"future_field": "not current schema"
}))
.is_err()
);
}
fn worker_launch_summary() -> WorkerLaunchWorkerSummary {
WorkerLaunchWorkerSummary {
runtime_id: "runtime-a".to_string(),
+179 -69
View File
@@ -1,6 +1,10 @@
use memory::extract::StagingRecord;
use memory::schema::{SourceEvidenceRef, SourceRef};
use serde::{Deserialize, Serialize};
use memory::extract::{CandidateKind, StagingRecord};
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef};
use workspace_api::{
Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin,
MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry,
MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord,
};
use crate::Result;
use crate::authority::MemoryAuthority;
@@ -8,59 +12,6 @@ use crate::authority::MemoryAuthority;
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<MemoryStagingEntrySummary>,
}
#[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<String>,
pub evidence: Vec<MemoryStagingEvidenceSummary>,
pub source_refs: Vec<MemorySourceEvidenceRefSummary>,
}
#[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<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemorySourceEvidenceRefSummary {
pub session_id: Option<String>,
pub segment_id: Option<String>,
pub entry_range: Option<[u64; 2]>,
pub evidence_id: Option<String>,
pub evidence_kind: Option<String>,
pub label: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryStagingBacklogSummary {
pub candidate_count: usize,
@@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
};
total_valid_count += 1;
if valid_items.len() < limit {
valid_items.push(MemoryStagingEntrySummary {
valid_items.push(MemoryStagingEntry {
id: entry.candidate_id,
byte_len: entry.raw_json.len() as u64,
record: memory_staging_record_summary(record),
record: memory_staging_record_projection(record),
});
}
}
let returned_count = valid_items.len();
let diagnostics = (invalid_count > 0)
.then(|| Diagnostic {
code: "memory_staging_record_invalid".to_string(),
message: format!(
"{invalid_count} Memory staging record(s) were excluded because they did not match the current schema."
),
severity: DiagnosticSeverity::Error,
})
.into_iter()
.collect();
Ok(MemoryStagingListResponse {
limit,
returned_count,
@@ -107,6 +68,7 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
order: "imported_at_desc_candidate_id_asc".to_string(),
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
items: valid_items,
diagnostics,
})
}
@@ -133,23 +95,24 @@ pub fn memory_staging_backlog_from_authority<A: MemoryAuthority>(
})
}
fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSummary {
MemoryStagingRecordSummary {
fn memory_staging_record_projection(record: StagingRecord) -> MemoryStagingRecord {
MemoryStagingRecord {
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(),
source: memory_source_ref_projection(record.source),
kind: memory_candidate_kind_projection(record.kind),
claim: record.claim,
why_useful: record.why_useful,
staleness: record.staleness,
evidence: record
.evidence
.into_iter()
.map(|evidence| MemoryStagingEvidenceSummary {
.map(|evidence| MemoryStagingEvidence {
id: evidence.id,
kind: evidence.kind.as_str().to_string(),
entry_range: evidence.entry_range,
origin: evidence.origin.map(memory_evidence_origin_projection),
excerpt: evidence.excerpt,
summary: evidence.summary,
})
@@ -157,19 +120,36 @@ fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSu
source_refs: record
.source_refs
.into_iter()
.map(memory_source_evidence_ref_summary)
.map(memory_source_evidence_ref_projection)
.collect(),
}
}
fn memory_source_evidence_ref_summary(
source_ref: SourceEvidenceRef,
) -> MemorySourceEvidenceRefSummary {
MemorySourceEvidenceRefSummary {
fn memory_source_ref_projection(source_ref: SourceRef) -> MemorySourceRef {
MemorySourceRef {
segment_id: source_ref.segment_id,
range: source_ref.range,
}
}
fn memory_candidate_kind_projection(kind: CandidateKind) -> MemoryCandidateKind {
match kind {
CandidateKind::Preference => MemoryCandidateKind::Preference,
CandidateKind::WorkingAssumption => MemoryCandidateKind::WorkingAssumption,
CandidateKind::Constraint => MemoryCandidateKind::Constraint,
CandidateKind::Decision => MemoryCandidateKind::Decision,
CandidateKind::OpenQuestion => MemoryCandidateKind::OpenQuestion,
CandidateKind::Lesson => MemoryCandidateKind::Lesson,
}
}
fn memory_source_evidence_ref_projection(source_ref: SourceEvidenceRef) -> MemorySourceEvidenceRef {
MemorySourceEvidenceRef {
session_id: source_ref.session_id,
segment_id: source_ref.segment_id,
entry_range: source_ref.entry_range,
evidence_id: source_ref.evidence_id,
origin: source_ref.origin.map(memory_evidence_origin_projection),
evidence_kind: source_ref
.evidence_kind
.map(|evidence_kind| evidence_kind.as_str().to_string()),
@@ -178,12 +158,35 @@ fn memory_source_evidence_ref_summary(
}
}
fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOrigin {
MemoryEvidenceOrigin {
kind: match origin.kind {
EvidenceOriginKind::HumanInput => MemoryEvidenceOriginKind::HumanInput,
EvidenceOriginKind::WorkerInput => MemoryEvidenceOriginKind::WorkerInput,
EvidenceOriginKind::FlowInstruction => MemoryEvidenceOriginKind::FlowInstruction,
EvidenceOriginKind::BackendInstruction => MemoryEvidenceOriginKind::BackendInstruction,
EvidenceOriginKind::ModelOutput => MemoryEvidenceOriginKind::ModelOutput,
EvidenceOriginKind::ToolOutput => MemoryEvidenceOriginKind::ToolOutput,
EvidenceOriginKind::DerivedSummary => MemoryEvidenceOriginKind::DerivedSummary,
EvidenceOriginKind::LegacyUnknown => MemoryEvidenceOriginKind::LegacyUnknown,
},
account_id: origin.account_id,
workspace_id: origin.workspace_id,
runtime_id: origin.runtime_id,
worker_id: origin.worker_id,
flow_selector: origin.flow_selector,
flow_definition_id: origin.flow_definition_id,
flow_definition_revision: origin.flow_definition_revision,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef};
use tempfile::TempDir;
fn source() -> SourceRef {
@@ -258,7 +261,114 @@ mod tests {
response.record_authority,
"sqlite_workspace_authority.memory_staging"
);
assert_eq!(response.items[0].record.kind, "decision");
assert_eq!(response.items[0].record.kind, MemoryCandidateKind::Decision);
assert!(response.diagnostics.is_empty());
}
#[test]
fn projects_every_typed_evidence_origin_without_flattening() {
let cases = [
(
EvidenceOriginKind::HumanInput,
MemoryEvidenceOriginKind::HumanInput,
),
(
EvidenceOriginKind::WorkerInput,
MemoryEvidenceOriginKind::WorkerInput,
),
(
EvidenceOriginKind::FlowInstruction,
MemoryEvidenceOriginKind::FlowInstruction,
),
(
EvidenceOriginKind::BackendInstruction,
MemoryEvidenceOriginKind::BackendInstruction,
),
(
EvidenceOriginKind::ModelOutput,
MemoryEvidenceOriginKind::ModelOutput,
),
(
EvidenceOriginKind::ToolOutput,
MemoryEvidenceOriginKind::ToolOutput,
),
(
EvidenceOriginKind::DerivedSummary,
MemoryEvidenceOriginKind::DerivedSummary,
),
(
EvidenceOriginKind::LegacyUnknown,
MemoryEvidenceOriginKind::LegacyUnknown,
),
];
for (domain_kind, api_kind) in cases {
let projected = memory_source_evidence_ref_projection(SourceEvidenceRef {
session_id: Some("session-1".to_string()),
origin: Some(EvidenceOrigin {
kind: domain_kind,
account_id: Some("account-1".to_string()),
workspace_id: Some("workspace-test".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: Some("worker-1".to_string()),
flow_selector: Some("builtin:coder-review".to_string()),
flow_definition_id: Some("flow-1".to_string()),
flow_definition_revision: Some(7),
}),
..SourceEvidenceRef::default()
});
let origin = projected.origin.unwrap();
assert_eq!(origin.kind, api_kind);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
assert_eq!(origin.workspace_id.as_deref(), Some("workspace-test"));
assert_eq!(origin.runtime_id.as_deref(), Some("runtime-1"));
assert_eq!(origin.worker_id.as_deref(), Some("worker-1"));
assert_eq!(
origin.flow_selector.as_deref(),
Some("builtin:coder-review")
);
assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1"));
assert_eq!(origin.flow_definition_revision, Some(7));
}
}
#[tokio::test]
async fn invalid_or_newer_origin_shapes_are_excluded_with_bounded_diagnostic() {
let (_temp, authority) = authority().await;
for (id, origin) in [
(
"unknown-origin-kind",
serde_json::json!({"kind": "future_origin_kind"}),
),
(
"newer-origin-shape",
serde_json::json!({"kind": "human_input", "future_field": "do not echo me"}),
),
] {
let mut record: serde_json::Value =
serde_json::from_str(&record_json(id, "claim")).unwrap();
record["source_refs"] = serde_json::json!([{"origin": origin}]);
authority
.upsert_memory_staging_record(id, &serde_json::to_string(&record).unwrap(), None)
.unwrap();
}
let response = list_memory_staging_from_authority(&authority, None).unwrap();
assert_eq!(response.returned_count, 0);
assert_eq!(response.invalid_count, 2);
assert_eq!(response.diagnostics.len(), 1);
assert_eq!(
response.diagnostics[0].code,
"memory_staging_record_invalid"
);
assert_eq!(response.diagnostics[0].severity, DiagnosticSeverity::Error);
assert!(
!response.diagnostics[0]
.message
.contains("future_origin_kind")
);
assert!(!response.diagnostics[0].message.contains("do not echo me"));
}
#[tokio::test]
+10 -15
View File
@@ -62,10 +62,11 @@ use workspace_api::{
CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest,
CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest,
CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ProfileSettingsResponse,
PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse,
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
DeleteRepositorySshHostTrustRequest, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
@@ -113,8 +114,7 @@ use crate::hosts::{
use crate::identity::WorkspaceIdentity;
use crate::memory_backend::execute_memory_backend_operation_with_authority;
use crate::memory_staging::{
MemoryStagingListResponse, list_memory_staging_from_authority,
memory_staging_backlog_from_authority,
list_memory_staging_from_authority, memory_staging_backlog_from_authority,
};
use crate::observation::{
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
@@ -7540,15 +7540,6 @@ fn find_workspace_orchestrator(api: &WorkspaceApi) -> Option<WorkerSummary> {
None
}
#[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<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -25981,6 +25972,8 @@ mod tests {
let memory_document =
get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await;
let _: workspace_api::MemoryDocumentResponse =
serde_json::from_value(memory_document.clone()).unwrap();
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);
@@ -25997,6 +25990,8 @@ mod tests {
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"),
)
.await;
let _: workspace_api::MemoryStagingListResponse =
serde_json::from_value(memory_staging.clone()).unwrap();
assert_eq!(
memory_staging["record_authority"],
"sqlite_workspace_authority.memory_staging"
@@ -0,0 +1,100 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type MemoryDocumentResponse = {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
};
export type MemoryCandidateKind =
| "preference"
| "working_assumption"
| "constraint"
| "decision"
| "open_question"
| "lesson";
export type MemoryEvidenceOriginKind =
| "human_input"
| "worker_input"
| "flow_instruction"
| "backend_instruction"
| "model_output"
| "tool_output"
| "derived_summary"
| "legacy_unknown";
export type MemoryEvidenceOrigin = {
kind: MemoryEvidenceOriginKind;
account_id?: string | null;
workspace_id?: string | null;
runtime_id?: string | null;
worker_id?: string | null;
flow_selector?: string | null;
flow_definition_id?: string | null;
flow_definition_revision?: number | null;
};
export type MemorySourceRef = { segment_id: string; range: [number, number] };
export type MemoryStagingEvidence = {
id: string;
kind: string;
entry_range: [number, number] | null;
origin?: MemoryEvidenceOrigin | 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;
origin?: MemoryEvidenceOrigin | 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: Array<MemoryStagingEvidence>;
source_refs: Array<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: Array<MemoryStagingEntry>;
diagnostics: Array<Diagnostic>;
};
@@ -0,0 +1,392 @@
import type {
Diagnostic,
DiagnosticSeverity,
MemoryCandidateKind,
MemoryDocumentResponse,
MemoryEvidenceOrigin,
MemoryEvidenceOriginKind,
MemorySourceEvidenceRef,
MemorySourceRef,
MemoryStagingEntry,
MemoryStagingEvidence,
MemoryStagingListResponse,
MemoryStagingRecord,
} from "$lib/generated/memory-api";
const MAX_STAGING_ITEMS = 500;
const MAX_EVIDENCE_PER_RECORD = 500;
const MAX_SOURCE_REFS_PER_RECORD = 500;
const MAX_ORIGIN_VALUE_LENGTH = 512;
const candidateKinds = new Set<MemoryCandidateKind>([
"preference",
"working_assumption",
"constraint",
"decision",
"open_question",
"lesson",
]);
const originKinds = new Set<MemoryEvidenceOriginKind>([
"human_input",
"worker_input",
"flow_instruction",
"backend_instruction",
"model_output",
"tool_output",
"derived_summary",
"legacy_unknown",
]);
const diagnosticSeverities = new Set<DiagnosticSeverity>([
"info",
"warning",
"error",
]);
export function parseMemoryDocumentResponse(
value: unknown,
): MemoryDocumentResponse {
const record = strictRecord(
value,
["body_md", "created_at", "updated_at", "bytes", "record_source"],
"Memory document response",
);
return {
body_md: requiredString(record, "body_md"),
created_at: requiredString(record, "created_at"),
updated_at: requiredString(record, "updated_at"),
bytes: requiredNonNegativeInteger(record, "bytes"),
record_source: requiredString(record, "record_source"),
};
}
export function parseMemoryStagingListResponse(
value: unknown,
): MemoryStagingListResponse {
const record = strictRecord(
value,
[
"limit",
"returned_count",
"total_valid_count",
"invalid_count",
"truncated",
"order",
"record_authority",
"items",
"diagnostics",
],
"Memory staging list response",
);
const items = boundedArray(record.items, MAX_STAGING_ITEMS, "items").map(
parseStagingEntry,
);
const diagnostics = boundedArray(
record.diagnostics,
MAX_STAGING_ITEMS,
"diagnostics",
).map(parseDiagnostic);
const returnedCount = requiredNonNegativeInteger(record, "returned_count");
if (returnedCount !== items.length) {
invalid("returned_count does not match items");
}
return {
limit: requiredNonNegativeInteger(record, "limit"),
returned_count: returnedCount,
total_valid_count: requiredNonNegativeInteger(record, "total_valid_count"),
invalid_count: requiredNonNegativeInteger(record, "invalid_count"),
truncated: requiredBoolean(record, "truncated"),
order: requiredString(record, "order"),
record_authority: requiredString(record, "record_authority"),
items,
diagnostics,
};
}
function parseStagingEntry(value: unknown): MemoryStagingEntry {
const record = strictRecord(
value,
["id", "byte_len", "record"],
"Memory staging entry",
);
return {
id: requiredString(record, "id"),
byte_len: requiredNonNegativeInteger(record, "byte_len"),
record: parseStagingRecord(record.record),
};
}
function parseStagingRecord(value: unknown): MemoryStagingRecord {
const record = strictRecord(
value,
[
"schema_version",
"id",
"extract_run_id",
"source",
"kind",
"claim",
"why_useful",
"staleness",
"evidence",
"source_refs",
],
"Memory staging record",
);
const kind = requiredString(record, "kind") as MemoryCandidateKind;
if (!candidateKinds.has(kind)) {
invalid("unknown Memory candidate kind");
}
return {
schema_version: requiredNonNegativeInteger(record, "schema_version"),
id: requiredString(record, "id"),
extract_run_id: requiredString(record, "extract_run_id"),
source: parseSourceRef(record.source),
kind,
claim: requiredString(record, "claim"),
why_useful: requiredString(record, "why_useful"),
staleness: nullableString(record, "staleness"),
evidence: boundedArray(
record.evidence,
MAX_EVIDENCE_PER_RECORD,
"evidence",
).map(parseStagingEvidence),
source_refs: boundedArray(
record.source_refs,
MAX_SOURCE_REFS_PER_RECORD,
"source_refs",
).map(parseSourceEvidenceRef),
};
}
function parseSourceRef(value: unknown): MemorySourceRef {
const record = strictRecord(
value,
["segment_id", "range"],
"Memory source ref",
);
return {
segment_id: requiredString(record, "segment_id"),
range: parseEntryRange(record.range, "range"),
};
}
function parseStagingEvidence(value: unknown): MemoryStagingEvidence {
const record = strictRecord(
value,
["id", "kind", "entry_range", "origin", "excerpt", "summary"],
"Memory staging evidence",
["origin"],
);
const result: MemoryStagingEvidence = {
id: requiredString(record, "id"),
kind: requiredString(record, "kind"),
entry_range: parseNullableEntryRange(record.entry_range, "entry_range"),
excerpt: nullableString(record, "excerpt"),
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef {
const record = strictRecord(
value,
[
"session_id",
"segment_id",
"entry_range",
"evidence_id",
"origin",
"evidence_kind",
"label",
"summary",
],
"Memory source evidence ref",
["origin"],
);
const result: MemorySourceEvidenceRef = {
session_id: nullableString(record, "session_id"),
segment_id: nullableString(record, "segment_id"),
entry_range: parseNullableEntryRange(record.entry_range, "entry_range"),
evidence_id: nullableString(record, "evidence_id"),
evidence_kind: nullableString(record, "evidence_kind"),
label: nullableString(record, "label"),
summary: nullableString(record, "summary"),
};
if ("origin" in record) {
result.origin = record.origin === null
? null
: parseEvidenceOrigin(record.origin);
}
return result;
}
function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
const optional = [
"account_id",
"workspace_id",
"runtime_id",
"worker_id",
"flow_selector",
"flow_definition_id",
"flow_definition_revision",
] as const;
const record = strictRecord(
value,
["kind", ...optional],
"Memory evidence origin",
[...optional],
);
const kind = requiredString(record, "kind") as MemoryEvidenceOriginKind;
if (!originKinds.has(kind)) {
invalid("unknown Memory evidence origin kind");
}
const result: MemoryEvidenceOrigin = { kind };
for (
const key of [
"account_id",
"workspace_id",
"runtime_id",
"worker_id",
"flow_selector",
"flow_definition_id",
] as const
) {
if (key in record) {
const text = nullableString(record, key);
if (text !== null && text.length > MAX_ORIGIN_VALUE_LENGTH) {
invalid(`${key} exceeds the Memory origin limit`);
}
result[key] = text;
}
}
if ("flow_definition_revision" in record) {
result.flow_definition_revision = record.flow_definition_revision === null
? null
: nonNegativeInteger(
record.flow_definition_revision,
"flow_definition_revision",
);
}
return result;
}
function parseDiagnostic(value: unknown): Diagnostic {
const record = strictRecord(
value,
["code", "severity", "message"],
"Memory diagnostic",
);
const severity = requiredString(record, "severity") as DiagnosticSeverity;
if (!diagnosticSeverities.has(severity)) {
invalid("unknown diagnostic severity");
}
return {
code: requiredString(record, "code"),
severity,
message: requiredString(record, "message"),
};
}
function strictRecord(
value: unknown,
keys: readonly string[],
label: string,
optionalKeys: readonly string[] = [],
): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalid(`${label} must be an object`);
}
const record = value as Record<string, unknown>;
const allowed = new Set(keys);
for (const key of Object.keys(record)) {
if (!allowed.has(key)) {
invalid(`${label} has an unknown field`);
}
}
const optional = new Set(optionalKeys);
for (const key of keys) {
if (!optional.has(key) && !(key in record)) {
invalid(`${label} is missing a required field`);
}
}
return record;
}
function boundedArray(
value: unknown,
maximum: number,
label: string,
): unknown[] {
if (!Array.isArray(value) || value.length > maximum) {
invalid(`${label} must be a bounded array`);
}
return value;
}
function requiredString(record: Record<string, unknown>, key: string): string {
if (typeof record[key] !== "string") {
invalid(`${key} must be a string`);
}
return record[key];
}
function nullableString(
record: Record<string, unknown>,
key: string,
): string | null {
const value = record[key];
if (value !== null && typeof value !== "string") {
invalid(`${key} must be a string or null`);
}
return value;
}
function requiredBoolean(
record: Record<string, unknown>,
key: string,
): boolean {
if (typeof record[key] !== "boolean") {
invalid(`${key} must be a boolean`);
}
return record[key];
}
function requiredNonNegativeInteger(
record: Record<string, unknown>,
key: string,
): number {
return nonNegativeInteger(record[key], key);
}
function nonNegativeInteger(value: unknown, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
invalid(`${label} must be a non-negative safe integer`);
}
return value as number;
}
function parseEntryRange(value: unknown, label: string): [number, number] {
if (!Array.isArray(value) || value.length !== 2) {
invalid(`${label} must be a two-item entry range`);
}
return [
nonNegativeInteger(value[0], label),
nonNegativeInteger(value[1], label),
];
}
function parseNullableEntryRange(
value: unknown,
label: string,
): [number, number] | null {
return value === null ? null : parseEntryRange(value, label);
}
function invalid(message: string): never {
throw new Error(`Invalid Memory API response: ${message}`);
}
@@ -206,75 +206,6 @@ export type RepositoryListResponse = SharedRepositoryListResponse;
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
export type RepositoryLogResponse = SharedRepositoryLogResponse;
export type MemoryDocumentResponse = {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
};
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 {
DerivedTicketRelation,
TicketDetail,
@@ -1,13 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryDocumentResponse } from "$lib/workspace/sidebar/types";
import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
memory: await loadJson<MemoryDocumentResponse>(
memory: await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/memory"),
undefined,
parseMemoryDocumentResponse,
),
};
};
@@ -1,5 +1,5 @@
<script lang="ts">
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/workspace/sidebar/types';
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/generated/memory-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -68,6 +68,12 @@
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
{/if}
{#each data.staging.data.diagnostics as diagnostic (diagnostic.code)}
<p class:error={diagnostic.severity === 'error'} class="section-note">
{diagnostic.message}
</p>
{/each}
{#if entries.length === 0}
<p>No Memory Staging records are present.</p>
{:else}
@@ -1,13 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { MemoryStagingListResponse } from "$lib/workspace/sidebar/types";
import { parseMemoryStagingListResponse } from "$lib/workspace/memory/api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
staging: await loadJson<MemoryStagingListResponse>(
staging: await loadJson(
fetch,
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
undefined,
parseMemoryStagingListResponse,
),
};
};
+166
View File
@@ -0,0 +1,166 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseMemoryDocumentResponse,
parseMemoryStagingListResponse,
} from "../src/lib/workspace/memory/api.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertThrows(fn: () => void, expectedMessage: string): void {
try {
fn();
} catch (error) {
if (error instanceof Error && error.message.includes(expectedMessage)) {
return;
}
throw error;
}
throw new Error(`expected function to throw ${expectedMessage}`);
}
function fixture(origin: Record<string, unknown>) {
return {
limit: 100,
returned_count: 1,
total_valid_count: 1,
invalid_count: 0,
truncated: false,
order: "imported_at_desc_candidate_id_asc",
record_authority: "sqlite_workspace_authority.memory_staging",
items: [{
id: "candidate-1",
byte_len: 128,
record: {
schema_version: 2,
id: "candidate-1",
extract_run_id: "extract-run-1",
source: { segment_id: "segment-1", range: [10, 20] },
kind: "decision",
claim: "Keep provenance typed.",
why_useful: "Prevents origin loss.",
staleness: null,
evidence: [{
id: "evidence-1",
kind: "message",
entry_range: [10, 10],
origin,
excerpt: null,
summary: "bounded summary",
}],
source_refs: [{
session_id: "session-1",
segment_id: "segment-1",
entry_range: [10, 10],
evidence_id: "evidence-1",
origin,
evidence_kind: "message",
label: "source",
summary: null,
}],
},
}],
diagnostics: [],
};
}
Deno.test("Memory document response requires the generated DTO fields", () => {
assertEquals(
parseMemoryDocumentResponse({
body_md: "# Memory\n",
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
bytes: 9,
record_source: "sqlite_workspace_authority.memory_document",
}).bytes,
9,
);
assertThrows(
() => parseMemoryDocumentResponse({ body_md: "# Memory\n" }),
"missing a required field",
);
});
for (
const [kind, fields] of [
["human_input", { account_id: "account-1" }],
[
"worker_input",
{
workspace_id: "workspace-1",
runtime_id: "runtime-1",
worker_id: "worker-1",
},
],
["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
["legacy_unknown", {}],
] as const
) {
Deno.test(`Memory staging parser preserves ${kind} origin`, () => {
const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields }));
assertEquals(parsed.items[0].record.evidence[0].origin, {
kind,
...fields,
});
assertEquals(parsed.items[0].record.source_refs[0].origin, {
kind,
...fields,
});
});
}
Deno.test("Memory staging parser preserves Flow origin fields", () => {
const origin = {
kind: "flow_instruction" as const,
workspace_id: "workspace-1",
runtime_id: "runtime-1",
worker_id: "worker-1",
flow_selector: "builtin:coder-review",
flow_definition_id: "flow-1",
flow_definition_revision: 7,
};
const parsed = parseMemoryStagingListResponse(fixture(origin));
assertEquals(parsed.items[0].record.source_refs[0].origin, origin);
});
Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => {
assertThrows(
() => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })),
"unknown Memory evidence origin kind",
);
assertThrows(
() =>
parseMemoryStagingListResponse(
fixture({ kind: "human_input", future_field: "must not be accepted" }),
),
"unknown field",
);
});
Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => {
const malformed = fixture({ kind: "legacy_unknown" });
malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [
number,
number,
];
assertThrows(
() => parseMemoryStagingListResponse(malformed),
"two-item entry range",
);
assertThrows(
() =>
parseMemoryStagingListResponse(
fixture({ kind: "worker_input", worker_id: "x".repeat(513) }),
),
"exceeds the Memory origin limit",
);
});