feat: cut over Memory to subjektiv product surfaces
This commit is contained in:
@@ -3,8 +3,11 @@ use server_api::{
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryDetailQuery, SubjektivMemoryListQuery,
|
||||
SubjektivMemoryListRevisionsResponse, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivMemoryRevisionsQuery, SubjektivResidentSurfaceResponse,
|
||||
SubjektivSubjectListQuery, SubjektivSubjectListResponse, SubjektivSubjectResponse,
|
||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
@@ -410,6 +413,94 @@ impl BackendWorkspaceProductClient {
|
||||
crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_subjects(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<SubjektivSubjectListResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_subject_list(
|
||||
workspace_id,
|
||||
SubjektivSubjectListQuery { limit: Some(limit) },
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_subject(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivSubjectResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client.subjektiv_subject_get(workspace_id, subject_id).await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_resident_surface(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivResidentSurfaceResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_resident_surface(workspace_id, subject_id)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memories(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
query: &SubjektivMemoryListQuery,
|
||||
) -> Result<SubjektivMemoryQueryResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_list(workspace_id, subject_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_memory(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryDetailQuery,
|
||||
) -> Result<SubjektivMemoryReadResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_detail(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memory_revisions(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryRevisionsQuery,
|
||||
) -> Result<SubjektivMemoryListRevisionsResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_revisions(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn launch_ticket_intake(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
@@ -917,6 +1008,45 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() {
|
||||
let (base_url, request, handle) =
|
||||
one_response_server("200 OK", r#"{"items":[],"has_more":false}"#);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = client
|
||||
.list_subjektiv_memories(
|
||||
"subject/a",
|
||||
&SubjektivMemoryListQuery {
|
||||
query: Some("stable cursor".to_string()),
|
||||
kinds: None,
|
||||
states: Some(vec![
|
||||
server_api::SubjektivMemoryState::Active,
|
||||
server_api::SubjektivMemoryState::Resolved,
|
||||
]),
|
||||
limit: Some(25),
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?")
|
||||
);
|
||||
assert!(request.contains("query=stable+cursor"));
|
||||
assert!(request.contains("states=active%2Cresolved"));
|
||||
assert!(request.contains("limit=25"));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#;
|
||||
|
||||
@@ -1241,6 +1241,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolve = |name: &str| {
|
||||
ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, name),
|
||||
ProfileResolveOptions::with_worker_name(format!("{name}-worker")),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest
|
||||
};
|
||||
|
||||
for name in ["coder", "companion", "intake", "reviewer", "orchestrator"] {
|
||||
let manifest = resolve(name);
|
||||
assert!(
|
||||
manifest.feature.subjektiv.profile.enabled,
|
||||
"{name} must enable subjektiv"
|
||||
);
|
||||
assert!(
|
||||
manifest.feature.subjektiv.profile.extraction.enabled,
|
||||
"{name} must enable subject extraction"
|
||||
);
|
||||
assert!(
|
||||
!manifest.feature.memory.profile.enabled,
|
||||
"{name} must not enable legacy Workspace Memory"
|
||||
);
|
||||
}
|
||||
|
||||
for name in ["default", "standalone", "backend-job"] {
|
||||
let manifest = resolve(name);
|
||||
assert!(!manifest.feature.memory.profile.enabled, "{name}");
|
||||
assert!(!manifest.feature.subjektiv.profile.enabled, "{name}");
|
||||
}
|
||||
|
||||
let legacy_consolidator = resolve("memory-consolidation");
|
||||
assert!(legacy_consolidator.feature.memory.profile.enabled);
|
||||
assert!(legacy_consolidator.feature.memory.profile.staging_tools);
|
||||
assert!(
|
||||
legacy_consolidator
|
||||
.feature
|
||||
.memory
|
||||
.profile
|
||||
.extraction
|
||||
.enabled
|
||||
);
|
||||
assert!(
|
||||
legacy_consolidator
|
||||
.feature
|
||||
.memory
|
||||
.profile
|
||||
.consolidation
|
||||
.request_enabled
|
||||
);
|
||||
assert!(!legacy_consolidator.feature.subjektiv.profile.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_profiles_pin_role_models_and_reasoning() {
|
||||
use crate::model::{ReasoningControl, ReasoningEffort};
|
||||
@@ -1298,7 +1356,7 @@ mod tests {
|
||||
role.manifest.engine.reasoning,
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::High))
|
||||
);
|
||||
let extraction = &role.manifest.feature.memory.profile.extraction;
|
||||
let extraction = &role.manifest.feature.subjektiv.profile.extraction;
|
||||
assert_eq!(
|
||||
extraction
|
||||
.model
|
||||
@@ -1341,12 +1399,10 @@ mod tests {
|
||||
let manifest = resolved.manifest;
|
||||
let feature = &manifest.feature;
|
||||
|
||||
assert!(feature.memory.profile.enabled);
|
||||
assert!(feature.memory.profile.staging_tools);
|
||||
assert!(!feature.memory.profile.resident.inject_summary);
|
||||
assert!(!feature.memory.profile.extraction.enabled);
|
||||
assert!(!feature.memory.profile.consolidation.request_enabled);
|
||||
assert!(!feature.subjektiv.profile.enabled);
|
||||
assert!(!feature.memory.profile.enabled);
|
||||
assert!(!feature.memory.profile.staging_tools);
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert!(!feature.subjektiv.profile.extraction.enabled);
|
||||
assert!(!feature.task.enabled);
|
||||
assert!(!feature.web.enabled);
|
||||
assert!(!feature.image.enabled);
|
||||
@@ -1518,7 +1574,8 @@ mod tests {
|
||||
panic!("unexpected error: {error}");
|
||||
};
|
||||
assert_eq!(target, ProfileExecutionTarget::Standalone);
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv));
|
||||
assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
|
||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
||||
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
||||
|
||||
@@ -228,6 +228,8 @@ impl_openapi_schema!(
|
||||
FlowSourceResolveRequest,
|
||||
ResolvedFlowSource,
|
||||
MemoryDocumentResponse,
|
||||
LegacyMemoryResetRequest,
|
||||
LegacyMemoryResetResponse,
|
||||
MemoryStagingQuery,
|
||||
MemoryStagingListResponse,
|
||||
MemoryBackendRequest,
|
||||
@@ -235,7 +237,18 @@ impl_openapi_schema!(
|
||||
MemoryConsolidateStagingRequest,
|
||||
MemoryConsolidationResponse,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivMemoryDetailQuery,
|
||||
SubjektivMemoryRevisionsQuery,
|
||||
SubjektivMemoryListQuery,
|
||||
SubjektivMemoryQueryRequest,
|
||||
SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse,
|
||||
SubjektivMemoryListRevisionsResponse,
|
||||
SubjektivSubjectWorkerStartRequest,
|
||||
SubjektivStageCandidateRequest,
|
||||
SubjektivStageCandidateResponse,
|
||||
@@ -1056,6 +1069,21 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<MemoryDocumentResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/memory/reset",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn legacy_memory_reset(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: LegacyMemoryResetRequest,
|
||||
) -> Result<LegacyMemoryResetResponse, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/memory/staging",
|
||||
status = 200,
|
||||
@@ -1110,6 +1138,19 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: SubjektivSubjectCreateRequest,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_subject_list(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: SubjektivSubjectListQuery,
|
||||
) -> Result<SubjektivSubjectListResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}",
|
||||
status = 200,
|
||||
@@ -1123,6 +1164,63 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_resident_surface(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
) -> Result<SubjektivResidentSurfaceResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 409, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_memory_list(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
#[query] query: SubjektivMemoryListQuery,
|
||||
) -> Result<SubjektivMemoryQueryResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_memory_detail(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
#[path] memory_id: String,
|
||||
#[query] query: SubjektivMemoryDetailQuery,
|
||||
) -> Result<SubjektivMemoryReadResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/memories/{memory_id}/revisions",
|
||||
status = 200,
|
||||
error_status = 404,
|
||||
additional_error_statuses = [400, 401, 403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_memory_revisions(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
#[path] memory_id: String,
|
||||
#[query] query: SubjektivMemoryRevisionsQuery,
|
||||
) -> Result<SubjektivMemoryListRevisionsResponse, RepositoryApiError>;
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire",
|
||||
status = 200,
|
||||
@@ -4499,6 +4597,25 @@ pub struct SubjektivSubjectCreateRequest {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectListQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectListResponse {
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
pub limit: usize,
|
||||
pub items: Vec<SubjektivSubjectResponse>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectResponse {
|
||||
@@ -4515,6 +4632,150 @@ pub struct SubjektivSubjectResponse {
|
||||
pub current_worker: Option<WorkerLaunchWorkerSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubjektivResidentSurfaceAvailability {
|
||||
Ungenerated,
|
||||
Stale,
|
||||
Failed,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivResidentSurfaceSnapshot {
|
||||
pub snapshot_id: String,
|
||||
pub body_md: String,
|
||||
#[serde(default)]
|
||||
pub memory_refs: Vec<SubjektivMemoryRevisionRef>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub built_from_store_revision: u64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivResidentSurfaceResponse {
|
||||
pub subject_id: String,
|
||||
pub availability: SubjektivResidentSurfaceAvailability,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot: Option<SubjektivResidentSurfaceSnapshot>,
|
||||
}
|
||||
|
||||
fn serialize_optional_query_enum_list<T, S>(
|
||||
value: &Option<Vec<T>>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Serialize,
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let Some(items) = value else {
|
||||
return serializer.serialize_none();
|
||||
};
|
||||
let mut encoded = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match serde_json::to_value(item).map_err(serde::ser::Error::custom)? {
|
||||
serde_json::Value::String(value) => encoded.push(value),
|
||||
_ => {
|
||||
return Err(serde::ser::Error::custom(
|
||||
"query-list values must serialize as strings",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
serializer.serialize_some(&encoded.join(","))
|
||||
}
|
||||
|
||||
fn deserialize_optional_query_enum_list<'de, T, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<T>>, D::Error>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let Some(encoded) = Option::<String>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
encoded
|
||||
.split(',')
|
||||
.map(|value| {
|
||||
serde_json::from_value(serde_json::Value::String(value.to_string()))
|
||||
.map_err(serde::de::Error::custom)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// Browser GET query for committed subject Memory. Enum lists use a bounded,
|
||||
/// comma-separated scalar representation so URL-form encoding stays portable.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivMemoryListQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub query: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
serialize_with = "serialize_optional_query_enum_list",
|
||||
deserialize_with = "deserialize_optional_query_enum_list"
|
||||
)]
|
||||
pub kinds: Option<Vec<memory::extract::CandidateKind>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
serialize_with = "serialize_optional_query_enum_list",
|
||||
deserialize_with = "deserialize_optional_query_enum_list"
|
||||
)]
|
||||
pub states: Option<Vec<SubjektivMemoryState>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SubjektivMemoryListQuery> for SubjektivMemoryQueryRequest {
|
||||
fn from(value: SubjektivMemoryListQuery) -> Self {
|
||||
Self {
|
||||
query: value.query,
|
||||
kinds: value.kinds,
|
||||
states: value.states,
|
||||
limit: value.limit,
|
||||
cursor: value.cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivMemoryDetailQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub offset: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub byte_offset: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1, max = 1000))]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub evidence_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivMemoryRevisionsQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectWorkerStartRequest {
|
||||
@@ -5197,6 +5458,7 @@ pub struct SubjektivSurfaceFailureResponse {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendOperation {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||
Query(SubjektivMemoryQueryRequest),
|
||||
Read(SubjektivMemoryReadRequest),
|
||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||
@@ -5368,6 +5630,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendResponse {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||
Query(SubjektivMemoryQueryResponse),
|
||||
Read(SubjektivMemoryReadResponse),
|
||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||
@@ -9364,6 +9627,43 @@ pub struct MemoryDocumentResponse {
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
pub const LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||
|
||||
fn deserialize_legacy_memory_reset_confirmation<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > LEGACY_MEMORY_RESET_MAX_CONFIRMATION_BYTES {
|
||||
return Err(serde::de::Error::custom(
|
||||
"legacy Memory reset confirmation is too long",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Explicit destructive reset request. The confirmation must exactly equal
|
||||
/// `RESET LEGACY MEMORY <workspace_id>` for the Workspace named in the route.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LegacyMemoryResetRequest {
|
||||
#[serde(deserialize_with = "deserialize_legacy_memory_reset_confirmation")]
|
||||
#[schemars(length(max = 256))]
|
||||
pub confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LegacyMemoryResetResponse {
|
||||
pub workspace_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub documents_deleted: usize,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub staging_records_deleted: usize,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub staging_resolutions_deleted: usize,
|
||||
}
|
||||
|
||||
/// Candidate kinds exposed by the Memory staging resource.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -9541,12 +9841,47 @@ pub fn memory_api_typescript() -> String {
|
||||
MemoryStagingEntry::decl(&config),
|
||||
MemoryStagingListResponse::decl(&config),
|
||||
];
|
||||
// These browser DTOs include nested types owned by `memory` and the Worker
|
||||
// launch API. Keep their exported names explicit so the generated Web
|
||||
// contract remains stable without requiring those crates to depend on
|
||||
// `ts-rs` solely for this read-only projection.
|
||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryState = "active" | "resolved" | "retracted";
|
||||
|
||||
export type SubjektivMemoryRevisionRef = { memory_id: string, revision: number, };
|
||||
|
||||
export type SubjektivResidentSurfaceAvailability = "ungenerated" | "stale" | "failed" | "ready";
|
||||
|
||||
export type SubjektivResidentSurfaceSnapshot = { snapshot_id: string, body_md: string, memory_refs: Array<SubjektivMemoryRevisionRef>, built_from_store_revision: number, created_at: string, };
|
||||
|
||||
export type SubjektivResidentSurfaceResponse = { subject_id: string, availability: SubjektivResidentSurfaceAvailability, snapshot?: SubjektivResidentSurfaceSnapshot | null, };
|
||||
|
||||
export type SubjektivMemoryQueryItem = { id: string, revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, excerpt: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryQueryResponse = { items: Array<SubjektivMemoryQueryItem>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryEvidence = { id: string, kind: string, entry_range?: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt?: string | null, summary?: string | null, };
|
||||
|
||||
export type SubjektivMemorySourceEvidenceRef = { 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 SubjektivMemoryEvidenceCandidate = { candidate_id: string, evidence: Array<SubjektivMemoryEvidence>, evidence_total: number, evidence_truncated: boolean, source_refs: Array<SubjektivMemorySourceEvidenceRef>, source_refs_total: number, source_refs_truncated: boolean, };
|
||||
|
||||
export type SubjektivMemoryReadResponse = { memory_id: string, revision: number, current_revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, body_md: string, why_useful: string, staleness?: string | null, change_reason: string, created_at: string, updated_at: string, body_offset: number, body_byte_offset: number, body_next_offset?: number | null, body_next_byte_offset?: number | null, body_truncated: boolean, source_candidate_ids: Array<string>, source_candidates: Array<SubjektivMemoryEvidenceCandidate>, derived_from: Array<SubjektivMemoryRevisionRef>, evidence_next_cursor?: string | null, evidence_has_more: boolean, };
|
||||
|
||||
export type SubjektivMemoryRevisionItem = { revision: number, kind: MemoryCandidateKind, state: SubjektivMemoryState, claim: string, change_reason: string, updated_at: string, };
|
||||
|
||||
export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_revision: number, items: Array<SubjektivMemoryRevisionItem>, next_cursor?: string | null, has_more: boolean, };"#;
|
||||
|
||||
let limits = format!(
|
||||
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
|
||||
);
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
|
||||
@@ -784,10 +784,12 @@ impl WorkerController {
|
||||
// the actual registered set instead of a hand-maintained mirror.
|
||||
worker.engine().tool_server_handle().flush_pending();
|
||||
|
||||
// Runtime-owned fresh Workers persist their initial model-visible head
|
||||
// before controller exposure. Feature installation above must happen first
|
||||
// so the resident contribution is loaded into that durable prompt. Restored
|
||||
// Workers retain their established first-turn refresh lifecycle.
|
||||
// Runtime-owned Workers persist their current model-visible head before
|
||||
// controller exposure. Feature installation above must happen first so a
|
||||
// fresh subject Worker captures the current resident surface in its durable
|
||||
// prompt. Restored Workers defer their one-shot append-only resident refresh
|
||||
// until the next model-visible context materialization, after the Runtime has
|
||||
// committed restoration of the existing durable head.
|
||||
if runtime_managed && worker.needs_initial_session_head_materialization() {
|
||||
worker
|
||||
.materialize_durable_session_head()
|
||||
@@ -1179,18 +1181,17 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||
// per-item commit channel is wired at the top of this function.
|
||||
}
|
||||
|
||||
fn validate_automatic_memory_extraction_targets(
|
||||
fn validate_memory_lifecycle_targets(
|
||||
memory: &manifest::MemoryFeatureProfileConfig,
|
||||
subjektiv: &manifest::SubjektivFeatureProfileConfig,
|
||||
) -> std::io::Result<()> {
|
||||
if memory.enabled
|
||||
&& memory.extraction.enabled
|
||||
&& subjektiv.enabled
|
||||
&& subjektiv.extraction.enabled
|
||||
{
|
||||
let legacy_lifecycle =
|
||||
memory.enabled && (memory.extraction.enabled || memory.consolidation.request_enabled);
|
||||
let subjektiv_lifecycle = subjektiv.enabled && subjektiv.extraction.enabled;
|
||||
if legacy_lifecycle && subjektiv_lifecycle {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker",
|
||||
"Workspace Memory and subjektiv lifecycles cannot both be enabled for one Worker",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1307,56 +1308,75 @@ where
|
||||
}
|
||||
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||
validate_automatic_memory_extraction_targets(memory_profile, subjektiv_profile)?;
|
||||
validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?;
|
||||
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
(
|
||||
plan.resident_summary_source.clone(),
|
||||
plan.system_prompt_override.clone(),
|
||||
)
|
||||
});
|
||||
let memory_lifecycle_config = memory_install_plan
|
||||
.as_ref()
|
||||
.map(|plan| plan.resolved_config.clone());
|
||||
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
|
||||
if let Some(plan) = memory_install_plan {
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
if let Some(subjektiv_memory) =
|
||||
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
let subjektiv_consolidation_plan =
|
||||
crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
if let Some(plan) = subjektiv_consolidation_plan {
|
||||
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
let ordinary_subjektiv_features_enabled =
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest());
|
||||
if let Some(resident_summary_source) =
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_prompt_contribution = Some((Some(resident_summary_source), None));
|
||||
}
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_memory) =
|
||||
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_memory);
|
||||
}
|
||||
if let Some(subjektiv_sessions) =
|
||||
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_sessions) =
|
||||
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_sessions);
|
||||
}
|
||||
if let Some(memory_config) = memory_lifecycle_config
|
||||
&& let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
memory_config,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
if let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
memory_lifecycle_config,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
}
|
||||
@@ -1370,19 +1390,20 @@ where
|
||||
)? {
|
||||
feature_registry.add_module(surface_lifecycle);
|
||||
}
|
||||
if let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
prompts.clone(),
|
||||
spawner_workspace_context.clone(),
|
||||
worker.working_event_sender(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(subjektiv_lifecycle);
|
||||
}
|
||||
@@ -1592,7 +1613,7 @@ where
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution {
|
||||
if let Some((resident_summary, system_prompt_override)) = feature_prompt_contribution {
|
||||
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
|
||||
}
|
||||
worker.attach_tracker(tracker);
|
||||
@@ -3570,9 +3591,17 @@ mod tests {
|
||||
let exposure = startup
|
||||
.find("let handle = WorkerHandle")
|
||||
.expect("controller handle exposure");
|
||||
let materialize_gate = startup[..materialize]
|
||||
.rfind("if runtime_managed && worker.needs_initial_session_head_materialization() {")
|
||||
.expect("fresh runtime-managed materialization gate");
|
||||
|
||||
assert!(feature_install < materialize);
|
||||
assert!(feature_install < materialize_gate);
|
||||
assert!(materialize_gate < materialize);
|
||||
assert!(materialize < exposure);
|
||||
assert!(
|
||||
startup[materialize_gate..materialize]
|
||||
.contains("needs_initial_session_head_materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3639,7 +3668,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_memory_extraction_destinations_are_mutually_exclusive() {
|
||||
fn legacy_and_subjektiv_lifecycles_are_mutually_exclusive() {
|
||||
let mut memory = manifest::MemoryFeatureProfileConfig::default();
|
||||
memory.enabled = true;
|
||||
memory.extraction.enabled = true;
|
||||
@@ -3647,12 +3676,17 @@ mod tests {
|
||||
subjektiv.enabled = true;
|
||||
subjektiv.extraction.enabled = true;
|
||||
|
||||
let error = validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap_err();
|
||||
let error = validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("cannot both be enabled"));
|
||||
|
||||
subjektiv.extraction.enabled = false;
|
||||
validate_automatic_memory_extraction_targets(&memory, &subjektiv).unwrap();
|
||||
memory.extraction.enabled = false;
|
||||
assert!(
|
||||
validate_memory_lifecycle_targets(&memory, &subjektiv).is_err(),
|
||||
"legacy consolidation still installs the legacy lifecycle"
|
||||
);
|
||||
memory.consolidation.request_enabled = false;
|
||||
validate_memory_lifecycle_targets(&memory, &subjektiv).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -650,6 +650,22 @@ struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
||||
"The current ready resident memory surface is intentionally empty.";
|
||||
|
||||
fn resident_summary_contribution(
|
||||
output: memory::backend::MemoryResidentSummaryOutput,
|
||||
) -> SystemPromptContribution {
|
||||
if output.availability != memory::backend::MemoryResidentSummaryAvailability::Ready {
|
||||
return SystemPromptContribution::Unavailable;
|
||||
}
|
||||
SystemPromptContribution::Ready(output.content.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
@@ -662,14 +678,8 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output))
|
||||
if output.availability
|
||||
== memory::backend::MemoryResidentSummaryAvailability::Ready =>
|
||||
{
|
||||
SystemPromptContribution::Ready(output.content.unwrap_or_default())
|
||||
}
|
||||
Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(_)) => {
|
||||
SystemPromptContribution::Unavailable
|
||||
Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) => {
|
||||
resident_summary_contribution(output)
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected resident Memory Backend result");
|
||||
@@ -683,11 +693,39 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
match execute_subjektiv_memory_operation(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
||||
match resident_summary_contribution(output) {
|
||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
||||
}
|
||||
contribution => contribution,
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
||||
SystemPromptContribution::Unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MemoryFeatureInstallPlan {
|
||||
pub(crate) module: MemoryToolsFeature,
|
||||
pub(crate) resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pub(crate) system_prompt_override: Option<String>,
|
||||
pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig,
|
||||
}
|
||||
|
||||
impl MemoryFeatureInstallPlan {
|
||||
@@ -720,11 +758,7 @@ impl MemoryFeatureInstallPlan {
|
||||
} => Some(name.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
let memory_consolidation_worker = matches!(
|
||||
profile_name,
|
||||
Some("memory-consolidation" | "subjektiv-memory-consolidation")
|
||||
);
|
||||
let subjektiv_consolidation_worker = profile_name == Some("subjektiv-memory-consolidation");
|
||||
let memory_consolidation_worker = profile_name == Some("memory-consolidation");
|
||||
config
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
@@ -758,31 +792,170 @@ impl MemoryFeatureInstallPlan {
|
||||
let system_prompt_override = if memory_consolidation_worker {
|
||||
let language = settings.language;
|
||||
Some(
|
||||
if subjektiv_consolidation_worker {
|
||||
prompts.subjektiv_memory_consolidation_system(&language)
|
||||
} else {
|
||||
prompts.memory_consolidation_system(&language)
|
||||
}
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?,
|
||||
prompts
|
||||
.memory_consolidation_system(&language)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let module = if subjektiv_consolidation_worker {
|
||||
MemoryToolsFeature::from_tools(workspace_http_subjektiv_consolidation_tools(client))
|
||||
} else {
|
||||
MemoryToolsFeature::new(client, config.profile.staging_tools)
|
||||
};
|
||||
let module = MemoryToolsFeature::new(client, config.profile.staging_tools);
|
||||
Ok(Some(Self {
|
||||
module,
|
||||
resident_summary_source,
|
||||
system_prompt_override,
|
||||
resolved_config: config,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_builtin_subjektiv_consolidation_profile(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
) -> bool {
|
||||
manifest.profile.as_ref().is_some_and(|snapshot| {
|
||||
matches!(
|
||||
&snapshot.source,
|
||||
manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::Builtin,
|
||||
name,
|
||||
..
|
||||
} if name == "subjektiv-memory-consolidation"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
|
||||
manifest.feature.subjektiv.profile.enabled
|
||||
&& !is_builtin_subjektiv_consolidation_profile(manifest)
|
||||
}
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||
return Ok(None);
|
||||
}
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !client.is_available() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv resident Memory requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
let workspace_id = client.workspace_id().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv resident Memory requires Backend Workspace API authority",
|
||||
)
|
||||
})?;
|
||||
let settings = manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.workspace_settings()
|
||||
.expect("validated enabled subjektiv config has Workspace settings");
|
||||
if settings.workspace_id != workspace_id {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"subjektiv Workspace settings belong to {} instead of {}",
|
||||
settings.workspace_id, workspace_id
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||
client,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) struct SubjektivConsolidationFeatureInstallPlan {
|
||||
pub(crate) module: SubjektivConsolidationToolsFeature,
|
||||
pub(crate) system_prompt_override: String,
|
||||
}
|
||||
|
||||
impl SubjektivConsolidationFeatureInstallPlan {
|
||||
pub(crate) fn prepare(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !is_builtin_subjektiv_consolidation_profile(manifest) {
|
||||
return Ok(None);
|
||||
}
|
||||
let config = &manifest.feature.subjektiv;
|
||||
config
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !config.profile.enabled || config.profile.extraction.enabled {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv consolidation profile requires subject settings without automatic extraction",
|
||||
));
|
||||
}
|
||||
let workspace_id = client.workspace_id().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv consolidation tools require Backend Workspace API authority",
|
||||
)
|
||||
})?;
|
||||
let settings = config
|
||||
.workspace_settings()
|
||||
.expect("validated enabled subjektiv config has Workspace settings");
|
||||
if settings.workspace_id != workspace_id {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"subjektiv settings belong to {} instead of {}",
|
||||
settings.workspace_id, workspace_id
|
||||
),
|
||||
));
|
||||
}
|
||||
let system_prompt_override = prompts
|
||||
.subjektiv_memory_consolidation_system(&settings.language)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
Ok(Some(Self {
|
||||
module: SubjektivConsolidationToolsFeature {
|
||||
tools: workspace_http_subjektiv_consolidation_tools(client),
|
||||
},
|
||||
system_prompt_override,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivConsolidationToolsFeature {
|
||||
tools: Vec<ToolDefinition>,
|
||||
}
|
||||
|
||||
impl FeatureModule for SubjektivConsolidationToolsFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(
|
||||
"subjektiv-memory-consolidation",
|
||||
"subjektiv Memory Consolidation",
|
||||
)
|
||||
.with_description("Dedicated subject Memory staging consolidation tools.");
|
||||
for tool in &self.tools {
|
||||
let (meta, _) = tool();
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
for tool in &self.tools {
|
||||
let (meta, _) = tool();
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(meta.name, tool.clone()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MemoryToolsFeature {
|
||||
tools: Vec<ToolDefinition>,
|
||||
@@ -797,10 +970,6 @@ impl MemoryToolsFeature {
|
||||
};
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
fn from_tools(tools: Vec<ToolDefinition>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MemoryToolsFeature {
|
||||
@@ -828,7 +997,9 @@ impl FeatureModule for MemoryToolsFeature {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::WorkspaceResponse;
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
@@ -837,13 +1008,100 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SubjectResidentClient {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
paths: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SubjectResidentClient {
|
||||
fn new(
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
paths: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn denied() -> Self {
|
||||
Self {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
paths: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for SubjectResidentClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"subject-resident-test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.paths.lock().unwrap().push(request.path.clone());
|
||||
assert!(request.path.ends_with("/subjektiv/memory"));
|
||||
let request: server_api::SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
));
|
||||
if !self.scope_allowed {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
|
||||
resident_client_state(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some(content),
|
||||
)
|
||||
}
|
||||
|
||||
fn resident_client_state(
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<&str>,
|
||||
) -> Arc<dyn WorkspaceClient> {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let content = content.to_string();
|
||||
let content = content.map(str::to_string);
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 1024];
|
||||
@@ -852,7 +1110,7 @@ mod tests {
|
||||
"status": "ok",
|
||||
"result": {
|
||||
"kind": "resident_summary",
|
||||
"availability": "ready",
|
||||
"availability": availability,
|
||||
"content": content,
|
||||
}
|
||||
})
|
||||
@@ -989,6 +1247,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedicated_subjektiv_consolidator_uses_subject_settings_and_only_consolidation_tools() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut manifest = manifest::ProfileResolver::new()
|
||||
.with_workspace_base(workspace.path())
|
||||
.resolve(
|
||||
&manifest::ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Builtin,
|
||||
"subjektiv-memory-consolidation",
|
||||
),
|
||||
manifest::ProfileResolveOptions::with_worker_name("subject-consolidator"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest;
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
assert!(!ordinary_subjektiv_features_enabled(&manifest));
|
||||
assert!(
|
||||
MemoryFeatureInstallPlan::prepare(
|
||||
&manifest,
|
||||
test_client(),
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let plan = SubjektivConsolidationFeatureInstallPlan::prepare(
|
||||
&manifest,
|
||||
test_client(),
|
||||
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.module.descriptor().id.as_str(),
|
||||
"builtin:subjektiv-memory-consolidation"
|
||||
);
|
||||
let names = tool_names(plan.module.tools);
|
||||
assert!(names.contains(&"SubjektivMemoryQuery".to_string()));
|
||||
assert!(names.contains(&"MemoryApplyCandidate".to_string()));
|
||||
assert!(!names.contains(&"MemoryQuery".to_string()));
|
||||
assert!(!names.contains(&"SubjektivRemember".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_prompt_contribution_defers_resident_summary_until_loaded() {
|
||||
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
|
||||
@@ -1029,6 +1341,132 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn subject_manifest(workspace_id: &str) -> manifest::WorkerManifest {
|
||||
let mut manifest = manifest::WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "subject-worker"
|
||||
scope = "main"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/subject-resident-test"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
manifest
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_preserves_only_current_ready_surface() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let ready_client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready.load().await,
|
||||
SystemPromptContribution::Ready("current subject surface".into())
|
||||
);
|
||||
assert_eq!(
|
||||
ready_client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
);
|
||||
|
||||
let ready_empty_client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready_empty.load().await,
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
||||
);
|
||||
|
||||
for availability in [
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
memory::backend::MemoryResidentSummaryAvailability::Stale,
|
||||
memory::backend::MemoryResidentSummaryAvailability::Failed,
|
||||
] {
|
||||
let source = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
Arc::new(SubjectResidentClient::new(
|
||||
availability,
|
||||
Some("must not be injected"),
|
||||
)),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::denied());
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
assert_eq!(
|
||||
client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_resident_source_requires_trusted_matching_workspace_config() {
|
||||
let disabled = manifest::WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "ordinary-worker"
|
||||
scope = "main"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/subject-resident-test"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let foreign = subject_manifest("other-workspace");
|
||||
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_feature_owns_normal_and_staging_tool_surfaces() {
|
||||
let normal = MemoryToolsFeature::new(test_client(), false);
|
||||
|
||||
@@ -49,6 +49,35 @@ enum ExtractionTarget {
|
||||
Subjektiv,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LifecycleConfig {
|
||||
extraction: manifest::MemoryExtractionProfileConfig,
|
||||
consolidation_request_enabled: bool,
|
||||
workspace_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
}
|
||||
|
||||
impl LifecycleConfig {
|
||||
fn from_memory(config: manifest::ResolvedMemoryFeatureConfig) -> Self {
|
||||
Self {
|
||||
extraction: config.profile.extraction,
|
||||
consolidation_request_enabled: config.profile.consolidation.request_enabled,
|
||||
workspace_settings: config.workspace_settings,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_subjektiv(config: manifest::ResolvedSubjektivFeatureConfig) -> Self {
|
||||
Self {
|
||||
extraction: config.profile.extraction,
|
||||
consolidation_request_enabled: true,
|
||||
workspace_settings: config.workspace_settings,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_settings(&self) -> Option<manifest::WorkspaceMemorySettingsSnapshot> {
|
||||
self.workspace_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent-Worker lifecycle Feature that observes committed runs and schedules
|
||||
/// bounded extraction work. It owns the Memory pointer, audit, restricted
|
||||
/// Internal Worker, and staging disposition; Worker core owns only generic
|
||||
@@ -67,7 +96,7 @@ pub(crate) struct SubjektivLifecycleFeature {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MemoryLifecycleTask {
|
||||
config: manifest::ResolvedMemoryFeatureConfig,
|
||||
config: LifecycleConfig,
|
||||
target: ExtractionTarget,
|
||||
capture: CommittedSessionCaptureHandle,
|
||||
extensions: SessionExtensionHandle,
|
||||
@@ -141,7 +170,7 @@ impl MemoryLifecycleFeature {
|
||||
) -> Self {
|
||||
Self {
|
||||
task: MemoryLifecycleTask {
|
||||
config,
|
||||
config: LifecycleConfig::from_memory(config),
|
||||
target: ExtractionTarget::WorkspaceMemory,
|
||||
capture,
|
||||
extensions,
|
||||
@@ -170,7 +199,7 @@ impl SubjektivLifecycleFeature {
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
event_tx: Option<broadcast::Sender<Event>>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !lifecycle_enabled || !config.profile.enabled {
|
||||
if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
config
|
||||
@@ -182,23 +211,9 @@ impl SubjektivLifecycleFeature {
|
||||
"subjektiv extraction requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
let memory_config = manifest::ResolvedMemoryFeatureConfig {
|
||||
profile: manifest::MemoryFeatureProfileConfig {
|
||||
enabled: true,
|
||||
staging_tools: false,
|
||||
resident: manifest::MemoryResidentProfileConfig {
|
||||
inject_summary: false,
|
||||
},
|
||||
extraction: config.profile.extraction,
|
||||
consolidation: manifest::MemoryConsolidationProfileConfig {
|
||||
request_enabled: false,
|
||||
},
|
||||
},
|
||||
workspace_settings: config.workspace_settings,
|
||||
};
|
||||
Ok(Some(Self {
|
||||
task: MemoryLifecycleTask {
|
||||
config: memory_config,
|
||||
config: LifecycleConfig::from_subjektiv(config),
|
||||
target: ExtractionTarget::Subjektiv,
|
||||
capture,
|
||||
extensions,
|
||||
@@ -282,10 +297,10 @@ impl MemoryLifecycleTask {
|
||||
|
||||
fn extraction_manifest(&self) -> WorkerManifest {
|
||||
let mut manifest = self.manifest.clone();
|
||||
if let Some(model) = self.config.profile.extraction.model.clone() {
|
||||
if let Some(model) = self.config.extraction.model.clone() {
|
||||
manifest.model = model;
|
||||
}
|
||||
if let Some(reasoning) = self.config.profile.extraction.reasoning.clone() {
|
||||
if let Some(reasoning) = self.config.extraction.reasoning.clone() {
|
||||
manifest.engine.reasoning = Some(reasoning);
|
||||
}
|
||||
manifest
|
||||
@@ -301,14 +316,13 @@ impl MemoryLifecycleTask {
|
||||
memory::audit::AuditWorker::MemoryExtract,
|
||||
memory::audit::AuditTrigger::TokenThreshold,
|
||||
self.config
|
||||
.profile
|
||||
.extraction
|
||||
.model
|
||||
.as_ref()
|
||||
.or(Some(&self.manifest.model))
|
||||
.map(model_audit_from_manifest),
|
||||
)
|
||||
.with_memory_settings(&self.config);
|
||||
.with_workspace_settings(self.config.workspace_settings.as_ref());
|
||||
let audit = if self.target == ExtractionTarget::Subjektiv {
|
||||
audit.without_legacy_backend()
|
||||
} else {
|
||||
@@ -364,7 +378,6 @@ impl MemoryLifecycleTask {
|
||||
};
|
||||
let Some(threshold) = self
|
||||
.config
|
||||
.profile
|
||||
.extraction
|
||||
.threshold
|
||||
.filter(|threshold| *threshold > 0)
|
||||
@@ -470,7 +483,7 @@ impl MemoryLifecycleTask {
|
||||
capture.session_id.clone(),
|
||||
),
|
||||
};
|
||||
let client = if let Some(model) = self.config.profile.extraction.model.as_ref() {
|
||||
let client = if let Some(model) = self.config.extraction.model.as_ref() {
|
||||
match crate::model_client::build_client(model) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
@@ -534,7 +547,6 @@ impl MemoryLifecycleTask {
|
||||
cache_key: Some(capture.segment_id.clone()),
|
||||
max_turns: self
|
||||
.config
|
||||
.profile
|
||||
.extraction
|
||||
.worker_max_turns
|
||||
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||
@@ -710,7 +722,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask {
|
||||
context: BackgroundTaskContext,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
let extraction = if self.config.profile.extraction.enabled {
|
||||
let extraction = if self.config.extraction.enabled {
|
||||
self.run_extraction(context.clone(), cancellation.clone())
|
||||
.await
|
||||
} else {
|
||||
@@ -719,9 +731,7 @@ impl FeatureBackgroundTask for MemoryLifecycleTask {
|
||||
if !cancellation.is_cancelled() {
|
||||
context.generation_fence.ensure_current()?;
|
||||
match self.target {
|
||||
ExtractionTarget::WorkspaceMemory
|
||||
if self.config.profile.consolidation.request_enabled =>
|
||||
{
|
||||
ExtractionTarget::WorkspaceMemory if self.config.consolidation_request_enabled => {
|
||||
self.request_consolidation(false).await;
|
||||
}
|
||||
ExtractionTarget::Subjektiv => {
|
||||
@@ -770,7 +780,7 @@ impl MemoryLifecycleTask {
|
||||
memory::audit::AuditTrigger::StagingBacklog,
|
||||
Some(model_audit_from_manifest(&self.manifest.model)),
|
||||
)
|
||||
.with_memory_settings(&self.config);
|
||||
.with_workspace_settings(self.config.workspace_settings.as_ref());
|
||||
let operation = memory::backend::MemoryConsolidateStagingOperation { force: false };
|
||||
let request = if subjektiv {
|
||||
self.workspace_client
|
||||
@@ -981,15 +991,15 @@ impl WorkerAuditBase {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self {
|
||||
self.memory_settings =
|
||||
config
|
||||
.workspace_settings()
|
||||
.map(|snapshot| memory::audit::MemorySettingsAudit {
|
||||
workspace_id: snapshot.workspace_id,
|
||||
settings_revision: snapshot.settings_revision,
|
||||
language: snapshot.language,
|
||||
});
|
||||
fn with_workspace_settings(
|
||||
mut self,
|
||||
settings: Option<&manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
) -> Self {
|
||||
self.memory_settings = settings.map(|snapshot| memory::audit::MemorySettingsAudit {
|
||||
workspace_id: snapshot.workspace_id.clone(),
|
||||
settings_revision: snapshot.settings_revision,
|
||||
language: snapshot.language.clone(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1407,7 +1417,7 @@ permission = "write"
|
||||
Ok(true)
|
||||
});
|
||||
MemoryLifecycleTask {
|
||||
config: test_config(),
|
||||
config: LifecycleConfig::from_memory(test_config()),
|
||||
target: ExtractionTarget::WorkspaceMemory,
|
||||
capture: capture_handle,
|
||||
extensions,
|
||||
@@ -1483,6 +1493,35 @@ permission = "write"
|
||||
assert!(feature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_without_extraction_installs_no_session_lifecycle() {
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
panic!("subjektiv without extraction must not capture a Session")
|
||||
});
|
||||
let extensions = SessionExtensionHandle::new(|_, _, _| {
|
||||
panic!("subjektiv without extraction must not write a pointer")
|
||||
});
|
||||
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
|
||||
config.profile.enabled = true;
|
||||
config.profile.extraction.enabled = false;
|
||||
|
||||
let feature = SubjektivLifecycleFeature::from_resolved_config(
|
||||
true,
|
||||
config,
|
||||
capture,
|
||||
extensions,
|
||||
crate::worker::marker_workspace_client(None, "extraction-disabled"),
|
||||
test_manifest(),
|
||||
Box::new(ScriptClient::new(Vec::new())),
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())),
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(feature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_host_suppresses_enabled_subjektiv_lifecycle_without_capture_side_effects() {
|
||||
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||
@@ -1536,12 +1575,11 @@ permission = "write"
|
||||
event_tx,
|
||||
workspace_client,
|
||||
);
|
||||
task.config.profile.extraction.model = Some(manifest::ModelManifest {
|
||||
task.config.extraction.model = Some(manifest::ModelManifest {
|
||||
ref_: Some("codex-oauth/gpt-5.6-luna".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
task.config.profile.extraction.reasoning =
|
||||
Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
task.config.extraction.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
|
||||
let manifest = task.extraction_manifest();
|
||||
assert_eq!(
|
||||
@@ -1658,7 +1696,7 @@ permission = "write"
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
let writes = extension_writes.lock().unwrap();
|
||||
@@ -1711,7 +1749,7 @@ permission = "write"
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
let writes = extension_writes.lock().unwrap();
|
||||
@@ -1747,7 +1785,7 @@ permission = "write"
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
let registry = start_background_task(task);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !workspace_client.session_started.load(Ordering::Acquire) {
|
||||
@@ -1797,7 +1835,7 @@ permission = "write"
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
assert!(extension_writes.lock().unwrap().is_empty());
|
||||
@@ -1914,7 +1952,7 @@ permission = "write"
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.target = ExtractionTarget::Subjektiv;
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
let registry = start_background_task(task);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
@@ -2011,7 +2049,7 @@ permission = "write"
|
||||
event_tx,
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.config.profile.consolidation.request_enabled = false;
|
||||
task.config.consolidation_request_enabled = false;
|
||||
run_background_task(task).await;
|
||||
|
||||
let requests = workspace_client.requests.lock().unwrap();
|
||||
@@ -2041,8 +2079,8 @@ permission = "write"
|
||||
event_tx,
|
||||
workspace_client.clone(),
|
||||
);
|
||||
task.config.profile.extraction.enabled = false;
|
||||
task.config.profile.consolidation.request_enabled = true;
|
||||
task.config.extraction.enabled = false;
|
||||
task.config.consolidation_request_enabled = true;
|
||||
run_background_task(task).await;
|
||||
|
||||
assert!(extension_writes.lock().unwrap().is_empty());
|
||||
|
||||
@@ -14,7 +14,9 @@ use crate::feature::background::{
|
||||
BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger,
|
||||
FeatureBackgroundTask,
|
||||
};
|
||||
use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
|
||||
use crate::feature::builtin::memory::{
|
||||
WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile,
|
||||
};
|
||||
use crate::feature::builtin::memory_surface_output::{
|
||||
MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition,
|
||||
};
|
||||
@@ -98,19 +100,15 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
let dedicated = manifest.profile.as_ref().is_some_and(|snapshot| {
|
||||
matches!(
|
||||
&snapshot.source,
|
||||
manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::Builtin,
|
||||
name,
|
||||
..
|
||||
} if name == "subjektiv-memory-consolidation"
|
||||
)
|
||||
});
|
||||
let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest);
|
||||
if !lifecycle_enabled || !dedicated {
|
||||
return Ok(None);
|
||||
}
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.validate_execution()
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
@@ -260,13 +258,13 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
let language = self
|
||||
.manifest
|
||||
.feature
|
||||
.memory
|
||||
.subjektiv
|
||||
.workspace_settings()
|
||||
.map(|settings| settings.language)
|
||||
.ok_or_else(|| {
|
||||
SurfaceEditorFailure::editor(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"surface editor requires bound Workspace Memory settings",
|
||||
"surface editor requires bound subjektiv Workspace settings",
|
||||
))
|
||||
})?;
|
||||
let system_prompt = self
|
||||
@@ -721,10 +719,10 @@ permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.feature.memory.profile.enabled = true;
|
||||
manifest.feature.subjektiv.profile.enabled = true;
|
||||
manifest
|
||||
.feature
|
||||
.memory
|
||||
.subjektiv
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-1".into(),
|
||||
settings_revision: 1,
|
||||
|
||||
@@ -1044,16 +1044,25 @@ async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
|
||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let settings = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
};
|
||||
if resolved.manifest.feature.memory.enabled() {
|
||||
resolved
|
||||
.manifest
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
.bind_workspace_settings(settings.clone())
|
||||
.unwrap();
|
||||
}
|
||||
if resolved.manifest.feature.subjektiv.enabled() {
|
||||
resolved
|
||||
.manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
.bind_workspace_settings(settings)
|
||||
.unwrap();
|
||||
}
|
||||
let workspace_context =
|
||||
|
||||
@@ -20,8 +20,8 @@ use config_source::ConfigTreeSnapshot;
|
||||
use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use memory::backend::{
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryBackendOperationResult,
|
||||
MemoryConsolidateStagingOperation, MemoryConsolidationOutput,
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
|
||||
MemoryConsolidationOutput,
|
||||
};
|
||||
use protocol::Segment;
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
@@ -5433,6 +5433,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::memory_document(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::legacy_memory_reset(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::memory_staging_list(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -5443,9 +5446,24 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_create(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_list(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_memory_list(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_memory_detail(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_memory_revisions(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_retire(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -5464,6 +5482,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::subjektiv_session_backend(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_memory_consolidation(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::skill_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::skill_lint(service.clone()))
|
||||
.merge(server_api::server_api_axum::skill_get(service.clone()))
|
||||
@@ -6984,6 +7005,24 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn legacy_memory_reset(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: server_api::LegacyMemoryResetRequest,
|
||||
) -> std::result::Result<server_api::LegacyMemoryResetResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_reset_legacy_memory(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
Extension(actor),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn memory_staging_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -7056,6 +7095,22 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_subject_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
query: server_api::SubjektivSubjectListQuery,
|
||||
) -> std::result::Result<server_api::SubjektivSubjectListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_list_subjektiv_subjects(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
Query(query),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_subject_get(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -7074,6 +7129,92 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_resident_surface(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
) -> std::result::Result<
|
||||
server_api::SubjektivResidentSurfaceResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
scoped_get_subjektiv_resident_surface(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_memory_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
query: server_api::SubjektivMemoryListQuery,
|
||||
) -> std::result::Result<server_api::SubjektivMemoryQueryResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_list_subjektiv_memories(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
}),
|
||||
Query(query.into()),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_memory_detail(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
query: server_api::SubjektivMemoryDetailQuery,
|
||||
) -> std::result::Result<server_api::SubjektivMemoryReadResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_get_subjektiv_memory(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
memory_id,
|
||||
}),
|
||||
Query(query),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_memory_revisions(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
query: server_api::SubjektivMemoryRevisionsQuery,
|
||||
) -> std::result::Result<
|
||||
server_api::SubjektivMemoryListRevisionsResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
scoped_list_subjektiv_memory_revisions(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
memory_id,
|
||||
}),
|
||||
Query(query),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_subject_retire(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -10435,6 +10576,13 @@ struct ScopedSubjektivSubjectPath {
|
||||
subject_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ScopedSubjektivMemoryPath {
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
memory_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedFlowPath {
|
||||
workspace_id: String,
|
||||
@@ -16540,6 +16688,35 @@ async fn scoped_get_memory_document(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_reset_legacy_memory(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<server_api::LegacyMemoryResetRequest>,
|
||||
) -> ApiResult<Json<server_api::LegacyMemoryResetResponse>> {
|
||||
require_workspace_owner(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
&actor,
|
||||
"Legacy Workspace Memory reset",
|
||||
)
|
||||
.await?;
|
||||
let expected = format!("RESET LEGACY MEMORY {}", path.workspace_id);
|
||||
if request.confirmation != expected {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"legacy Memory reset confirmation must exactly equal `{expected}`"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let counts = api.store.reset_legacy_memory(&path.workspace_id)?;
|
||||
Ok(Json(server_api::LegacyMemoryResetResponse {
|
||||
workspace_id: path.workspace_id,
|
||||
documents_deleted: counts.documents,
|
||||
staging_records_deleted: counts.staging_records,
|
||||
staging_resolutions_deleted: counts.staging_resolutions,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_list_memory_staging(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -16557,16 +16734,11 @@ async fn scoped_list_memory_staging(
|
||||
async fn scoped_memory_backend_operation(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
context: server_api::ServerRequestContext,
|
||||
_context: server_api::ServerRequestContext,
|
||||
Json(operation): Json<MemoryBackendOperation>,
|
||||
) -> ApiResult<Json<MemoryBackendHttpResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let result = match operation {
|
||||
MemoryBackendOperation::ResidentSummary(_) => {
|
||||
execute_scoped_resident_summary(&api, &context)
|
||||
}
|
||||
operation => execute_memory_backend_operation_with_authority(&api.authority, operation),
|
||||
};
|
||||
let result = execute_memory_backend_operation_with_authority(&api.authority, operation);
|
||||
let response = match result {
|
||||
Ok(result) => MemoryBackendHttpResponse::Ok { result },
|
||||
Err(error) => MemoryBackendHttpResponse::Error {
|
||||
@@ -16576,48 +16748,6 @@ async fn scoped_memory_backend_operation(
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
fn execute_scoped_resident_summary(
|
||||
api: &WorkspaceApi,
|
||||
context: &server_api::ServerRequestContext,
|
||||
) -> crate::Result<MemoryBackendOperationResult> {
|
||||
let Some(source) = context.runtime_source.as_ref() else {
|
||||
return execute_memory_backend_operation_with_authority(
|
||||
&api.authority,
|
||||
MemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
);
|
||||
};
|
||||
let Some(worker_id) = source.worker_id.as_deref() else {
|
||||
return execute_memory_backend_operation_with_authority(
|
||||
&api.authority,
|
||||
MemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
);
|
||||
};
|
||||
let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id);
|
||||
let Some(lease) = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(&api.config.workspace_id, &worker)?
|
||||
else {
|
||||
return execute_memory_backend_operation_with_authority(
|
||||
&api.authority,
|
||||
MemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
);
|
||||
};
|
||||
let Some(subject_id) = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else {
|
||||
return execute_memory_backend_operation_with_authority(
|
||||
&api.authority,
|
||||
MemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
);
|
||||
};
|
||||
let resident =
|
||||
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
|
||||
.map_err(|error| Error::Store(error.to_string()))?
|
||||
.resident_surface(subject_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
Ok(MemoryBackendOperationResult::ResidentSummary(
|
||||
resident_summary_output(resident),
|
||||
))
|
||||
}
|
||||
|
||||
fn resident_summary_output(
|
||||
resident: crate::subjektiv::ResidentSurface,
|
||||
) -> memory::backend::MemoryResidentSummaryOutput {
|
||||
@@ -16852,6 +16982,34 @@ async fn scoped_create_subjektiv_subject(
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_list_subjektiv_subjects(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Query(query): Query<server_api::SubjektivSubjectListQuery>,
|
||||
) -> ApiResult<Json<server_api::SubjektivSubjectListResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let limit = query.limit.unwrap_or(20);
|
||||
if limit == 0 || limit > server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"subject list limit must be within 1..={}",
|
||||
server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let page = open_subjektiv_store(&api)?
|
||||
.list_subjects(limit)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok(Json(server_api::SubjektivSubjectListResponse {
|
||||
limit,
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|subject| subjektiv_subject_response(&api, subject))
|
||||
.collect(),
|
||||
has_more: page.has_more,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_get_subjektiv_subject(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
@@ -16864,6 +17022,123 @@ async fn scoped_get_subjektiv_subject(
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
}
|
||||
|
||||
fn require_subjektiv_subject(
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
subject_id: &str,
|
||||
) -> ApiResult<()> {
|
||||
if store
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(Error::SubjektivSubjectNotFound(subject_id.to_string()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn scoped_get_subjektiv_resident_surface(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
) -> ApiResult<Json<server_api::SubjektivResidentSurfaceResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
let resident = store
|
||||
.resident_surface(&path.subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let availability = match resident.availability {
|
||||
crate::subjektiv::SurfaceAvailability::Ungenerated => {
|
||||
server_api::SubjektivResidentSurfaceAvailability::Ungenerated
|
||||
}
|
||||
crate::subjektiv::SurfaceAvailability::Stale => {
|
||||
server_api::SubjektivResidentSurfaceAvailability::Stale
|
||||
}
|
||||
crate::subjektiv::SurfaceAvailability::Failed => {
|
||||
server_api::SubjektivResidentSurfaceAvailability::Failed
|
||||
}
|
||||
crate::subjektiv::SurfaceAvailability::Ready => {
|
||||
server_api::SubjektivResidentSurfaceAvailability::Ready
|
||||
}
|
||||
};
|
||||
let snapshot = resident
|
||||
.snapshot
|
||||
.map(|snapshot| server_api::SubjektivResidentSurfaceSnapshot {
|
||||
snapshot_id: snapshot.id,
|
||||
body_md: snapshot.body_md,
|
||||
memory_refs: snapshot
|
||||
.memory_refs
|
||||
.into_iter()
|
||||
.map(|reference| server_api::SubjektivMemoryRevisionRef {
|
||||
memory_id: reference.memory_id,
|
||||
revision: reference.revision,
|
||||
})
|
||||
.collect(),
|
||||
built_from_store_revision: snapshot.built_from_store_revision,
|
||||
created_at: snapshot.created_at,
|
||||
});
|
||||
Ok(Json(server_api::SubjektivResidentSurfaceResponse {
|
||||
subject_id: path.subject_id,
|
||||
availability,
|
||||
snapshot,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_list_subjektiv_memories(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
Query(query): Query<server_api::SubjektivMemoryQueryRequest>,
|
||||
) -> ApiResult<Json<server_api::SubjektivMemoryQueryResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
Ok(Json(subjektiv_memory_query(
|
||||
&store,
|
||||
&path.subject_id,
|
||||
query,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_subjektiv_memory(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivMemoryPath>,
|
||||
Query(query): Query<server_api::SubjektivMemoryDetailQuery>,
|
||||
) -> ApiResult<Json<server_api::SubjektivMemoryReadResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
Ok(Json(subjektiv_memory_read(
|
||||
&store,
|
||||
&path.subject_id,
|
||||
server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: path.memory_id,
|
||||
revision: query.revision,
|
||||
offset: query.offset,
|
||||
byte_offset: query.byte_offset,
|
||||
limit: query.limit,
|
||||
evidence_cursor: query.evidence_cursor,
|
||||
},
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_list_subjektiv_memory_revisions(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivMemoryPath>,
|
||||
Query(query): Query<server_api::SubjektivMemoryRevisionsQuery>,
|
||||
) -> ApiResult<Json<server_api::SubjektivMemoryListRevisionsResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
require_subjektiv_subject(&store, &path.subject_id)?;
|
||||
Ok(Json(subjektiv_memory_list_revisions(
|
||||
&store,
|
||||
&path.subject_id,
|
||||
server_api::SubjektivMemoryListRevisionsRequest {
|
||||
memory_id: path.memory_id,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
},
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_retire_subjektiv_subject(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
@@ -16871,7 +17146,7 @@ async fn scoped_retire_subjektiv_subject(
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.retire_subject(&path.subject_id)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
.map_err(subjektiv_store_error)?;
|
||||
// Retirement deliberately does not stop or remove the current Worker. The
|
||||
// Worker lease and subject lifecycle are independent authorities.
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
@@ -18357,6 +18632,19 @@ async fn scoped_subjektiv_memory_backend(
|
||||
let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?;
|
||||
let store = open_subjektiv_store(&api)?;
|
||||
let response = match request.operation {
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) => {
|
||||
require_subjektiv_worker_authority(
|
||||
authority,
|
||||
SubjektivWorkerAuthority::Subject,
|
||||
"resident surface read",
|
||||
)?;
|
||||
let resident = store
|
||||
.resident_surface(&subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentSummary(resident_summary_output(
|
||||
resident,
|
||||
))
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::Query(input) => {
|
||||
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
|
||||
&store,
|
||||
@@ -19738,9 +20026,12 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
||||
| crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => {
|
||||
Error::RepositoryConflict(format!("candidate_decision_conflict: {error}"))
|
||||
}
|
||||
crate::subjektiv::SubjektivError::MemoryNotFound(_) => {
|
||||
Error::InvalidInput(format!("memory_not_found: {error}"))
|
||||
crate::subjektiv::SubjektivError::SubjectNotFound(subject_id) => {
|
||||
Error::SubjektivSubjectNotFound(subject_id)
|
||||
}
|
||||
crate::subjektiv::SubjektivError::MemoryNotFound(memory_id) => Error::InvalidInput(
|
||||
format!("memory_not_found: Memory `{memory_id}` was not found"),
|
||||
),
|
||||
crate::subjektiv::SubjektivError::Storage(_) => Error::Store(error.to_string()),
|
||||
_ => Error::InvalidInput(error.to_string()),
|
||||
}
|
||||
@@ -31302,7 +31593,14 @@ fn api_error_status(error: &Error) -> StatusCode {
|
||||
| Error::RuntimeBindingRevisionConflict { .. }
|
||||
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidInput(message)
|
||||
if message.starts_with("memory_not_found:")
|
||||
|| message.starts_with("memory_revision_not_found:") =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
@@ -38007,6 +38305,398 @@ mod tests {
|
||||
assert_eq!(chunks, content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_memory_reset_requires_owner_confirmation_and_preserves_subjektiv() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
api.store
|
||||
.upsert_memory_document(&crate::store::MemoryDocumentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
body_md: "# Legacy Memory\n\nRetained until explicit reset.\n".to_string(),
|
||||
created_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
updated_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
api.store
|
||||
.upsert_memory_staging_record(&crate::store::MemoryStagingRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
candidate_id: "legacy-pending".to_string(),
|
||||
raw_json: r#"{"claim":"pending"}"#.to_string(),
|
||||
source_path: None,
|
||||
imported_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
api.store
|
||||
.insert_memory_staging_resolution(&crate::store::MemoryStagingResolutionRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
candidate_id: "legacy-resolved".to_string(),
|
||||
action: "discarded".to_string(),
|
||||
reason: "fixture".to_string(),
|
||||
affected_refs_json: "[]".to_string(),
|
||||
staging_raw_json: r#"{"claim":"resolved"}"#.to_string(),
|
||||
source_path: None,
|
||||
imported_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
resolved_at: "2026-10-02T00:01:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let subjektiv = open_subjektiv_store(&api).unwrap();
|
||||
let subject = subjektiv
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let memory = subjektiv
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Decision,
|
||||
"Preserve feature Memory",
|
||||
"This record is outside the legacy reset scope.",
|
||||
"Proves reset isolation",
|
||||
"fixture",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let incorrect = scoped_reset_legacy_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Extension(test_owner_actor()),
|
||||
Json(server_api::LegacyMemoryResetRequest {
|
||||
confirmation: "RESET LEGACY MEMORY wrong-workspace".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(incorrect.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
api.store
|
||||
.get_memory_document(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let mut non_owner = test_owner_actor();
|
||||
non_owner.account_id = "another-account".to_string();
|
||||
let forbidden = scoped_reset_legacy_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Extension(non_owner),
|
||||
Json(server_api::LegacyMemoryResetRequest {
|
||||
confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(forbidden.into_response().status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let Json(reset) = scoped_reset_legacy_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Extension(test_owner_actor()),
|
||||
Json(server_api::LegacyMemoryResetRequest {
|
||||
confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reset.documents_deleted, 1);
|
||||
assert_eq!(reset.staging_records_deleted, 1);
|
||||
assert_eq!(reset.staging_resolutions_deleted, 1);
|
||||
assert!(
|
||||
api.store
|
||||
.get_memory_document(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
subjektiv
|
||||
.memory(&subject.id, &memory.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
|
||||
let Json(repeated) = scoped_reset_legacy_memory(
|
||||
State(api),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Extension(test_owner_actor()),
|
||||
Json(server_api::LegacyMemoryResetRequest {
|
||||
confirmation: format!("RESET LEGACY MEMORY {TEST_WORKSPACE_ID}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(repeated.documents_deleted, 0);
|
||||
assert_eq!(repeated.staging_records_deleted, 0);
|
||||
assert_eq!(repeated.staging_resolutions_deleted, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let first_subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let second_subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("reviewer").unwrap())
|
||||
.unwrap();
|
||||
|
||||
let invalid_limit = scoped_list_subjektiv_subjects(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Query(server_api::SubjektivSubjectListQuery { limit: Some(0) }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
invalid_limit.into_response().status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
let Json(subjects) = scoped_list_subjektiv_subjects(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Query(server_api::SubjektivSubjectListQuery { limit: Some(1) }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(subjects.items.len(), 1);
|
||||
assert!(subjects.has_more);
|
||||
|
||||
let missing = scoped_get_subjektiv_subject(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: "missing-subject".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(store.list_subjects(100).unwrap().items.len(), 2);
|
||||
|
||||
let Json(subject_detail) = scoped_get_subjektiv_subject(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(subject_detail.id, first_subject.id);
|
||||
let Json(ungenerated) = scoped_get_subjektiv_resident_surface(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ungenerated.availability,
|
||||
server_api::SubjektivResidentSurfaceAvailability::Ungenerated
|
||||
);
|
||||
assert!(ungenerated.snapshot.is_none());
|
||||
|
||||
let memory = store
|
||||
.create_memory(
|
||||
&first_subject.id,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Lesson,
|
||||
"Keep exact revisions",
|
||||
"first body",
|
||||
"Supports historical reads",
|
||||
"initial",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let revised = store
|
||||
.revise_memory(
|
||||
&first_subject.id,
|
||||
&memory.id,
|
||||
1,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Lesson,
|
||||
"Keep exact revisions",
|
||||
"second body",
|
||||
"Supports historical reads",
|
||||
"correction",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let other_memory = store
|
||||
.create_memory(
|
||||
&second_subject.id,
|
||||
crate::subjektiv::MemoryDraft::active(
|
||||
memory::extract::CandidateKind::Decision,
|
||||
"Keep subjects isolated",
|
||||
"other body",
|
||||
"Prevents cross-subject reads",
|
||||
"initial",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let Json(memories) = scoped_list_subjektiv_memories(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryQueryRequest {
|
||||
query: None,
|
||||
kinds: None,
|
||||
states: None,
|
||||
limit: Some(1),
|
||||
cursor: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(memories.items.len(), 1);
|
||||
assert_eq!(memories.items[0].id, memory.id);
|
||||
assert_eq!(memories.items[0].revision, 2);
|
||||
|
||||
let missing_memory = scoped_get_subjektiv_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
memory_id: "missing-memory".to_string(),
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryDetailQuery::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
missing_memory.into_response().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
|
||||
let missing_revision = scoped_get_subjektiv_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
memory_id: memory.id.clone(),
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryDetailQuery {
|
||||
revision: Some(99),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
missing_revision.into_response().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
|
||||
let cross_subject = scoped_get_subjektiv_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
memory_id: other_memory.id,
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryDetailQuery::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
cross_subject.into_response().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
let Json(historical) = scoped_get_subjektiv_memory(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
memory_id: memory.id.clone(),
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryDetailQuery {
|
||||
revision: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(historical.revision, 1);
|
||||
assert_eq!(historical.current_revision, 2);
|
||||
assert_eq!(historical.body_md, "first body");
|
||||
|
||||
let Json(revisions) = scoped_list_subjektiv_memory_revisions(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivMemoryPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id.clone(),
|
||||
memory_id: memory.id.clone(),
|
||||
}),
|
||||
Query(server_api::SubjektivMemoryRevisionsQuery {
|
||||
limit: Some(1),
|
||||
cursor: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revisions.current_revision, 2);
|
||||
assert_eq!(revisions.items[0].revision, 2);
|
||||
assert!(revisions.has_more);
|
||||
|
||||
let generation = store.prepare_surface_generation(&first_subject.id).unwrap();
|
||||
let expected_ref = crate::subjektiv::MemoryRevisionRef {
|
||||
memory_id: revised.id.clone(),
|
||||
revision: revised.revision,
|
||||
};
|
||||
store
|
||||
.publish_surface_generation(
|
||||
&first_subject.id,
|
||||
&generation.id,
|
||||
vec![crate::subjektiv::SurfacePoint {
|
||||
body_md: "- Keep exact revisions".to_string(),
|
||||
memory_refs: vec![expected_ref.clone()],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let Json(surface) = scoped_get_subjektiv_resident_surface(
|
||||
State(api),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: first_subject.id,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
surface.availability,
|
||||
server_api::SubjektivResidentSurfaceAvailability::Ready
|
||||
);
|
||||
let snapshot = surface.snapshot.unwrap();
|
||||
assert_eq!(snapshot.memory_refs.len(), 1);
|
||||
assert_eq!(snapshot.memory_refs[0].memory_id, expected_ref.memory_id);
|
||||
assert_eq!(snapshot.memory_refs[0].revision, expected_ref.revision);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -38064,6 +38754,58 @@ mod tests {
|
||||
origin: None,
|
||||
transport_headers: Vec::new(),
|
||||
};
|
||||
api.store
|
||||
.upsert_memory_document(&MemoryDocumentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
body_md: "# Legacy sentinel\n".to_string(),
|
||||
created_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
updated_at: "2026-10-02T00:00:00Z".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let Json(legacy_resident) = scoped_memory_backend_operation(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context.clone(),
|
||||
Json(MemoryBackendOperation::ResidentSummary(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
legacy_resident,
|
||||
MemoryBackendHttpResponse::Ok {
|
||||
result: memory::backend::MemoryBackendOperationResult::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some(ref body),
|
||||
}
|
||||
)
|
||||
} if body == "# Legacy sentinel\n"
|
||||
));
|
||||
let Json(resident) = scoped_subjektiv_memory_backend(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context.clone(),
|
||||
Json(server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::ResidentSummary(
|
||||
Default::default(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
resident,
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
content: None,
|
||||
}
|
||||
)
|
||||
));
|
||||
let operation = memory::backend::MemoryStageCandidateOperation {
|
||||
source: memory::schema::SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
|
||||
@@ -1035,6 +1035,13 @@ pub struct MemoryStagingResolutionRecord {
|
||||
pub resolved_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LegacyMemoryResetCounts {
|
||||
pub documents: usize,
|
||||
pub staging_records: usize,
|
||||
pub staging_resolutions: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FlowSourceRecord {
|
||||
pub workspace_id: String,
|
||||
@@ -1450,6 +1457,10 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<MemoryStagingResolutionRecord>>;
|
||||
/// Atomically removes only the legacy Workspace Memory document and legacy
|
||||
/// staging rows. Subjektiv Feature storage and all other Workspace state are
|
||||
/// outside this transaction's deletion set.
|
||||
fn reset_legacy_memory(&self, workspace_id: &str) -> Result<LegacyMemoryResetCounts>;
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()>;
|
||||
fn get_account(&self, account_id: &str) -> Result<Option<AccountRecord>>;
|
||||
@@ -5890,6 +5901,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_legacy_memory(&self, workspace_id: &str) -> Result<LegacyMemoryResetCounts> {
|
||||
validate_identifier("workspace_id", workspace_id)?;
|
||||
self.with_conn_mut(|connection| {
|
||||
let transaction =
|
||||
connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
// Resolutions retain copies of staging rows, so remove them first;
|
||||
// pending staging follows, then the singleton legacy document.
|
||||
let staging_resolutions = transaction.execute(
|
||||
"DELETE FROM memory_staging_resolutions WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
)?;
|
||||
let staging_records = transaction.execute(
|
||||
"DELETE FROM memory_staging_records WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
)?;
|
||||
let documents = transaction.execute(
|
||||
"DELETE FROM workspace_memory_documents WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
)?;
|
||||
transaction.commit()?;
|
||||
Ok(LegacyMemoryResetCounts {
|
||||
documents,
|
||||
staging_records,
|
||||
staging_resolutions,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_account(&self, record: &AccountRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
@@ -20139,6 +20178,171 @@ INSERT INTO worker_registry (
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
for (workspace_id, display_name) in [
|
||||
("local-dev", "Local Dev"),
|
||||
("other-workspace", "Other Workspace"),
|
||||
] {
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
owner_account_id: "owner-account".to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.ensure_memory_document(workspace_id, "# Legacy Memory\n", "2")
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_memory_staging_record(&MemoryStagingRecord {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
candidate_id: format!("candidate-{workspace_id}"),
|
||||
raw_json: r#"{"claim":"legacy"}"#.to_string(),
|
||||
source_path: None,
|
||||
imported_at: "3".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.insert_memory_staging_resolution(&MemoryStagingResolutionRecord {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
candidate_id: format!("resolved-{workspace_id}"),
|
||||
action: "discarded".to_string(),
|
||||
reason: "fixture".to_string(),
|
||||
affected_refs_json: "[]".to_string(),
|
||||
staging_raw_json: r#"{"claim":"resolved"}"#.to_string(),
|
||||
source_path: None,
|
||||
imported_at: "3".to_string(),
|
||||
resolved_at: "4".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let unrelated_counts = |store: &SqliteWorkspaceStore| {
|
||||
store
|
||||
.with_conn(|connection| {
|
||||
let mut tables = connection.prepare(
|
||||
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
)?;
|
||||
let names = tables
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
drop(tables);
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
for name in names {
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"workspace_memory_documents"
|
||||
| "memory_staging_records"
|
||||
| "memory_staging_resolutions"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let quoted = name.replace('"', "\"\"");
|
||||
let count = connection.query_row(
|
||||
&format!("SELECT COUNT(*) FROM \"{quoted}\""),
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)?;
|
||||
counts.insert(name, count);
|
||||
}
|
||||
Ok(counts)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let before = unrelated_counts(&store);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_memory_settings("local-dev")
|
||||
.unwrap()
|
||||
.language,
|
||||
"English"
|
||||
);
|
||||
|
||||
// A failure after resolution deletion must roll the entire operation back.
|
||||
store
|
||||
.with_conn(|connection| {
|
||||
connection.execute_batch(
|
||||
"CREATE TRIGGER fail_legacy_memory_reset
|
||||
BEFORE DELETE ON memory_staging_records
|
||||
WHEN OLD.workspace_id = 'local-dev'
|
||||
BEGIN SELECT RAISE(ABORT, 'reset failure fixture'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.reset_legacy_memory("local-dev").is_err());
|
||||
assert!(store.get_memory_document("local-dev").unwrap().is_some());
|
||||
assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_memory_staging_resolutions("local-dev", 10)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
store
|
||||
.with_conn(|connection| {
|
||||
connection.execute_batch("DROP TRIGGER fail_legacy_memory_reset")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.reset_legacy_memory("local-dev").unwrap(),
|
||||
LegacyMemoryResetCounts {
|
||||
documents: 1,
|
||||
staging_records: 1,
|
||||
staging_resolutions: 1,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store.reset_legacy_memory("local-dev").unwrap(),
|
||||
LegacyMemoryResetCounts::default()
|
||||
);
|
||||
assert!(store.get_memory_document("local-dev").unwrap().is_none());
|
||||
assert_eq!(store.count_memory_staging_records("local-dev").unwrap(), 0);
|
||||
assert!(
|
||||
store
|
||||
.list_memory_staging_resolutions("local-dev", 10)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
assert!(
|
||||
store
|
||||
.get_memory_document("other-workspace")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.count_memory_staging_records("other-workspace")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_memory_staging_resolutions("other-workspace", 10)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(unrelated_counts(&store), before);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_memory_settings("local-dev")
|
||||
.unwrap()
|
||||
.language,
|
||||
"English"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -141,6 +141,12 @@ pub struct SubjectRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubjectPage {
|
||||
pub items: Vec<SubjectRecord>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Immutable host-recorded history that attributes one durable Session to the
|
||||
/// subject and Worker that produced it. This is archival provenance only: it is
|
||||
/// deliberately not a current-Worker link or execution ownership record.
|
||||
@@ -1085,6 +1091,35 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists one deterministic bounded page of subjects. Ordering uses immutable
|
||||
/// creation time with the host-issued subject id as a total-order tie breaker.
|
||||
pub fn list_subjects(&self, limit: usize) -> Result<SubjectPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject list limit must be between 1 and 100".to_string(),
|
||||
));
|
||||
}
|
||||
let query_limit = limit.saturating_add(1);
|
||||
self.database.try_with_connection(|connection| {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT record_json FROM subjects
|
||||
ORDER BY created_at ASC, subject_id ASC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = statement
|
||||
.query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(parse_subject(&row?)?);
|
||||
}
|
||||
let has_more = items.len() > limit;
|
||||
if has_more {
|
||||
items.truncate(limit);
|
||||
}
|
||||
Ok(SubjectPage { items, has_more })
|
||||
})
|
||||
}
|
||||
|
||||
/// Subject retirement is distinct from stopping any current Worker. It does
|
||||
/// not edit or delete Memory and cannot be reversed by this baseline schema.
|
||||
pub fn retire_subject(&self, subject_id: &str) -> Result<SubjectRecord> {
|
||||
@@ -3506,6 +3541,155 @@ mod tests {
|
||||
(manager, workspace, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_listing_is_deterministic_bounded_and_read_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
|
||||
let mut expected = vec![
|
||||
store.create_subject(role()).unwrap(),
|
||||
store.create_subject(role()).unwrap(),
|
||||
store.create_subject(role()).unwrap(),
|
||||
];
|
||||
expected.sort_by(|left, right| {
|
||||
left.created_at
|
||||
.cmp(&right.created_at)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
|
||||
let page = store.list_subjects(2).unwrap();
|
||||
assert_eq!(page.items, expected[..2]);
|
||||
assert!(page.has_more);
|
||||
assert!(store.list_subjects(0).is_err());
|
||||
assert!(store.list_subjects(101).is_err());
|
||||
|
||||
assert!(store.subject("missing-subject").unwrap().is_none());
|
||||
let complete = store.list_subjects(3).unwrap();
|
||||
assert_eq!(complete.items, expected);
|
||||
assert!(!complete.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
let session = attribution(
|
||||
&subject.id,
|
||||
"runtime-1",
|
||||
"worker-1",
|
||||
"session-1",
|
||||
"2026-09-28T09:00:00.000Z",
|
||||
);
|
||||
|
||||
let (first_candidate, stored_session) = store
|
||||
.stage_candidate_with_attribution(
|
||||
candidate(&subject.id, "candidate-1", "workspace-a"),
|
||||
session.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored_session, session);
|
||||
assert_eq!(
|
||||
first_candidate.source_refs[0].session_id.as_deref(),
|
||||
Some("session-1")
|
||||
);
|
||||
|
||||
let create_request = CandidateDecisionRequest {
|
||||
request_id: "decision-create-1".into(),
|
||||
candidate_id: first_candidate.id.clone(),
|
||||
reason: "adopt the attributed candidate".into(),
|
||||
decision: CandidateDecision::Apply {
|
||||
target: MemoryRevisionTarget::Create,
|
||||
draft: draft(
|
||||
"Keep the first confirmed rule",
|
||||
"initial attributed decision",
|
||||
),
|
||||
},
|
||||
};
|
||||
let created = store
|
||||
.decide_candidate(&subject.id, create_request.clone())
|
||||
.unwrap();
|
||||
let first_revision = created.memory.unwrap();
|
||||
assert_eq!(first_revision.revision, 1);
|
||||
assert_eq!(
|
||||
first_revision.source_candidate_ids,
|
||||
[first_candidate.id.clone()]
|
||||
);
|
||||
assert_eq!(created.resolution.affected_memory.len(), 1);
|
||||
assert_eq!(created.resolution.affected_memory[0].revision, 1);
|
||||
|
||||
// Exact decision retries return the original receipt rather than creating
|
||||
// a second revision or resolution.
|
||||
let replayed = store.decide_candidate(&subject.id, create_request).unwrap();
|
||||
assert_eq!(replayed.store_revision, created.store_revision);
|
||||
assert_eq!(replayed.memory.as_ref().unwrap().revision, 1);
|
||||
|
||||
let (second_candidate, _) = store
|
||||
.stage_candidate_with_attribution(
|
||||
candidate(&subject.id, "candidate-2", "workspace-a"),
|
||||
session,
|
||||
)
|
||||
.unwrap();
|
||||
let revised = store
|
||||
.decide_candidate(
|
||||
&subject.id,
|
||||
CandidateDecisionRequest {
|
||||
request_id: "decision-revise-2".into(),
|
||||
candidate_id: second_candidate.id.clone(),
|
||||
reason: "apply the attributed correction".into(),
|
||||
decision: CandidateDecision::Apply {
|
||||
target: MemoryRevisionTarget::Revise {
|
||||
memory_id: first_revision.id.clone(),
|
||||
expected_revision: 1,
|
||||
},
|
||||
draft: draft("Keep the corrected confirmed rule", "attributed correction"),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let second_revision = revised.memory.unwrap();
|
||||
assert_eq!(second_revision.revision, 2);
|
||||
assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]);
|
||||
|
||||
let historical = store
|
||||
.scoped_memory_revision(&subject.id, &first_revision.id, 1)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(historical.source_candidate_ids, [first_candidate.id]);
|
||||
assert_eq!(
|
||||
store
|
||||
.session_attribution("session-1")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.subject_id,
|
||||
subject.id
|
||||
);
|
||||
|
||||
let generation = store.prepare_surface_generation(&subject.id).unwrap();
|
||||
assert_eq!(generation.materials.len(), 1);
|
||||
assert_eq!(generation.materials[0].memory_id, second_revision.id);
|
||||
assert_eq!(generation.materials[0].revision, 2);
|
||||
let expected_ref = MemoryRevisionRef {
|
||||
memory_id: second_revision.id.clone(),
|
||||
revision: second_revision.revision,
|
||||
};
|
||||
let published = store
|
||||
.publish_surface_generation(
|
||||
&subject.id,
|
||||
&generation.id,
|
||||
vec![SurfacePoint {
|
||||
body_md: "- Keep the corrected confirmed rule".into(),
|
||||
memory_refs: vec![expected_ref.clone()],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(published.memory_refs, [expected_ref.clone()]);
|
||||
assert_eq!(published.built_from_store_revision, revised.store_revision);
|
||||
|
||||
let resident = store.resident_surface(&subject.id).unwrap();
|
||||
assert_eq!(resident.availability, SurfaceAvailability::Ready);
|
||||
assert_eq!(resident.snapshot.unwrap().memory_refs, [expected_ref]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_json_roundtrips_and_rejects_invalid_shapes() {
|
||||
let staging = candidate("subject-1", "candidate-1", "workspace-a");
|
||||
|
||||
Reference in New Issue
Block a user