feat: unify Memory REST DTO authority
This commit is contained in:
@@ -10,10 +10,10 @@ use ticket::{
|
|||||||
};
|
};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
WorkerLaunchOptionsResponse,
|
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
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(
|
pub fn launch_ticket_intake(
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
@@ -668,6 +679,82 @@ mod tests {
|
|||||||
(format!("http://{address}"), receiver, handle)
|
(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]
|
#[test]
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ impl ExtractedPayload {
|
|||||||
|
|
||||||
/// Bounded evidence snippet copied into a flat staging record.
|
/// Bounded evidence snippet copied into a flat staging record.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StagingEvidence {
|
pub struct StagingEvidence {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind: EvidenceKind,
|
pub kind: EvidenceKind,
|
||||||
@@ -89,6 +90,7 @@ pub struct StagingEvidence {
|
|||||||
|
|
||||||
/// One flat staging record. One record is one consolidation decision unit.
|
/// One flat staging record. One record is one consolidation decision unit.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StagingRecord {
|
pub struct StagingRecord {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ impl<'de> Deserialize<'de> for SourceRef {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
struct RawSourceRef {
|
struct RawSourceRef {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
segment_id: Option<String>,
|
segment_id: Option<String>,
|
||||||
@@ -83,6 +84,7 @@ pub enum EvidenceOriginKind {
|
|||||||
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
||||||
/// metadata only and cannot authorize Workspace operations.
|
/// metadata only and cannot authorize Workspace operations.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct EvidenceOrigin {
|
pub struct EvidenceOrigin {
|
||||||
pub kind: EvidenceOriginKind,
|
pub kind: EvidenceOriginKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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
|
/// ranges, and short labels/summaries. It must not carry raw message bodies or
|
||||||
/// full tool result content.
|
/// full tool result content.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SourceEvidenceRef {
|
pub struct SourceEvidenceRef {
|
||||||
/// Stable session id when the anchor crosses or disambiguates segments.
|
/// Stable session id when the anchor crosses or disambiguates segments.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ required-features = ["typescript"]
|
|||||||
name = "generate_companion_api_types"
|
name = "generate_companion_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_memory_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_repository_access_types"
|
name = "generate_repository_access_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
print!("{}", workspace_api::memory_api_typescript());
|
||||||
|
}
|
||||||
@@ -1291,6 +1291,197 @@ pub struct WorkerRestoreResponse {
|
|||||||
pub result: WorkerRestoreResult,
|
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.
|
/// Workspace-owned Memory settings returned by the shared Server API.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[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"))]
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
mod workdir_typescript_tests {
|
mod workdir_typescript_tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1636,6 +1857,52 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn worker_launch_summary() -> WorkerLaunchWorkerSummary {
|
||||||
WorkerLaunchWorkerSummary {
|
WorkerLaunchWorkerSummary {
|
||||||
runtime_id: "runtime-a".to_string(),
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use memory::extract::StagingRecord;
|
use memory::extract::{CandidateKind, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use workspace_api::{
|
||||||
|
Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin,
|
||||||
|
MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry,
|
||||||
|
MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::authority::MemoryAuthority;
|
use crate::authority::MemoryAuthority;
|
||||||
@@ -8,59 +12,6 @@ use crate::authority::MemoryAuthority;
|
|||||||
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
|
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
|
||||||
const MAX_MEMORY_STAGING_LIMIT: usize = 500;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct MemoryStagingBacklogSummary {
|
pub struct MemoryStagingBacklogSummary {
|
||||||
pub candidate_count: usize,
|
pub candidate_count: usize,
|
||||||
@@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
|
|||||||
};
|
};
|
||||||
total_valid_count += 1;
|
total_valid_count += 1;
|
||||||
if valid_items.len() < limit {
|
if valid_items.len() < limit {
|
||||||
valid_items.push(MemoryStagingEntrySummary {
|
valid_items.push(MemoryStagingEntry {
|
||||||
id: entry.candidate_id,
|
id: entry.candidate_id,
|
||||||
byte_len: entry.raw_json.len() as u64,
|
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 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 {
|
Ok(MemoryStagingListResponse {
|
||||||
limit,
|
limit,
|
||||||
returned_count,
|
returned_count,
|
||||||
@@ -107,6 +68,7 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
|
|||||||
order: "imported_at_desc_candidate_id_asc".to_string(),
|
order: "imported_at_desc_candidate_id_asc".to_string(),
|
||||||
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
|
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
|
||||||
items: valid_items,
|
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 {
|
fn memory_staging_record_projection(record: StagingRecord) -> MemoryStagingRecord {
|
||||||
MemoryStagingRecordSummary {
|
MemoryStagingRecord {
|
||||||
schema_version: record.schema_version,
|
schema_version: record.schema_version,
|
||||||
id: record.id,
|
id: record.id,
|
||||||
extract_run_id: record.extract_run_id,
|
extract_run_id: record.extract_run_id,
|
||||||
source: record.source,
|
source: memory_source_ref_projection(record.source),
|
||||||
kind: record.kind.as_str().to_string(),
|
kind: memory_candidate_kind_projection(record.kind),
|
||||||
claim: record.claim,
|
claim: record.claim,
|
||||||
why_useful: record.why_useful,
|
why_useful: record.why_useful,
|
||||||
staleness: record.staleness,
|
staleness: record.staleness,
|
||||||
evidence: record
|
evidence: record
|
||||||
.evidence
|
.evidence
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|evidence| MemoryStagingEvidenceSummary {
|
.map(|evidence| MemoryStagingEvidence {
|
||||||
id: evidence.id,
|
id: evidence.id,
|
||||||
kind: evidence.kind.as_str().to_string(),
|
kind: evidence.kind.as_str().to_string(),
|
||||||
entry_range: evidence.entry_range,
|
entry_range: evidence.entry_range,
|
||||||
|
origin: evidence.origin.map(memory_evidence_origin_projection),
|
||||||
excerpt: evidence.excerpt,
|
excerpt: evidence.excerpt,
|
||||||
summary: evidence.summary,
|
summary: evidence.summary,
|
||||||
})
|
})
|
||||||
@@ -157,19 +120,36 @@ fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSu
|
|||||||
source_refs: record
|
source_refs: record
|
||||||
.source_refs
|
.source_refs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(memory_source_evidence_ref_summary)
|
.map(memory_source_evidence_ref_projection)
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory_source_evidence_ref_summary(
|
fn memory_source_ref_projection(source_ref: SourceRef) -> MemorySourceRef {
|
||||||
source_ref: SourceEvidenceRef,
|
MemorySourceRef {
|
||||||
) -> MemorySourceEvidenceRefSummary {
|
segment_id: source_ref.segment_id,
|
||||||
MemorySourceEvidenceRefSummary {
|
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,
|
session_id: source_ref.session_id,
|
||||||
segment_id: source_ref.segment_id,
|
segment_id: source_ref.segment_id,
|
||||||
entry_range: source_ref.entry_range,
|
entry_range: source_ref.entry_range,
|
||||||
evidence_id: source_ref.evidence_id,
|
evidence_id: source_ref.evidence_id,
|
||||||
|
origin: source_ref.origin.map(memory_evidence_origin_projection),
|
||||||
evidence_kind: source_ref
|
evidence_kind: source_ref
|
||||||
.evidence_kind
|
.evidence_kind
|
||||||
.map(|evidence_kind| evidence_kind.as_str().to_string()),
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
|
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
|
||||||
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
|
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
|
||||||
|
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn source() -> SourceRef {
|
fn source() -> SourceRef {
|
||||||
@@ -258,7 +261,114 @@ mod tests {
|
|||||||
response.record_authority,
|
response.record_authority,
|
||||||
"sqlite_workspace_authority.memory_staging"
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -62,10 +62,11 @@ use workspace_api::{
|
|||||||
CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest,
|
CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest,
|
||||||
CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest,
|
CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest,
|
||||||
CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest,
|
CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
|
DeleteRepositorySshHostTrustRequest, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ProfileSettingsResponse,
|
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
ObjectiveStateRequest, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||||
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
||||||
|
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
||||||
RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary,
|
RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
|
UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
|
||||||
@@ -113,8 +114,7 @@ use crate::hosts::{
|
|||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||||
use crate::memory_staging::{
|
use crate::memory_staging::{
|
||||||
MemoryStagingListResponse, list_memory_staging_from_authority,
|
list_memory_staging_from_authority, memory_staging_backlog_from_authority,
|
||||||
memory_staging_backlog_from_authority,
|
|
||||||
};
|
};
|
||||||
use crate::observation::{
|
use crate::observation::{
|
||||||
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
||||||
@@ -7540,15 +7540,6 @@ fn find_workspace_orchestrator(api: &WorkspaceApi) -> Option<WorkerSummary> {
|
|||||||
None
|
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(
|
async fn scoped_get_memory_document(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -25981,6 +25972,8 @@ mod tests {
|
|||||||
|
|
||||||
let memory_document =
|
let memory_document =
|
||||||
get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await;
|
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["created_at"], "2026-01-01T00:00:00Z");
|
||||||
assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z");
|
assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z");
|
||||||
assert_eq!(memory_document["bytes"], 63);
|
assert_eq!(memory_document["bytes"], 63);
|
||||||
@@ -25997,6 +25990,8 @@ mod tests {
|
|||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
let _: workspace_api::MemoryStagingListResponse =
|
||||||
|
serde_json::from_value(memory_staging.clone()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
memory_staging["record_authority"],
|
memory_staging["record_authority"],
|
||||||
"sqlite_workspace_authority.memory_staging"
|
"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 RepositoryDetailResponse = SharedRepositoryDetailResponse;
|
||||||
export type RepositoryLogResponse = SharedRepositoryLogResponse;
|
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 {
|
export type {
|
||||||
DerivedTicketRelation,
|
DerivedTicketRelation,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
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";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
memory: await loadJson<MemoryDocumentResponse>(
|
memory: await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/memory"),
|
workspaceApiPath(params.workspaceId, "/memory"),
|
||||||
|
undefined,
|
||||||
|
parseMemoryDocumentResponse,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -68,6 +68,12 @@
|
|||||||
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
|
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
|
||||||
{/if}
|
{/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}
|
{#if entries.length === 0}
|
||||||
<p>No Memory Staging records are present.</p>
|
<p>No Memory Staging records are present.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
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";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
staging: await loadJson<MemoryStagingListResponse>(
|
staging: await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
|
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
|
||||||
|
undefined,
|
||||||
|
parseMemoryStagingListResponse,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user