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"