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");
|
||||
|
||||
+4
-1
@@ -16,7 +16,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
||||
8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||
9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority.
|
||||
10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection.
|
||||
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
|
||||
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — deprecated single-document Workspace Memory authority and its separation from subject-scoped subjektiv Memory.
|
||||
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
|
||||
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
|
||||
14. [`development/ui-ux/design-language.md`](development/ui-ux/design-language.md) — Workspace Webの視覚・interaction原則、文法、token、汎用component。
|
||||
@@ -30,6 +30,9 @@ It is not a dumping ground for external research, old plans, API inventories, or
|
||||
22. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
|
||||
23. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
|
||||
24. [`design/server-feature-storage.md`](design/server-feature-storage.md) — Server管理のWorkspace/Feature専用SQLite、migration、transaction、backup/restore、削除・shutdown境界。
|
||||
25. [`design/subjektiv-store.md`](design/subjektiv-store.md) — subject-scoped Memoryのownership、authorization、Session attribution、revision、candidate consolidation境界。
|
||||
26. [`design/subjektiv-surface.md`](design/subjektiv-surface.md) — confirmed Memoryから生成するresident surface、availability、append-only restore injection。
|
||||
27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。
|
||||
|
||||
## What belongs here
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
# Workspace Memory authority
|
||||
# Legacy Workspace Memory authority
|
||||
|
||||
Generated Memory is durable Workspace context owned by the Workspace Server control plane. Runtime Workers query and mutate it through typed Workspace API operations; they do not read or write repository-local paths.
|
||||
The single-document Workspace Memory product is a **legacy/deprecated** compatibility surface. Its document, staging candidates, and staging resolutions are durable Workspace context owned by the Workspace Server control-plane database. It is separate from subject-scoped subjektiv Memory, whose persistence authority is the Workspace's Server-managed `subjektiv` Feature database.
|
||||
|
||||
The active Memory document and staging/audit lifecycle are stored by the Server-owned backend. Worker-side `memory` code contains only shared schema, extraction, audit, and transport types needed to construct those operations.
|
||||
Legacy Runtime Workers query and mutate the old document through the established typed `/api/w/{workspace_id}/memory...` operations and `MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and legacy `MemoryStaging*` tools. While those surfaces remain available, their existing inputs keep their existing Workspace-document meaning. They must not be silently redirected to subjektiv, assigned an inferred subject, or reused with a new interpretation.
|
||||
|
||||
Worker-side `memory` code owns shared schemas and transport types, not persistence. Legacy `AppendAudit` is acknowledgement-only and has no dedicated durable Memory-audit rows. General Session logs and diagnostics remain independently durable under their own retention policies.
|
||||
|
||||
Repository or ancestor `.yoi/memory`, `.yoi/knowledge`, and malformed marker trees are ignored. There is no cwd/ancestor discovery, dual read, startup import, or automatic migration. The removed `yoi memory lint` command is intentionally not a compatibility surface; invoking `yoi memory ...` is an unknown command.
|
||||
|
||||
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only session records for exact current facts.
|
||||
Memory remains supporting context rather than implementation authority. Use Tickets, Objectives, repository files, Git history, and append-only Session records for exact current facts.
|
||||
|
||||
## subjektiv cutover and reset
|
||||
|
||||
Enabling `feature.subjektiv` does not migrate or erase legacy rows. The only supported destructive boundary is the explicit, authorized legacy reset API described by the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md). It atomically deletes the old document, staging candidates, and staging resolutions while retaining Workspace Memory settings, Sessions and diagnostics, Tickets, Knowledge, Skills, the subjektiv Feature database, and unrelated Workspace data. The reset is operator-triggered and idempotent; it is never a startup migration or Profile side effect.
|
||||
|
||||
## Upgrade guidance
|
||||
|
||||
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the Workspace-backed product surface available to the deployment. Stale files may be archived or deleted after verification.
|
||||
Old repository-local Memory data is not imported automatically. Before upgrading from a version that used `.yoi/memory`, explicitly export any data that must be retained with that old version and import it into the intended Workspace-backed product surface. Do not assume the legacy control-plane reset imports repository-local or legacy Workspace data into subjektiv. Stale files may be archived or deleted only after verification.
|
||||
|
||||
## Preserved filesystem boundaries
|
||||
|
||||
This removal does not affect repository checkouts, Git metadata, Runtime-owned Workdirs/session logs/artifacts, Server data directories, or XDG client/server configuration. It removes only ambient repository-local Memory authority.
|
||||
Repository-local Memory removal and the control-plane legacy reset do not affect repository checkouts, Git metadata, Runtime-owned Workdirs, Session logs or artifacts, Server data directories, XDG client/server configuration, or the subjektiv Feature database. They remove only their explicitly named legacy authority.
|
||||
|
||||
@@ -16,7 +16,7 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d
|
||||
- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend.
|
||||
- `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract.
|
||||
- `tools` implements built-in tools with bounded output and policy-aware execution.
|
||||
- `memory` owns shared Memory domain, extraction, audit-event, and Workspace API transport types. Persistence and lifecycle execution belong to the Workspace Server control plane.
|
||||
- `memory` owns shared legacy Memory transport plus extraction/evidence schemas, not persistence. Legacy single-document Memory lives in the Workspace Server control-plane database; subject-scoped Memory lives in the Workspace's Server-managed `subjektiv` Feature database.
|
||||
- `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files.
|
||||
- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server-managed Feature storage
|
||||
|
||||
`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool.
|
||||
`yoi-workspace-server::feature_storage` provides the SQLite lifecycle boundary for trusted Server-side Features. It is not a Worker feature API and it is not a model-visible SQL tool. Subject-scoped Memory is persisted in the Workspace's `subjektiv` Feature database through this boundary; legacy single-document Workspace Memory remains in the control-plane database and is not owned, imported, or reset by `FeatureStorage`.
|
||||
|
||||
## Ownership boundary
|
||||
|
||||
@@ -126,7 +126,7 @@ Not registering/opening a disabled Feature creates nothing. Disabling a previous
|
||||
|
||||
`WorkspaceFeatureStorage::backup` uses SQLite's online backup API for each discovered Feature database, so a live WAL is not ignored or copied as a bare database file. Each image passes `integrity_check` and `foreign_key_check`, and a manifest binds the snapshot to its Workspace and Feature identifiers. The snapshot directory is published with an atomic rename only after all images succeed.
|
||||
|
||||
Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers.
|
||||
Each Feature image is internally consistent. There is deliberately no cross-database snapshot instant or distributed transaction between Feature databases or the Server DB. A caller that needs a higher-level coordinated backup must first stop relevant Feature writers. In particular, the [subjektiv product cutover](../development/subjektiv-product-cutover.md) stops legacy and subjektiv activity before pairing a control-plane backup with the subjektiv Feature backup; the explicit legacy reset never deletes or recreates the Feature database.
|
||||
|
||||
`restore` rejects the wrong Workspace manifest, corrupt/foreign-key-invalid images, an existing destination, or any live connection. It builds validated databases in a temporary directory and atomically publishes the Workspace storage directory. Restore is an operator/Server lifecycle operation, not a model API.
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# subjektiv subject Memory store
|
||||
|
||||
T-667 introduces a trusted Server-side `subjektiv` repository in
|
||||
`yoi_workspace_server::subjektiv`. It is a domain store, not a Worker tool or a
|
||||
cutover of the existing Workspace Memory authority.
|
||||
The trusted Server-side `subjektiv` repository in
|
||||
`yoi_workspace_server::subjektiv` is the persistence authority for subject-scoped
|
||||
Memory. It is a domain store, not a Worker tool. Legacy single-document Workspace
|
||||
Memory remains a separate, deprecated control-plane authority until an operator
|
||||
performs the explicit reset defined by the
|
||||
[subjektiv product cutover runbook](../development/subjektiv-product-cutover.md);
|
||||
legacy rows are never imported, dual-written, or silently reinterpreted as subject
|
||||
Memory.
|
||||
|
||||
## Ownership and scope
|
||||
|
||||
@@ -85,8 +90,9 @@ immutable. A resolution stores both the resolution JSON and the exact staged JSO
|
||||
bytes that were resolved. Aggregate seal rows prevent evidence, derivations,
|
||||
resolution targets, or snapshot references from being appended after their
|
||||
parent JSON is finalized. Foreign keys plus retention triggers prevent dangling
|
||||
candidate, derivation, resolution-target, and snapshot references. There is no
|
||||
record deletion API in this baseline.
|
||||
candidate, derivation, resolution-target, and snapshot references. Ordinary
|
||||
subject operations expose no record deletion API; the legacy reset contract does
|
||||
not touch this Feature database.
|
||||
|
||||
Search columns and JSON projections are written together by the typed repository.
|
||||
Raw SQL remains private trusted implementation detail. Existing Workspace Memory
|
||||
@@ -280,3 +286,34 @@ proposal unresolved/conflicted. It must not parse target information from the
|
||||
claim, retarget to the latest revision, or revive a retracted Memory. Valid
|
||||
transitions at staging are revise while active/resolved, active→resolved,
|
||||
active/resolved→retracted, and resolved→active. Retraction remains terminal.
|
||||
|
||||
## Candidate consolidation and corrections (T-670)
|
||||
|
||||
The Backend starts a restricted `builtin:subjektiv-memory-consolidation` Worker
|
||||
for exactly one authenticated subject. The Host binds the subject; model-visible
|
||||
inputs cannot select a Workspace, subject, Runtime, Worker, or Session. Its tools
|
||||
list/read pending immutable candidates, query/read confirmed revisions, and make
|
||||
one candidate decision. Candidate text is evidence rather than authority.
|
||||
|
||||
Every decision carries a stable request ID. An exact retry returns the committed
|
||||
receipt, while reuse with different input is a conflict. Applying a candidate
|
||||
atomically writes one new confirmed revision, its exact candidate and derivation
|
||||
edges, the immutable resolution and affected revision references, and advances
|
||||
the subject store revision. A non-applied decision explicitly records
|
||||
`discarded`, `invalid`, `duplicate`, or `already_covered`; an empty model response,
|
||||
transport failure, or aborted consolidation records no disposition and leaves the
|
||||
candidate pending. A successful applied response is the only evidence that
|
||||
confirmed Memory changed.
|
||||
|
||||
A typed revision proposal must be applied to its exact `memory_id` and
|
||||
`expected_revision`, preserving its intent and change reason. The transaction
|
||||
rechecks the current revision and state transition. It never rebases or retargets
|
||||
a stale proposal, revives retracted Memory, or extracts a target from claim prose.
|
||||
Correction/refinement of the same experience appends under the same Memory ID;
|
||||
a corrected experience after terminal retraction receives a new Memory ID and may
|
||||
cite the retracted fixed revision as a derivation. Surface generation is a
|
||||
separate post-consolidation lifecycle: its failure cannot roll back a committed
|
||||
candidate decision.
|
||||
|
||||
For the product activation, legacy reset, and operator recovery boundary, follow
|
||||
the [subjektiv product cutover runbook](../development/subjektiv-product-cutover.md).
|
||||
|
||||
@@ -22,7 +22,34 @@ Every editor point carries one or more exact `{memory_id, revision}` references.
|
||||
|
||||
Publication is idempotent for identical output at one store revision. A different concurrent output cannot replace the first published surface. Confirmed Memory writes atomically mark the prior surface stale. Failed surface work never rolls back confirmed Memory or candidate disposition.
|
||||
|
||||
Resident injection loads only a `ready` snapshot built from the subject's exact current store revision. The resident Backend contract transports typed `ready`, `un-generated`, `failed`, and `stale` availability separately from optional Markdown, so a valid ready-empty snapshot cannot be conflated with omission or request failure. `un-generated`, `failed`, and `stale` states are omitted rather than represented as "no Memory". A successful empty generation is represented by a current ready snapshot with empty Markdown. Snapshots created before this generation policy existed are retained as immutable history but migrate as `stale` and must be regenerated before injection. Runtime-owned new Workers install Features before durably materializing the initial session head, ensuring the current contribution is present in their initial prompt. Restored Workers preserve that persisted prompt and all prior history verbatim, then append one durable restore-boundary system item that supersedes earlier resident summaries with the latest ready surface, an explicit ready-empty state, or a no-current-surface tombstone; subsequent turns in the same restored process do not append it again.
|
||||
Resident injection uses five distinct product states:
|
||||
|
||||
- `ready`: inject the non-empty snapshot only when its `built_from_store_revision`
|
||||
equals the subject's exact current store revision;
|
||||
- `ready-empty`: a successful current generation selected no Markdown; inject no
|
||||
prose, but preserve the explicit successful-empty state;
|
||||
- `ungenerated`: no successful current generation exists; inject nothing;
|
||||
- `stale`: confirmed Memory changed after publication (or the snapshot predates
|
||||
this generation policy); inject nothing until regeneration succeeds;
|
||||
- `failed`: the generation attempt failed; inject nothing and preserve the typed
|
||||
failure for diagnostics/retry rather than representing it as empty Memory.
|
||||
|
||||
The resident Backend transports availability separately from optional Markdown.
|
||||
In storage, `ready-empty` is a current `ready` snapshot with empty Markdown; it is
|
||||
never conflated with `ungenerated`, `stale`, `failed`, request failure, or
|
||||
omission. Publication is append-only: generation inputs/runs, failures, snapshots,
|
||||
and exact references remain immutable history, while current state points at the
|
||||
applicable result.
|
||||
|
||||
Runtime-owned new Workers install Features before durably materializing the
|
||||
initial Session head, so `ready` content or the explicit `ready-empty` result is
|
||||
represented at initial prompt construction. Restored Workers preserve their
|
||||
persisted prompt and all prior history verbatim. They append one durable
|
||||
restore-boundary system item that supersedes earlier resident summaries with the
|
||||
latest `ready` surface, an explicit `ready-empty` state, or a
|
||||
no-current-surface tombstone for `ungenerated`, `stale`, or `failed`; subsequent
|
||||
turns in that restored process do not append the boundary again. No regeneration,
|
||||
correction, failure, or cutover rewrites a committed Session entry.
|
||||
|
||||
## Semantic fixture and verification
|
||||
|
||||
@@ -45,4 +72,4 @@ A valid review fixture must:
|
||||
- cite only exact refs supplied with those materials;
|
||||
- distinguish mechanical citation validation from a human/model semantic review of the wording.
|
||||
|
||||
The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/un-generated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior.
|
||||
The repository tests exercise deterministic bounds and fair budget reclamation, subject isolation, active-current-only selection, complete normalized editor-request accounting, output/reference rejection, empty/ungenerated/failed/stale states, legacy regeneration, generation races, bounded conflict retry, terminal failure recording, parallel publication, correction/retraction staleness, and publication idempotency. The prompt fixture above is the manual semantic check for condition, negation, reservation, conflict, expiry, and duplicate editing behavior.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# subjektiv product cutover runbook
|
||||
|
||||
This runbook defines the product and operator boundary for moving a Workspace from legacy single-document Memory to subject-scoped subjektiv Memory. The cutover changes which product path is active; it does not reinterpret legacy tool or API inputs, migrate legacy records into subjektiv, or make historical Worker attribution into current authority.
|
||||
|
||||
T-672 documents and validates the contract only. It does **not** authorize or perform a reset in any real environment.
|
||||
|
||||
## Authorities and persistence
|
||||
|
||||
Two independent Memory systems exist during the transition:
|
||||
|
||||
| Product path | Persistence owner | Durable content | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| Legacy Workspace Memory | Workspace Server control-plane database | one Workspace document, staging candidates, and staging resolutions | legacy/deprecated; retained only for compatibility and explicit reset |
|
||||
| Subject Memory | Server-managed `FeatureStorage`, in the Workspace-scoped `subjektiv` Feature database | subjects, immutable candidate and resolution records, confirmed Memory revisions, Session attribution, surface generation records, and immutable surface snapshots | current product path |
|
||||
|
||||
The shared `memory` crate and Worker Features do not own either database. `FeatureStorage` owns the subjektiv SQLite lifecycle (path, connection, migration, backup/restore, shutdown, and Workspace deletion), while the subjektiv repository owns its schema and domain transactions. The legacy control-plane tables are not imported or dual-written into subjektiv.
|
||||
|
||||
Every subject operation is bounded first by the authenticated Workspace and then by an explicitly authorized subject. A subject ID is a selector, not a credential. The Host derives subject scope from authenticated Runtime/Worker source proof and current ownership of the opaque `subjektiv:<subject-id>` Worker key; model-visible requests do not supply or expand that authority.
|
||||
|
||||
The current Worker remains Yoi's keyed-singleton responsibility. The subjektiv database records immutable historical Worker and Session attribution, but it contains no second `current_worker_id` mapping and historical attribution is never used to choose or authorize the current Worker. Stopping or replacing a Worker does not retire its subject, and retiring a subject does not stop a Worker.
|
||||
|
||||
Workspace Memory settings remain Workspace-owned configuration. When `feature.subjektiv` is enabled, the Backend binds the trusted settings snapshot used for extraction language; the snapshot is not subject identity and cannot be authored by a Profile, Browser, or model.
|
||||
|
||||
## Session, Memory, and correction semantics
|
||||
|
||||
- **Session attribution and recall:** only a committed primary-Worker Session with Host-recorded subject attribution enters subject Session discovery. Attribution is immutable and idempotent. Recall reads the retained or committed-archive Session projection under current Workspace, subject, observation, and retention authorization; it does not copy Session bodies into subjektiv, infer attribution for older Sessions, restore a Worker, or turn recalled tool output into `HumanInput`.
|
||||
- **`SubjektivMemoryRemember`:** stages a new candidate; it does not directly create confirmed Memory. Explicit committed evidence is resolved against the Session capture. A non-preference request without an entry is staged only after its tool call commits, using the committed call as model-origin evidence. An uncommitted operation creates no candidate.
|
||||
- **`SubjektivMemoryProposeRevision`:** stages a candidate with typed target metadata: exact Memory ID, expected revision, intent, and change reason. It does not mutate the target or silently retarget to a newer revision.
|
||||
- **Candidate consolidation:** a restricted, subject-bound consolidator compares an immutable pending candidate with confirmed Memory and makes one idempotent atomic decision. An applied decision writes the new confirmed revision, exact provenance and affected revision references, and the immutable candidate resolution in one subjektiv transaction. Non-applied dispositions are explicit (`discarded`, `invalid`, `duplicate`, or `already_covered`). A failed or empty model run is not a disposition, and surface generation failure cannot undo an already committed decision.
|
||||
- **Corrections:** refinement or correction of the same experience appends the next revision under the same Memory ID using an expected-revision check. Stale proposals remain unresolved/conflicted and are never rebased automatically. Retraction is terminal; a later corrected experience receives a new Memory ID and may derive from the old fixed revision. Old revisions, candidates, resolutions, provenance, and surface snapshots remain immutable history.
|
||||
|
||||
Confirmed Memory is useful subject context, not authorization and not exact implementation authority. Tickets, Objectives, repository state, Git history, and append-only Session records remain the exact sources for implementation state.
|
||||
|
||||
## Resident surface and history
|
||||
|
||||
A subject surface is a bounded rendering of current active confirmed revisions, not another Memory authority. The injection contract is:
|
||||
|
||||
- **ready:** inject the current non-empty snapshot only when it was built from the subject's exact current store revision;
|
||||
- **ready-empty:** explicitly record that a successful current generation produced no Markdown and inject no Memory prose; do not conflate this with missing or failed generation;
|
||||
- **ungenerated:** inject nothing and report no current surface;
|
||||
- **stale:** inject nothing until regeneration publishes a snapshot for the current store revision;
|
||||
- **failed:** inject nothing; retain the failure for diagnosis and retry rather than presenting it as an empty Memory.
|
||||
|
||||
A new Worker receives the current ready or ready-empty result in its initial durable prompt construction. A restored Worker does not rewrite its persisted prompt or prior Session history. It appends one durable restore-boundary system item that supersedes prior resident summaries with the latest ready surface, explicit ready-empty state, or a no-current-surface tombstone. Later turns in the same restored process do not append the boundary again. This preserves append-only history: no stale summary, failed generation, correction, or cutover operation edits previously committed Session entries or immutable subjektiv records.
|
||||
|
||||
## Legacy/deprecated compatibility boundary
|
||||
|
||||
The existing legacy tools (`MemoryQuery`, `MemoryReadDocument`, `MemoryUpdateDocument`, and the legacy `MemoryStaging*` operations) and `/api/w/{workspace_id}/memory...` APIs retain their established Workspace-document meanings while they exist. They are legacy/deprecated surfaces, not aliases for subject Memory. Do not silently route an old request to subjektiv, infer a subject from old input, or change an old input field's meaning.
|
||||
|
||||
New product work uses the explicit subject/session/confirmed-Memory/revision/source and surface operations. Old clients must be stopped or upgraded before reset; a post-reset legacy read may observe the legacy empty/default state, but it must never expose subjektiv records through the old schema.
|
||||
|
||||
## Explicit legacy reset contract
|
||||
|
||||
The legacy reset is a destructive, operator-triggered API operation scoped to one authorized Workspace. Startup, Profile resolution, enabling `feature.subjektiv`, Worker restore, consolidation, and ordinary reads must never trigger it implicitly.
|
||||
|
||||
In one control-plane database transaction, the reset deletes all of the following legacy rows for the Workspace:
|
||||
|
||||
1. the legacy Memory document;
|
||||
2. all legacy Memory staging candidates;
|
||||
3. all legacy Memory staging resolutions.
|
||||
|
||||
The operation is all-or-nothing. A failure before commit leaves all three legacy sets unchanged. A retry after success is idempotent: absence of those rows is success and must not delete anything outside the legacy set.
|
||||
|
||||
Legacy `AppendAudit` was acknowledgement-only and created no dedicated durable Memory-audit rows, so there is no fourth legacy audit table to erase. General Session logs and diagnostics remain retained under their own policies.
|
||||
|
||||
The reset must retain:
|
||||
|
||||
- Workspace Memory settings and their revision;
|
||||
- Sessions, Session logs, archives, and diagnostics;
|
||||
- Tickets, Objectives, and their audit/history;
|
||||
- Knowledge and Skills;
|
||||
- the entire Workspace-scoped subjektiv Feature database, including subjects, candidate/resolution history, confirmed revisions, attribution, generation runs, failures, and surface snapshots;
|
||||
- all other new-product and unrelated Workspace data.
|
||||
|
||||
The reset is not a subject deletion API, a Workspace deletion API, a settings reset, a retention override, or a data migration.
|
||||
|
||||
## Operator procedure
|
||||
|
||||
Perform the cutover in this order:
|
||||
|
||||
1. **Stop legacy activity.** Disable legacy `feature.memory` Profiles and automatic extraction/consolidation, stop or drain old Workers, and block Browser/API clients from writing `/memory` routes. Wait for in-flight legacy writes to finish. Do not run legacy and new automatic extraction for the same Worker.
|
||||
2. **Back up while writers are stopped.** Capture the control-plane database containing legacy Memory and a `FeatureStorage` backup containing subjektiv. Record the Workspace and backup identities and verify the backup artifacts. Feature backup is internally consistent, but there is no cross-database snapshot instant; stopping both writer sets is what makes the pair a usable cutover checkpoint.
|
||||
3. **Trigger reset explicitly.** An authenticated operator invokes the legacy reset API for the intended Workspace. Do not automate this as a startup migration or infer consent from `feature.subjektiv` enablement.
|
||||
4. **Verify both sides.** Confirm the legacy document, staging, and resolutions are absent (or return their defined empty/default projection); confirm Workspace Memory settings and unrelated control-plane data remain; reopen/read the subjektiv store and verify the expected subject, confirmed revisions, attribution, and current surface state are unchanged. Verify a subject Worker resolves through the `subjektiv:<subject-id>` singleton and has only the intended new tools.
|
||||
5. **Resume only the new path.** Start subject Workers with `feature.subjektiv`, keep legacy write surfaces disabled, and monitor typed surface/Session diagnostics. Do not treat an ungenerated, stale, or failed surface as ready-empty.
|
||||
|
||||
### Failure and recovery
|
||||
|
||||
- If reset returns a definite failure, the transaction rollback means legacy document, staging, and resolutions remain together. Correct the cause and rerun the same explicit operation.
|
||||
- If communication is lost and the result is ambiguous, keep legacy writers stopped, inspect all three legacy sets, and rerun. Idempotency makes a committed first attempt safe to repeat and atomicity prevents a valid partial state.
|
||||
- If verification of retained data or the new path fails, keep both write paths stopped. Restore the coordinated pre-reset backups according to the normal control-plane and `FeatureStorage` restore procedures, verify them, and only then resume. Never "recover" by deleting or recreating the subjektiv database.
|
||||
- Preserve Session logs and diagnostics throughout; they are recovery evidence, not reset targets.
|
||||
|
||||
## Validation boundary
|
||||
|
||||
The validation supporting this cutover proves substrate-level store shutdown/reopen behavior and restoration of a Worker object with its persisted history. It does **not** claim a restart of the Server or Runtime process, behavior during communication loss, real-model consolidation or surface quality, or a full-process end-to-end cutover. Those require separate operational validation before a production reset.
|
||||
|
||||
No real environment reset is performed or authorized by T-672.
|
||||
@@ -57,8 +57,11 @@ Workspaceを選ぶ前のscopeを扱う。
|
||||
2. Objectives
|
||||
3. Merge Requests
|
||||
4. Memory
|
||||
- Document
|
||||
- Staging
|
||||
- Subjects
|
||||
- Surface
|
||||
- Committed Memory
|
||||
- Revisions
|
||||
- Sources
|
||||
5. Workers
|
||||
|
||||
### Settings
|
||||
@@ -122,6 +125,16 @@ Workspace pageは、次のいずれかを基本形とする。
|
||||
- narrow viewportでは一列にする。
|
||||
- form内でfilled actionにするのはsave actionだけとする。
|
||||
|
||||
### Memory
|
||||
|
||||
- Memoryの入口はWorkspace全体のlegacy `Document` / `Staging`ではなく、明示的に選択したsubjectとする。
|
||||
- subject detailではcurrent Workerをkeyed-singleton projectionとして表示し、historical Worker/Session attributionをcurrent authorityとして扱わない。
|
||||
- `Surface`はresident contextのcurrent stateを表示する。`ready`、`ready-empty`、`ungenerated`、`stale`、`failed`を区別し、空表示やrequest failureへまとめない。
|
||||
- `Committed Memory`はcurrent confirmed revisionの一覧とdetailを持つ。revision historyはappend-only viewとして分離し、過去revisionをcurrentへ暗黙fallbackしない。
|
||||
- `Sources`は候補、resolution、exact Memory revision refs、Host-recorded Session attribution、閲覧可能なSession sourceをたどるviewとする。source IDをpermissionとして扱わず、現在のWorkspace/subject authorizationを毎回適用する。
|
||||
- legacy `/memory` の`Document` / `Staging` viewはdeprecated compatibility surfaceとして明示し、新しいsubject viewへ同じinputを別の意味で転送しない。
|
||||
- Markdown本文とdetail presentationはT-661の共有rendererを再利用する。Memory route独自のrenderer、application shell、Header、Sidebarを再実装せず、既存shellのmain contentとnested navigationを保つ。
|
||||
|
||||
### Worker Console
|
||||
|
||||
- transcriptをprimary surfaceとする。
|
||||
|
||||
+56
-12
@@ -235,32 +235,40 @@ permission = "write"
|
||||
# # ref = "anthropic/claude-haiku-4-5"
|
||||
|
||||
|
||||
# ===== [feature.memory] ======================================================
|
||||
# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では
|
||||
# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを
|
||||
# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが
|
||||
# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。
|
||||
# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる
|
||||
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。
|
||||
# ===== [feature.memory] (legacy/deprecated) ================================
|
||||
# Legacy single-document Workspace Memory compatibility設定。新規product pathは
|
||||
# `[feature.subjektiv]` を使う。旧`MemoryQuery` / `MemoryReadDocument` /
|
||||
# `MemoryUpdateDocument` / `MemoryStaging*` toolsと`/memory` APIのinput意味は
|
||||
# 変更せず、subject Memoryへ暗黙転送しない。
|
||||
#
|
||||
# [feature.memory.profile]
|
||||
# resolved Worker ManifestではProfile由来の設定を`profile`に、Backend由来の
|
||||
# Workspace設定snapshotを`workspace_settings`に分離して保存する。source TOMLの
|
||||
# `[feature.memory]` fieldsはresolve後に`profile`へ入り、`workspace_settings`は
|
||||
# Backendだけがbindする信頼済み入力で、Profile・Browser・
|
||||
# model入力から指定できない。source `enabled = false`(resolvedでは
|
||||
# `profile.enabled = false`)の場合、legacy Memory tools、
|
||||
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない。
|
||||
# `[feature.subjektiv]`の有効化やWorker restoreはlegacy dataをreset/migrateしない。
|
||||
# legacy resetは認証済みoperatorが明示APIで別途実行する。
|
||||
#
|
||||
# [feature.memory]
|
||||
# enabled = true
|
||||
# staging_tools = false
|
||||
#
|
||||
# [feature.memory.profile.resident]
|
||||
# [feature.memory.resident]
|
||||
# inject_summary = true
|
||||
#
|
||||
# [feature.memory.profile.extraction]
|
||||
# [feature.memory.extraction]
|
||||
# enabled = true
|
||||
# reasoning = "medium"
|
||||
# threshold = 30000
|
||||
# worker_max_turns = 8
|
||||
#
|
||||
# # modelは任意。省略時はmain modelをcloneする。
|
||||
# # [feature.memory.profile.extraction.model]
|
||||
# # [feature.memory.extraction.model]
|
||||
# # ref = "codex-oauth/gpt-5.6-luna"
|
||||
#
|
||||
# [feature.memory.profile.consolidation]
|
||||
# [feature.memory.consolidation]
|
||||
# request_enabled = true
|
||||
#
|
||||
# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。
|
||||
@@ -273,6 +281,42 @@ permission = "write"
|
||||
# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。
|
||||
|
||||
|
||||
# ===== [feature.subjektiv] ================================================
|
||||
# Current subject-scoped Memory product。ProfileはFeature activationと自動extract
|
||||
# policyだけを所有する。subject idはManifest fieldではない。Hostが認証済み
|
||||
# Runtime/Worker sourceとcurrent `subjektiv:<subject-id>` keyed-singleton ownershipから
|
||||
# operationごとにbindし、明示Workspace/subject authorizationを再評価する。
|
||||
#
|
||||
# `enabled = true`はsubject tools、Session attribution/recall、confirmed Memory、
|
||||
# candidate consolidation、resident surface lifecycleを有効にする。legacy
|
||||
# `[feature.memory]` dataのimport/reset/dual-writeは行わない。同じWorkerでlegacyと
|
||||
# subjektivの自動extract destinationを同時に有効化する構成は拒否される。
|
||||
#
|
||||
# [feature.subjektiv]
|
||||
# enabled = true
|
||||
#
|
||||
# [feature.subjektiv.extraction]
|
||||
# enabled = true
|
||||
# reasoning = "medium"
|
||||
# threshold = 50000
|
||||
# worker_max_turns = 8
|
||||
#
|
||||
# # modelは任意。省略時はmain modelをcloneする。
|
||||
# # [feature.subjektiv.extraction.model]
|
||||
# # ref = "codex-oauth/gpt-5.6-luna"
|
||||
#
|
||||
# # Backendがresolved Manifestへbindする。Profile・Browser・model入力では指定しない。
|
||||
# # language/settings revisionのtrusted snapshotであり、subject identityではない。
|
||||
# # [feature.subjektiv.workspace_settings]
|
||||
# # workspace_id = "workspace-id"
|
||||
# # settings_revision = 1
|
||||
# # language = "日本語"
|
||||
#
|
||||
# Surface selection/generation/injection、candidate consolidation eligibility、recall
|
||||
# limitsはHost/Backend policyが所有し、通常Worker Manifestには含めない。current Worker
|
||||
# authorityはYoi keyed singletonに残り、historical Session attributionを代用しない。
|
||||
|
||||
|
||||
# ===== [skills] =============================================================
|
||||
# 外部 Agent Skills (`SKILL.md`) の候補ディレクトリ群。
|
||||
# セクション省略 = 何も使用しない (implicit な `$config_dir/skills/` 検索や
|
||||
|
||||
+1258
-1
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import "./base.dcdl" // {
|
||||
feature = {
|
||||
task = { enabled = false; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||
web = { enabled = false; };
|
||||
image = { enabled = false; };
|
||||
sub_worker = { enabled = false; };
|
||||
|
||||
@@ -24,16 +24,15 @@ compaction = {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = {
|
||||
memory = { enabled = false; staging_tools = false; };
|
||||
subjektiv = {
|
||||
enabled = true;
|
||||
resident = { inject_summary = true; };
|
||||
extraction = {
|
||||
enabled = true;
|
||||
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||
reasoning = "medium";
|
||||
threshold = 50000;
|
||||
};
|
||||
consolidation = { request_enabled = true; };
|
||||
};
|
||||
web = { enabled = true; };
|
||||
image = { enabled = true; };
|
||||
|
||||
@@ -10,7 +10,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = true; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
flow = { enabled = true; };
|
||||
|
||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = true; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
worker = { enabled = true; direct_spawn = false; };
|
||||
|
||||
@@ -7,6 +7,7 @@ import "./base.dcdl" // {
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = false; staging_tools = false; };
|
||||
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||
web = { enabled = true; };
|
||||
image = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
|
||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = true; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
|
||||
@@ -7,7 +7,19 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = false; };
|
||||
memory = { enabled = true; staging_tools = true; };
|
||||
memory = {
|
||||
enabled = true;
|
||||
staging_tools = true;
|
||||
resident = { inject_summary = true; };
|
||||
extraction = {
|
||||
enabled = true;
|
||||
model = { ref = "codex-oauth/gpt-5.6-luna"; };
|
||||
reasoning = "medium";
|
||||
threshold = 50000;
|
||||
};
|
||||
consolidation = { request_enabled = true; };
|
||||
};
|
||||
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||
web = { enabled = false; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
|
||||
@@ -6,7 +6,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = true; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = true; direct_spawn = false; };
|
||||
|
||||
@@ -10,7 +10,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
memory = { enabled = true; };
|
||||
memory = { enabled = false; };
|
||||
subjektiv = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
|
||||
@@ -8,14 +8,8 @@ import "./base.dcdl" // {
|
||||
|
||||
feature = {
|
||||
task = { enabled = false; };
|
||||
memory = {
|
||||
enabled = true;
|
||||
staging_tools = true;
|
||||
resident = { inject_summary = false; };
|
||||
extraction = { enabled = false; };
|
||||
consolidation = { request_enabled = false; };
|
||||
};
|
||||
subjektiv = { enabled = false; extraction = { enabled = false; }; };
|
||||
memory = { enabled = false; staging_tools = false; };
|
||||
subjektiv = { enabled = true; extraction = { enabled = false; }; };
|
||||
web = { enabled = false; };
|
||||
image = { enabled = false; };
|
||||
sub_worker = { enabled = false; };
|
||||
|
||||
@@ -11,23 +11,263 @@ if (
|
||||
}
|
||||
|
||||
const workspaceId = "memory-review";
|
||||
const representativeSubjectId =
|
||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
|
||||
const representativeMemoryId =
|
||||
"memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001";
|
||||
const derivedMemoryId = "memory-derived-source-0002";
|
||||
const emptySubjectId = "empty-subject";
|
||||
const staleSubjectId = "stale-subject";
|
||||
const failedSubjectId = "failed-subject";
|
||||
const ungeneratedSubjectId = "ungenerated-subject";
|
||||
const errorSubjectId = "error-subject";
|
||||
|
||||
const paragraphs = Array.from(
|
||||
{ length: sectionCount },
|
||||
(_, index) =>
|
||||
`## Section ${
|
||||
index + 1
|
||||
}\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${
|
||||
}\n\nThis is representative resident Memory context with **important context**, an identifier \`memory-item-${
|
||||
index + 1
|
||||
}\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${
|
||||
index + 1
|
||||
}.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`,
|
||||
).join("\n\n");
|
||||
const bodyMd =
|
||||
`# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
|
||||
const surfaceBodyMd =
|
||||
`# Resident subject context\n\nThis generated surface is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
|
||||
"wide-value-".repeat(16)
|
||||
} | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${
|
||||
} | stable | keep scrolling local |\n\n##### Deep surface heading\n\n###### Deepest surface heading\n\n\`\`\`rust\nfn example() { println!("${
|
||||
"wide-code-".repeat(18)
|
||||
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`;
|
||||
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## Surface content complete`;
|
||||
|
||||
const detailBodyMd = `# Committed Memory detail
|
||||
|
||||
This immutable revision preserves its candidate evidence, source refs, and derivation refs.
|
||||
|
||||
| Local table overflow | Value |
|
||||
| --- | --- |
|
||||
| ${"detail-wide-value-".repeat(14)} | stable |
|
||||
|
||||
\`\`\`text
|
||||
${"detail-wide-code-".repeat(18)}
|
||||
\`\`\`
|
||||
|
||||
[Safe evidence link](https://example.com/evidence) [Unsafe evidence link](javascript:alert(2))
|
||||
|
||||
<script>alert("not rendered")</script>`;
|
||||
|
||||
const subjects = [
|
||||
subject(representativeSubjectId, "Release coordination", "active", 42),
|
||||
subject(emptySubjectId, "Empty ready subject", "active", 0),
|
||||
subject(staleSubjectId, "Stale surface subject", "active", 19),
|
||||
subject(failedSubjectId, "Failed surface subject", "active", 8),
|
||||
subject(ungeneratedSubjectId, "Ungenerated subject", "active", 0),
|
||||
subject(errorSubjectId, "Unavailable subject", "retired", 3),
|
||||
];
|
||||
|
||||
function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
state,
|
||||
store_revision: storeRevision,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
function memoriesFor(subjectId: string) {
|
||||
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
|
||||
if (subjectId === staleSubjectId) {
|
||||
return [
|
||||
memorySummary("stale-memory-1", 2, "lesson", "resolved", "A resolved stale-surface lesson"),
|
||||
];
|
||||
}
|
||||
if (subjectId === failedSubjectId) {
|
||||
return [
|
||||
memorySummary(
|
||||
"failed-memory-1",
|
||||
1,
|
||||
"constraint",
|
||||
"retracted",
|
||||
"A retracted failed-surface constraint",
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
memorySummary(
|
||||
representativeMemoryId,
|
||||
3,
|
||||
"decision",
|
||||
"active",
|
||||
"Keep subject Memory provenance typed and visible",
|
||||
),
|
||||
memorySummary(
|
||||
"memory-resolved-0002",
|
||||
2,
|
||||
"lesson",
|
||||
"resolved",
|
||||
"The bounded reader must preserve continuations",
|
||||
),
|
||||
memorySummary(
|
||||
"memory-retracted-0003",
|
||||
1,
|
||||
"working_assumption",
|
||||
"retracted",
|
||||
"T-672 UNIQUE END MARKER",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function memorySummary(
|
||||
id: string,
|
||||
revision: number,
|
||||
kind: string,
|
||||
state: string,
|
||||
claim: string,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
revision,
|
||||
kind,
|
||||
state,
|
||||
claim,
|
||||
excerpt: `Read-only ${state} Memory with exact revision and provenance metadata.`,
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceFor(subjectId: string) {
|
||||
if (subjectId === representativeSubjectId) {
|
||||
return {
|
||||
subject_id: subjectId,
|
||||
availability: "ready",
|
||||
snapshot: {
|
||||
snapshot_id: "surface-snapshot-0042",
|
||||
body_md: surfaceBodyMd,
|
||||
memory_refs: [
|
||||
{ memory_id: representativeMemoryId, revision: 3 },
|
||||
{ memory_id: derivedMemoryId, revision: 4 },
|
||||
],
|
||||
built_from_store_revision: 42,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (subjectId === emptySubjectId) {
|
||||
return {
|
||||
subject_id: subjectId,
|
||||
availability: "ready",
|
||||
snapshot: {
|
||||
snapshot_id: "surface-snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (subjectId === staleSubjectId) return { subject_id: subjectId, availability: "stale" };
|
||||
if (subjectId === failedSubjectId) return { subject_id: subjectId, availability: "failed" };
|
||||
return { subject_id: subjectId, availability: "ungenerated" };
|
||||
}
|
||||
|
||||
function memoryDetail(memoryId: string, requestedRevision: number | null) {
|
||||
const revision = requestedRevision && requestedRevision <= 3 ? requestedRevision : 3;
|
||||
const states = { 1: "retracted", 2: "resolved", 3: "active" } as const;
|
||||
return {
|
||||
memory_id: memoryId,
|
||||
revision,
|
||||
current_revision: 3,
|
||||
kind: "decision",
|
||||
state: states[revision as keyof typeof states],
|
||||
claim: revision === 3
|
||||
? "Keep subject Memory provenance typed and visible"
|
||||
: `Historical provenance decision, revision ${revision}`,
|
||||
body_md: detailBodyMd,
|
||||
why_useful:
|
||||
"Future readers can audit why a committed Memory exists without exposing unbounded session content.",
|
||||
staleness: null,
|
||||
change_reason: revision === 3
|
||||
? "Clarified the durable read contract."
|
||||
: "Immutable historical revision.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: `2026-01-0${revision}T03:04:05Z`,
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_truncated: false,
|
||||
source_candidate_ids: ["candidate-release-decision-0001"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-release-decision-0001",
|
||||
evidence: [{
|
||||
id: "evidence-message-0001",
|
||||
kind: "message",
|
||||
entry_range: [12, 14],
|
||||
origin: {
|
||||
kind: "flow_instruction",
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: "runtime-fixture",
|
||||
worker_id: "worker-fixture",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-definition-fixture",
|
||||
flow_definition_revision: 7,
|
||||
},
|
||||
excerpt: "Use normal explicit subject routes and preserve provenance.",
|
||||
summary: "The product contract was explicitly requested.",
|
||||
}],
|
||||
evidence_total: 1,
|
||||
evidence_truncated: false,
|
||||
source_refs: [{
|
||||
session_id: "session-fixture-0001",
|
||||
segment_id: "segment-fixture-0001",
|
||||
entry_range: [12, 14],
|
||||
evidence_id: "evidence-message-0001",
|
||||
origin: { kind: "human_input", account_id: "account-fixture" },
|
||||
evidence_kind: "message",
|
||||
label: "T-672 product direction",
|
||||
summary: "Bounded source reference retained with the committed revision.",
|
||||
}],
|
||||
source_refs_total: 1,
|
||||
source_refs_truncated: false,
|
||||
}],
|
||||
derived_from: [{ memory_id: derivedMemoryId, revision: 4 }],
|
||||
evidence_has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
function revisions(memoryId: string) {
|
||||
return {
|
||||
memory_id: memoryId,
|
||||
current_revision: 3,
|
||||
items: [
|
||||
{
|
||||
revision: 3,
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Keep subject Memory provenance typed and visible",
|
||||
change_reason: "Clarified the durable read contract.",
|
||||
updated_at: "2026-01-03T03:04:05Z",
|
||||
},
|
||||
{
|
||||
revision: 2,
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Historical provenance decision, revision 2",
|
||||
change_reason: "Resolved after endpoint integration.",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
{
|
||||
revision: 1,
|
||||
kind: "decision",
|
||||
state: "retracted",
|
||||
claim: "Historical provenance decision, revision 1",
|
||||
change_reason: "Retracted the legacy shape.",
|
||||
updated_at: "2026-01-01T03:04:05Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
@@ -110,15 +350,34 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
|
||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/memory`) {
|
||||
return json({
|
||||
body_md: bodyMd,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: new TextEncoder().encode(bodyMd).byteLength,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
});
|
||||
|
||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||
if (url.pathname === subjectsPath) {
|
||||
return json({ limit: 100, items: subjects, has_more: false });
|
||||
}
|
||||
if (url.pathname.startsWith(`${subjectsPath}/`)) {
|
||||
const suffix = url.pathname.slice(subjectsPath.length + 1);
|
||||
const parts = suffix.split("/").map(decodeURIComponent);
|
||||
const subjectId = parts[0];
|
||||
if (subjectId === errorSubjectId) return json({}, 503);
|
||||
const foundSubject = subjects.find((item) => item.id === subjectId);
|
||||
if (!foundSubject) return json({}, 404);
|
||||
|
||||
if (parts.length === 1) return json(foundSubject);
|
||||
if (parts.length === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
|
||||
if (parts.length === 2 && parts[1] === "memories") {
|
||||
return json({ items: memoriesFor(subjectId), has_more: false });
|
||||
}
|
||||
if (parts.length >= 3 && parts[1] === "memories") {
|
||||
const memoryId = parts[2];
|
||||
if (parts.length === 4 && parts[3] === "revisions") return json(revisions(memoryId));
|
||||
if (parts.length === 3) {
|
||||
const requested = url.searchParams.get("revision");
|
||||
return json(memoryDetail(memoryId, requested === null ? null : Number(requested)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
|
||||
request.headers.get("upgrade") === "websocket"
|
||||
|
||||
@@ -9,6 +9,10 @@ const fixtureServer = join(
|
||||
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
|
||||
);
|
||||
const workspaceId = "memory-review";
|
||||
const subjectId =
|
||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
|
||||
const memoryId =
|
||||
"memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001";
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
@@ -41,8 +45,8 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
return await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
const main = document.querySelector("main");
|
||||
const marker = [...document.querySelectorAll("h2")].find((heading) =>
|
||||
heading.textContent === "T-661 UNIQUE END MARKER"
|
||||
const marker = [...document.querySelectorAll("h3")].find((heading) =>
|
||||
heading.textContent === "T-672 UNIQUE END MARKER"
|
||||
);
|
||||
if (!main || !marker) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
@@ -51,31 +55,86 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openMemoryPage(
|
||||
async function assertNoPageWideOverflow(page: Page): Promise<void> {
|
||||
assertEquals(
|
||||
await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertMainOwnsVerticalScroll(page: Page): Promise<void> {
|
||||
const nested = await page.locator("main").evaluate((main) =>
|
||||
[...main.querySelectorAll("*")]
|
||||
.filter((element) => {
|
||||
const style = (globalThis as any).getComputedStyle(element);
|
||||
return ["auto", "scroll"].includes(style.overflowY) &&
|
||||
element.scrollHeight > element.clientHeight + 1;
|
||||
})
|
||||
.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
|
||||
);
|
||||
assertEquals(nested, []);
|
||||
}
|
||||
|
||||
async function openSubjectPage(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
mobile = false,
|
||||
): Promise<void> {
|
||||
await page.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const heading = page.locator("h1", { hasText: "Workspace Memory" });
|
||||
await page.goto(`${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}`);
|
||||
const heading = page.locator("h1", { hasText: "Release coordination" });
|
||||
await heading.waitFor({ state: "attached" });
|
||||
if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click();
|
||||
if (mobile) {
|
||||
const showSidebar = page.getByRole("button", { name: "Show sidebar" });
|
||||
await showSidebar.waitFor();
|
||||
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
||||
assertEquals(
|
||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
||||
false,
|
||||
);
|
||||
|
||||
await showSidebar.click();
|
||||
const hideSidebar = page.getByRole("button", { name: "Hide sidebar" });
|
||||
await hideSidebar.waitFor();
|
||||
assertEquals(await hideSidebar.getAttribute("aria-expanded"), "true");
|
||||
assertEquals(
|
||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
||||
true,
|
||||
);
|
||||
|
||||
await hideSidebar.click();
|
||||
await showSidebar.waitFor();
|
||||
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
|
||||
assertEquals(
|
||||
await page.locator("main").evaluate((main) => (main as unknown as { inert: boolean }).inert),
|
||||
false,
|
||||
);
|
||||
}
|
||||
await heading.waitFor();
|
||||
}
|
||||
|
||||
async function swipeMainToEnd(page: Page): Promise<void> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("touch viewport is unavailable");
|
||||
const x = Math.max(20, Math.min(300, viewport.width - 20));
|
||||
const startY = Math.max(120, viewport.height - 100);
|
||||
const moveYs = [0.8, 0.62, 0.44, 0.26, 0.12].map((ratio) =>
|
||||
Math.max(20, Math.round(viewport.height * ratio))
|
||||
);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const state = await mainScrollState(page);
|
||||
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return;
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: 300, y: 700, id: 1 }],
|
||||
touchPoints: [{ x, y: startY, id: 1 }],
|
||||
});
|
||||
for (const y of [580, 460, 340, 220, 100]) {
|
||||
for (const y of moveYs) {
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: 300, y, id: 1 }],
|
||||
touchPoints: [{ x, y, id: 1 }],
|
||||
});
|
||||
}
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
@@ -84,10 +143,10 @@ async function swipeMainToEnd(page: Page): Promise<void> {
|
||||
});
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
throw new Error("touch input did not reach the end of the Memory document");
|
||||
throw new Error("touch input did not reach the end of the resident Memory surface");
|
||||
}
|
||||
|
||||
Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => {
|
||||
Deno.test("production subject Memory shell renders exact states, safe Markdown, provenance, and one scroll owner", async () => {
|
||||
const build = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["task", "build"],
|
||||
cwd: workspaceRoot,
|
||||
@@ -130,6 +189,13 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
colorScheme: "light" as const,
|
||||
mobile: true,
|
||||
},
|
||||
{
|
||||
label: "compact-mobile",
|
||||
width: 320,
|
||||
height: 700,
|
||||
colorScheme: "dark" as const,
|
||||
mobile: true,
|
||||
},
|
||||
]
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
@@ -144,21 +210,28 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await openMemoryPage(page, baseUrl, scenario.mobile);
|
||||
await openSubjectPage(page, baseUrl, scenario.mobile);
|
||||
|
||||
const initial = await mainScrollState(page);
|
||||
assert(initial.scrollHeight > initial.clientHeight);
|
||||
assertEquals(await markerIsInsideMain(page), false);
|
||||
await assertNoPageWideOverflow(page);
|
||||
await assertMainOwnsVerticalScroll(page);
|
||||
assertEquals(
|
||||
await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
|
||||
}),
|
||||
true,
|
||||
await page.locator('aside a[href$="/memory"]', { hasText: "Subjects" }).count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(await page.locator("aside a", { hasText: "Staging" }).count(), 0);
|
||||
|
||||
if (scenario.label === "wheel") {
|
||||
assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]);
|
||||
assertEquals(
|
||||
await page.getByRole("heading", {
|
||||
name: "Release coordination",
|
||||
level: 1,
|
||||
exact: true,
|
||||
}).count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator("strong").filter({ hasText: "important context" }).count() > 0,
|
||||
true,
|
||||
@@ -167,8 +240,8 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
assertEquals(await page.locator("ul li").count() > 0, true);
|
||||
for (
|
||||
const heading of [
|
||||
{ selector: "h5", text: "Deep document heading" },
|
||||
{ selector: "h6", text: "Deepest document heading" },
|
||||
{ selector: "h5", text: "Deep surface heading" },
|
||||
{ selector: "h6", text: "Deepest surface heading" },
|
||||
]
|
||||
) {
|
||||
const element = page.locator(heading.selector, { hasText: heading.text });
|
||||
@@ -182,8 +255,17 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
}
|
||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(await page.locator("article img, article script").count(), 0);
|
||||
assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
for (const state of ["Active", "Resolved", "Retracted"]) {
|
||||
assertEquals(
|
||||
await page.locator(".memory-pill", { hasText: state }).count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
for (const label of ["Markdown table", "rust code block"]) {
|
||||
const region = page.getByRole("region", { name: label });
|
||||
@@ -194,7 +276,7 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
await page.locator("main").hover();
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
} else if (scenario.label === "keyboard") {
|
||||
await page.getByText("Document details", { exact: true }).focus();
|
||||
await page.getByText("Surface provenance", { exact: true }).focus();
|
||||
await page.keyboard.press("End");
|
||||
} else {
|
||||
await swipeMainToEnd(page);
|
||||
@@ -213,33 +295,72 @@ Deno.test("production Memory shell renders safe Markdown and scrolls to its end
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const emptyPage = await stateContext.newPage();
|
||||
await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 2,
|
||||
record_source: "fixture",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await emptyPage.getByText("Memory document is empty.").waitFor();
|
||||
assertEquals(await emptyPage.getByRole("alert").count(), 0);
|
||||
const detailContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const detailPage = await detailContext.newPage();
|
||||
await detailPage.goto(
|
||||
`${baseUrl}/w/${workspaceId}/memory/${encodeURIComponent(subjectId)}/${
|
||||
encodeURIComponent(memoryId)
|
||||
}`,
|
||||
);
|
||||
await detailPage.getByRole("heading", {
|
||||
name: "Keep subject Memory provenance typed and visible",
|
||||
level: 1,
|
||||
}).waitFor();
|
||||
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1);
|
||||
assertEquals(await detailPage.getByText("T-672 product direction").count(), 1);
|
||||
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
|
||||
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 1);
|
||||
assertEquals(await detailPage.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(
|
||||
await detailPage.getByRole("article", { name: "Committed Memory body" }).locator(
|
||||
"script, img",
|
||||
).count(),
|
||||
0,
|
||||
);
|
||||
for (const label of ["Markdown table", "text code block"]) {
|
||||
const region = detailPage.getByRole("region", { name: label });
|
||||
assertEquals(await region.getAttribute("tabindex"), "0");
|
||||
assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth));
|
||||
}
|
||||
await assertNoPageWideOverflow(detailPage);
|
||||
await assertMainOwnsVerticalScroll(detailPage);
|
||||
await detailContext.close();
|
||||
|
||||
const errorPage = await stateContext.newPage();
|
||||
await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
|
||||
});
|
||||
await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const alert = errorPage.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
assert((await alert.textContent())?.includes("Memory document unavailable."));
|
||||
assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0);
|
||||
const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const stateCases = [
|
||||
{
|
||||
id: "empty-subject",
|
||||
text: "Resident surface is ready and empty.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
|
||||
{
|
||||
id: "failed-subject",
|
||||
text: "Resident surface generation failed.",
|
||||
role: "alert" as const,
|
||||
},
|
||||
{
|
||||
id: "ungenerated-subject",
|
||||
text: "Resident surface has not been generated.",
|
||||
role: "status" as const,
|
||||
},
|
||||
{ id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const },
|
||||
];
|
||||
for (const stateCase of stateCases) {
|
||||
const page = await stateContext.newPage();
|
||||
await page.goto(`${baseUrl}/w/${workspaceId}/memory/${stateCase.id}`);
|
||||
const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
|
||||
await state.waitFor();
|
||||
assert((await state.textContent())?.includes(stateCase.text));
|
||||
if (stateCase.id !== "empty-subject") {
|
||||
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
|
||||
}
|
||||
await assertNoPageWideOverflow(page);
|
||||
await page.close();
|
||||
}
|
||||
await stateContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "memory-subjects-dark",
|
||||
"title": "Memory subjects, surfaces, and committed records — dark",
|
||||
"baseUrl": "http://127.0.0.1:15286",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [{
|
||||
"id": "fixture",
|
||||
"label": "Read-only Memory fixture",
|
||||
"auth": { "kind": "anonymous" }
|
||||
}],
|
||||
"viewports": [
|
||||
{ "label": "desktop", "width": 1440, "height": 900 },
|
||||
{ "label": "narrow", "width": 768, "height": 900 },
|
||||
{ "label": "mobile", "width": 390, "height": 844 },
|
||||
{ "label": "compact", "width": 320, "height": 700 }
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "subjects",
|
||||
"label": "Subject index",
|
||||
"path": "/w/memory-review/memory",
|
||||
"goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.",
|
||||
"dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Subjects" }]
|
||||
},
|
||||
{
|
||||
"id": "representative",
|
||||
"label": "Ready subject surface",
|
||||
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
|
||||
"goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.",
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"label": "Committed Memory detail",
|
||||
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001",
|
||||
"goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.",
|
||||
"dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Memory detail" }]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"label": "Ready-empty subject",
|
||||
"path": "/w/memory-review/memory/empty-subject",
|
||||
"goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.",
|
||||
"dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
"path": "/w/memory-review/memory/stale-subject",
|
||||
"goal": "Recognize that no snapshot is displayable while committed Memories remain readable.",
|
||||
"dataState": "Stale availability without snapshot and one resolved committed Memory.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='stale']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Stale" }]
|
||||
},
|
||||
{
|
||||
"id": "failed",
|
||||
"label": "Failed subject surface",
|
||||
"path": "/w/memory-review/memory/failed-subject",
|
||||
"goal": "See generation failure without displaying a failed or partial snapshot.",
|
||||
"dataState": "Failed availability without snapshot and one retracted committed Memory.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='failed']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Failed" }]
|
||||
},
|
||||
{
|
||||
"id": "error",
|
||||
"label": "Subject endpoint error",
|
||||
"path": "/w/memory-review/memory/error-subject",
|
||||
"goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.",
|
||||
"dataState": "Intentional HTTP 503 from all subject-scoped endpoints.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='error']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Unavailable" }]
|
||||
}
|
||||
],
|
||||
"processes": [{
|
||||
"id": "memory-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/memory_document_fixture_server.ts",
|
||||
"15286",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15286/health"
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "memory-subjects-light",
|
||||
"title": "Memory subjects, surfaces, and committed records — light",
|
||||
"baseUrl": "http://127.0.0.1:15285",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [{
|
||||
"id": "fixture",
|
||||
"label": "Read-only Memory fixture",
|
||||
"auth": { "kind": "anonymous" }
|
||||
}],
|
||||
"viewports": [
|
||||
{ "label": "desktop", "width": 1440, "height": 900 },
|
||||
{ "label": "narrow", "width": 768, "height": 900 },
|
||||
{ "label": "mobile", "width": 390, "height": 844 },
|
||||
{ "label": "compact", "width": 320, "height": 700 }
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "subjects",
|
||||
"label": "Subject index",
|
||||
"path": "/w/memory-review/memory",
|
||||
"goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.",
|
||||
"dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Subjects" }]
|
||||
},
|
||||
{
|
||||
"id": "representative",
|
||||
"label": "Ready subject surface",
|
||||
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
|
||||
"goal": "Read the ready resident surface and scan active, resolved, and retracted committed Memories without nested vertical scrolling.",
|
||||
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready subject" }]
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"label": "Committed Memory detail",
|
||||
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001",
|
||||
"goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.",
|
||||
"dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Memory detail" }]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"label": "Ready-empty subject",
|
||||
"path": "/w/memory-review/memory/empty-subject",
|
||||
"goal": "Distinguish a valid ready-empty snapshot and an empty committed Memory list from failure.",
|
||||
"dataState": "Ready snapshot with empty Markdown and no refs; successful current list with no records.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='ready'] [data-surface-ready-empty]"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Ready empty" }]
|
||||
},
|
||||
{
|
||||
"id": "stale",
|
||||
"label": "Stale subject surface",
|
||||
"path": "/w/memory-review/memory/stale-subject",
|
||||
"goal": "Recognize that no snapshot is displayable while committed Memories remain readable.",
|
||||
"dataState": "Stale availability without snapshot and one resolved committed Memory.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='stale']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Stale" }]
|
||||
},
|
||||
{
|
||||
"id": "failed",
|
||||
"label": "Failed subject surface",
|
||||
"path": "/w/memory-review/memory/failed-subject",
|
||||
"goal": "See generation failure without displaying a failed or partial snapshot.",
|
||||
"dataState": "Failed availability without snapshot and one retracted committed Memory.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='failed']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Failed" }]
|
||||
},
|
||||
{
|
||||
"id": "error",
|
||||
"label": "Subject endpoint error",
|
||||
"path": "/w/memory-review/memory/error-subject",
|
||||
"goal": "Keep each read-only region explicit when subject, surface, and current Memory requests fail.",
|
||||
"dataState": "Intentional HTTP 503 from all subject-scoped endpoints.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subject'][data-memory-state='error']"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Unavailable" }]
|
||||
}
|
||||
],
|
||||
"processes": [{
|
||||
"id": "memory-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/memory_document_fixture_server.ts",
|
||||
"15285",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15285/health"
|
||||
}]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -11,98 +11,56 @@ export const MEMORY_API_LIMITS = {
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: DiagnosticSeverity;
|
||||
message: string;
|
||||
};
|
||||
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
|
||||
|
||||
export type MemoryDocumentResponse = {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
};
|
||||
export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, };
|
||||
|
||||
export type MemoryCandidateKind =
|
||||
| "preference"
|
||||
| "working_assumption"
|
||||
| "constraint"
|
||||
| "decision"
|
||||
| "open_question"
|
||||
| "lesson";
|
||||
export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson";
|
||||
|
||||
export type MemoryEvidenceOriginKind =
|
||||
| "human_input"
|
||||
| "worker_input"
|
||||
| "flow_instruction"
|
||||
| "backend_instruction"
|
||||
| "model_output"
|
||||
| "tool_output"
|
||||
| "derived_summary"
|
||||
| "legacy_unknown";
|
||||
export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
|
||||
|
||||
export type MemoryEvidenceOrigin = {
|
||||
kind: MemoryEvidenceOriginKind;
|
||||
account_id?: string | null;
|
||||
workspace_id?: string | null;
|
||||
runtime_id?: string | null;
|
||||
worker_id?: string | null;
|
||||
flow_selector?: string | null;
|
||||
flow_definition_id?: string | null;
|
||||
flow_definition_revision?: number | null;
|
||||
};
|
||||
export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, };
|
||||
|
||||
export type MemorySourceRef = { segment_id: string; range: [number, number] };
|
||||
export type MemorySourceRef = { segment_id: string, range: [number, number], };
|
||||
|
||||
export type MemoryStagingEvidence = {
|
||||
id: string;
|
||||
kind: string;
|
||||
entry_range: [number, number] | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
excerpt: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, };
|
||||
|
||||
export type MemorySourceEvidenceRef = {
|
||||
session_id: string | null;
|
||||
segment_id: string | null;
|
||||
entry_range: [number, number] | null;
|
||||
evidence_id: string | null;
|
||||
origin?: MemoryEvidenceOrigin | null;
|
||||
evidence_kind: string | null;
|
||||
label: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, };
|
||||
|
||||
export type MemoryStagingRecord = {
|
||||
schema_version: number;
|
||||
id: string;
|
||||
extract_run_id: string;
|
||||
source: MemorySourceRef;
|
||||
kind: MemoryCandidateKind;
|
||||
claim: string;
|
||||
why_useful: string;
|
||||
staleness: string | null;
|
||||
evidence: Array<MemoryStagingEvidence>;
|
||||
source_refs: Array<MemorySourceEvidenceRef>;
|
||||
};
|
||||
export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array<MemoryStagingEvidence>, source_refs: Array<MemorySourceEvidenceRef>, };
|
||||
|
||||
export type MemoryStagingEntry = {
|
||||
id: string;
|
||||
byte_len: number;
|
||||
record: MemoryStagingRecord;
|
||||
};
|
||||
export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, };
|
||||
|
||||
export type MemoryStagingListResponse = {
|
||||
limit: number;
|
||||
returned_count: number;
|
||||
total_valid_count: number;
|
||||
invalid_count: number;
|
||||
truncated: boolean;
|
||||
order: string;
|
||||
record_authority: string;
|
||||
items: Array<MemoryStagingEntry>;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array<MemoryStagingEntry>, diagnostics: Array<Diagnostic>, };
|
||||
|
||||
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, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Input contract: 0.1.0 (sha256:c3466d73ec67447e6f8af0343b20dc90fcffc3620d2e58a4405870311aa8dfd3)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -96,11 +96,7 @@ function optional<T>(
|
||||
label: string,
|
||||
parse: (item: unknown, label: string) => T,
|
||||
): T | null | undefined {
|
||||
return value === undefined
|
||||
? undefined
|
||||
: value === null
|
||||
? null
|
||||
: parse(value, label);
|
||||
return value === undefined ? undefined : value === null ? null : parse(value, label);
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown, label: string): Diagnostic {
|
||||
@@ -388,17 +384,13 @@ export function parseWorkerSummary(
|
||||
`${label}.singleton_key`,
|
||||
string,
|
||||
),
|
||||
tags: item.tags === undefined
|
||||
? []
|
||||
: array(item.tags, `${label}.tags`, string),
|
||||
tags: item.tags === undefined ? [] : array(item.tags, `${label}.tags`, string),
|
||||
workspace: workspaceSummary(item.workspace, `${label}.workspace`),
|
||||
availability,
|
||||
state: string(item.state, `${label}.state`),
|
||||
worker_state: workerState,
|
||||
last_seen_at: optional(item.last_seen_at, `${label}.last_seen_at`, string),
|
||||
pinned: item.pinned === undefined
|
||||
? false
|
||||
: boolean(item.pinned, `${label}.pinned`),
|
||||
pinned: item.pinned === undefined ? false : boolean(item.pinned, `${label}.pinned`),
|
||||
retention_state: item.retention_state === undefined
|
||||
? ""
|
||||
: string(item.retention_state, `${label}.retention_state`),
|
||||
@@ -406,13 +398,11 @@ export function parseWorkerSummary(
|
||||
item.implementation,
|
||||
`${label}.implementation`,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined
|
||||
? undefined
|
||||
: array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
workerWorkdirAttachment,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
workerWorkdirAttachment,
|
||||
),
|
||||
diagnostics: item.diagnostics === undefined
|
||||
? []
|
||||
: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||
@@ -622,17 +612,22 @@ function workerSummary(
|
||||
item.implementation,
|
||||
`${label}.implementation`,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined
|
||||
? undefined
|
||||
: array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
runtimeWorkdirAttachment,
|
||||
),
|
||||
workdir_attachments: item.workdir_attachments === undefined ? undefined : array(
|
||||
item.workdir_attachments,
|
||||
`${label}.workdir_attachments`,
|
||||
runtimeWorkdirAttachment,
|
||||
),
|
||||
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkerLaunchWorkerSummary(
|
||||
value: unknown,
|
||||
label = "Worker launch summary",
|
||||
): WorkerLaunchWorkerSummary {
|
||||
return workerSummary(value, label);
|
||||
}
|
||||
|
||||
export function parseBrowserCreateWorkerResponse(
|
||||
value: unknown,
|
||||
): BrowserCreateWorkerResponse {
|
||||
|
||||
@@ -448,27 +448,39 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
const memoryNav = await Deno.readTextFile(
|
||||
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
|
||||
);
|
||||
const memoryDocumentLoad = await Deno.readTextFile(
|
||||
const subjectIndexLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDocumentPage = await Deno.readTextFile(
|
||||
const subjectIndexPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryStagingLoad = await Deno.readTextFile(
|
||||
const subjectPageLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/staging/+page.ts",
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryStagingPage = await Deno.readTextFile(
|
||||
const subjectPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/staging/+page.svelte",
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDetailLoad = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const memoryDetailPage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
@@ -478,35 +490,44 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
memoryNav.includes(
|
||||
'<h2 class="sidebar-nav-section__header">Memory</h2>',
|
||||
) &&
|
||||
memoryNav.includes("Document</a>") &&
|
||||
memoryNav.includes("Subjects</a>") &&
|
||||
memoryNav.includes("currentPath.startsWith(`${subjectsHref}/`)") &&
|
||||
memoryNav.includes("sidebar-nav-section--category") &&
|
||||
memoryNav.includes('class="sidebar-link"') &&
|
||||
memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') &&
|
||||
memoryNav.includes("Staging</a>") &&
|
||||
!memoryNav.includes("item-meta") &&
|
||||
!memoryNav.includes("durable workspace memory") &&
|
||||
!memoryNav.includes("pending extraction candidates"),
|
||||
"Memory sidebar section should show Document and Staging as compact single-line links",
|
||||
!memoryNav.includes("Staging</a>") &&
|
||||
!memoryNav.includes("Document</a>"),
|
||||
"Memory sidebar section should expose one Subjects link for the full nested route tree",
|
||||
);
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at") &&
|
||||
memoryDocumentPage.includes('role="alert"') &&
|
||||
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
|
||||
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
|
||||
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||
subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||
"Memory product entry should list explicit typed subjects",
|
||||
);
|
||||
assert(
|
||||
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryStagingLoad.includes('"/memory/staging"') &&
|
||||
memoryStagingPage.includes("Memory Staging") &&
|
||||
memoryStagingPage.includes("Workspace Server memory authority") &&
|
||||
memoryStagingPage.includes("data.staging.data.invalid_count") &&
|
||||
memoryStagingPage.includes("entry.record.evidence"),
|
||||
"Memory Staging page should read the scoped API and expose staged records without mutation controls",
|
||||
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||
subjectPage.includes("DocumentMarkdown from") &&
|
||||
subjectPage.includes("Resident surface") &&
|
||||
subjectPage.includes("Current Memories") &&
|
||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||
subjectPage.includes("data-surface-ready-empty") &&
|
||||
!subjectPage.includes("overflow-y"),
|
||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
||||
);
|
||||
assert(
|
||||
memoryDetailLoad.includes("`${memoryPath}/revisions`") &&
|
||||
memoryDetailLoad.includes("parseSubjektivMemoryReadResponse") &&
|
||||
memoryDetailPage.includes("Candidate provenance") &&
|
||||
memoryDetailPage.includes("Source refs") &&
|
||||
memoryDetailPage.includes("Derived from") &&
|
||||
memoryDetailPage.includes("Revision history") &&
|
||||
memoryDetailPage.includes("DocumentMarkdown from") &&
|
||||
!memoryDetailPage.includes("overflow-y"),
|
||||
"Committed Memory detail should preserve bounded provenance and immutable revisions without mutation controls",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -719,8 +740,12 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
|
||||
|
||||
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
||||
const files = [
|
||||
"ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
|
||||
"ConsoleTasks.svelte", "WorkerRunStatus.svelte", "WorkerContextStatus.svelte",
|
||||
"ComposerInput.svelte",
|
||||
"ConsoleLineItem.svelte",
|
||||
"RichMarkdown.svelte",
|
||||
"ConsoleTasks.svelte",
|
||||
"WorkerRunStatus.svelte",
|
||||
"WorkerContextStatus.svelte",
|
||||
"ConsoleDisplayState.svelte",
|
||||
"ConsoleTurnNavigation.svelte",
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
@@ -729,7 +754,8 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
|
||||
const source = await Deno.readTextFile(new URL(file, import.meta.url));
|
||||
const css = source.split("<style>")[1];
|
||||
assert(
|
||||
!/(?:gap|padding(?:-[\w]+)?|margin(?:-[\w]+)?)\s*:[^;]*\d(?:\.\d+)?rem/.test(css),
|
||||
!/(?:gap|padding(?:-[\w]+)?|margin(?:-[\w]+)?)\s*:[^;]*\d(?:\.\d+)?rem/
|
||||
.test(css),
|
||||
`${file} should use shared spacing tokens, not a local rem spacing scale`,
|
||||
);
|
||||
assert(
|
||||
@@ -737,7 +763,9 @@ Deno.test("Console spacing and text metrics use existing design tokens", async (
|
||||
`${file} should use the body/compact line-height tokens`,
|
||||
);
|
||||
}
|
||||
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
|
||||
const navigation = await Deno.readTextFile(
|
||||
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
navigation.includes("box-shadow: var(--shadow-overlay)") &&
|
||||
navigation.includes("font-size: var(--font-size-body)") &&
|
||||
@@ -764,7 +792,9 @@ Deno.test("Console and app shell keep compact layout spacing", async () => {
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const mainRules = [...layout.matchAll(/^\s*\.app-shell__main\s*\{([^}]+)\}/gm)];
|
||||
const mainRules = [
|
||||
...layout.matchAll(/^\s*\.app-shell__main\s*\{([^}]+)\}/gm),
|
||||
];
|
||||
assert(
|
||||
mainRules.length === 2 &&
|
||||
mainRules.every((rule) => rule[1].includes("padding: var(--space-4);")),
|
||||
@@ -785,7 +815,9 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
|
||||
const navigation = await Deno.readTextFile(
|
||||
new URL("./ConsoleTurnNavigation.svelte", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
!/timeline/i.test(consolePage) &&
|
||||
consolePage.includes("conversationTurnPreviewsFromLines(lines)") &&
|
||||
@@ -814,11 +846,17 @@ Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves sc
|
||||
consolePage.includes('class="console-scroll"') &&
|
||||
consolePage.includes("overflow-y: auto") &&
|
||||
consolePage.includes("onscroll={handleConsoleScroll}") &&
|
||||
consolePage.includes("autoFollowConsole = isNearConsoleBottom(consoleBodyElement)") &&
|
||||
consolePage.includes(
|
||||
"autoFollowConsole = isNearConsoleBottom(consoleBodyElement)",
|
||||
) &&
|
||||
consolePage.includes("void scrollConsoleToBottom()") &&
|
||||
consolePage.includes("rememberConsoleWorkerViewScroll()") &&
|
||||
consolePage.includes("consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(") &&
|
||||
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') &&
|
||||
consolePage.includes(
|
||||
"consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(",
|
||||
) &&
|
||||
consolePage.includes(
|
||||
'event.key === "PageUp" || event.key === "PageDown"',
|
||||
) &&
|
||||
consolePage.includes("consoleBodyElement.scrollBy({"),
|
||||
"Native scroll, PageUp/PageDown, conditional bottom-follow and per-Worker scroll restoration must remain",
|
||||
);
|
||||
@@ -896,7 +934,8 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
|
||||
const composerInput = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const footerStyle = consolePage.match(/\.composer-input-footer\s*\{([^}]+)\}/)?.[1];
|
||||
const footerStyle = consolePage.match(/\.composer-input-footer\s*\{([^}]+)\}/)
|
||||
?.[1];
|
||||
assert(
|
||||
footerStyle?.includes("display: grid") &&
|
||||
!/position:\s*(absolute|fixed)/.test(footerStyle) &&
|
||||
@@ -905,7 +944,9 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
|
||||
);
|
||||
assert(
|
||||
composerInput.includes("min-height: 2.65rem") &&
|
||||
composerInput.includes("padding: var(--space-2) var(--space-2) var(--space-1)") &&
|
||||
composerInput.includes(
|
||||
"padding: var(--space-2) var(--space-2) var(--space-1)",
|
||||
) &&
|
||||
!composerInput.includes("padding: 0.55rem 3.4rem 3rem 0.65rem"),
|
||||
"Editor must not reserve an overlapping toolbar inside its scrolling content",
|
||||
);
|
||||
@@ -997,19 +1038,32 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
||||
});
|
||||
|
||||
Deno.test("Worker Console routes image paste through the existing attachment upload", async () => {
|
||||
const consolePage = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||
));
|
||||
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||
const paste = input.slice(input.indexOf("function handlePasteEvent"), input.indexOf("function selectedClipboardContent"));
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const input = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const paste = input.slice(
|
||||
input.indexOf("function handlePasteEvent"),
|
||||
input.indexOf("function selectedClipboardContent"),
|
||||
);
|
||||
assert(
|
||||
paste.indexOf("if (disabled || view?.state.readOnly)") < paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") < paste.indexOf('getData("text/plain")') &&
|
||||
paste.indexOf("if (disabled || view?.state.readOnly)") <
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
|
||||
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") <
|
||||
paste.indexOf('getData("text/plain")') &&
|
||||
consolePage.includes("onpasteimages={addPastedImages}") &&
|
||||
consolePage.includes("addAttachmentFiles(files.map(namePastedImage))"),
|
||||
"image paste must respect editability and take priority over text before using the attachment callback",
|
||||
);
|
||||
const attach = consolePage.slice(consolePage.indexOf("function addAttachmentFiles"), consolePage.indexOf("async function removeAttachment"));
|
||||
const attach = consolePage.slice(
|
||||
consolePage.indexOf("function addAttachmentFiles"),
|
||||
consolePage.indexOf("async function removeAttachment"),
|
||||
);
|
||||
assert(
|
||||
attach.includes("if (!composerEditable) return") &&
|
||||
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
|
||||
@@ -1488,43 +1542,98 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
||||
});
|
||||
|
||||
Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => {
|
||||
const page = await Deno.readTextFile(new URL("./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url));
|
||||
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||
assert(!header.includes("Compact") && !header.includes("Rewind") && !page.includes("requestRewindTargets"), "Header must defer Compact/Rewind to commands");
|
||||
assert(input.includes('role="listbox"') && input.includes('role="option"') && input.includes("entry.description") && input.includes("completionStart + 6"), "Completion must expose a bounded selectable list with descriptions");
|
||||
assert(input.indexOf("keydown: completionKeydown") < input.indexOf('key: "ArrowUp"'), "Completion navigation must precede input history");
|
||||
assert(page.includes("fileCompletions.receive(event.data.entries)") && page.includes("fileCompletions.close()"), "File completion lifecycle must be scoped to the transport");
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const input = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("Compact") && !header.includes("Rewind") &&
|
||||
!page.includes("requestRewindTargets"),
|
||||
"Header must defer Compact/Rewind to commands",
|
||||
);
|
||||
assert(
|
||||
input.includes('role="listbox"') && input.includes('role="option"') &&
|
||||
input.includes("entry.description") &&
|
||||
input.includes("completionStart + 6"),
|
||||
"Completion must expose a bounded selectable list with descriptions",
|
||||
);
|
||||
assert(
|
||||
input.indexOf("keydown: completionKeydown") <
|
||||
input.indexOf('key: "ArrowUp"'),
|
||||
"Completion navigation must precede input history",
|
||||
);
|
||||
assert(
|
||||
page.includes("fileCompletions.receive(event.data.entries)") &&
|
||||
page.includes("fileCompletions.close()"),
|
||||
"File completion lifecycle must be scoped to the transport",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("mini task summary owns pane toggling instead of the header", async () => {
|
||||
const page = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||
));
|
||||
const component = await Deno.readTextFile(new URL("./ConsoleTasks.svelte", import.meta.url));
|
||||
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||
assert(!header.includes("taskPaneOpen = !taskPaneOpen") && page.includes("onTogglePane={() => {"), "Only the mini summary should own Tasks toggling");
|
||||
assert(component.includes("aria-expanded={paneOpen}") && component.includes("aria-controls={paneId}") && component.includes("onclick={onTogglePane}"), "Tasks summary must be an accessible pane toggle");
|
||||
assert(!/\.task-pane\s*\{[^}]*display:\s*none/.test(component), "Narrow layouts must not hide the opened Tasks pane");
|
||||
assert(page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"), "Narrow layouts should bound transcript and task scrolling separately");
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const component = await Deno.readTextFile(
|
||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("taskPaneOpen = !taskPaneOpen") &&
|
||||
page.includes("onTogglePane={() => {"),
|
||||
"Only the mini summary should own Tasks toggling",
|
||||
);
|
||||
assert(
|
||||
component.includes("aria-expanded={paneOpen}") &&
|
||||
component.includes("aria-controls={paneId}") &&
|
||||
component.includes("onclick={onTogglePane}"),
|
||||
"Tasks summary must be an accessible pane toggle",
|
||||
);
|
||||
assert(
|
||||
!/\.task-pane\s*\{[^}]*display:\s*none/.test(component),
|
||||
"Narrow layouts must not hide the opened Tasks pane",
|
||||
);
|
||||
assert(
|
||||
page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"),
|
||||
"Narrow layouts should bound transcript and task scrolling separately",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
|
||||
const page = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
));
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const pending = page.indexOf('<section class="pending-submissions"');
|
||||
assert(
|
||||
page.indexOf("<ConsoleTasks") < pending && pending < page.indexOf('<form class="console-composer"'),
|
||||
page.indexOf("<ConsoleTasks") < pending &&
|
||||
pending < page.indexOf('<form class="console-composer"'),
|
||||
"Pending inputs should sit between Tasks and Composer",
|
||||
);
|
||||
const section = page.slice(pending, page.indexOf("</section>", pending));
|
||||
assert(
|
||||
!section.includes("<details") && !page.includes('method: "continue_pending"') &&
|
||||
!section.includes("{submission.submission_id}") && !section.includes("segment_count") &&
|
||||
!section.includes("<details") &&
|
||||
!page.includes('method: "continue_pending"') &&
|
||||
!section.includes("{submission.submission_id}") &&
|
||||
!section.includes("segment_count") &&
|
||||
section.includes('submission.preview || "Preview unavailable"') &&
|
||||
section.includes('aria-label={`Cancel queued input ${index + 1}`}') &&
|
||||
section.includes("aria-label={`Cancel queued input ${index + 1}`}") &&
|
||||
section.includes('aria-label="Notifications"') &&
|
||||
section.includes("pendingSubmissions.notification_previews ?? []") &&
|
||||
!page.includes('method: "clear_pending_submissions"'),
|
||||
@@ -1533,18 +1642,23 @@ Deno.test("Pending inputs are single-line previews between Tasks and Composer",
|
||||
const css = page.split("<style>")[1];
|
||||
const previewRule = css.match(/\.pending-submission-preview,[^{]+\{([^}]+)\}/)?.[1] ?? "";
|
||||
assert(
|
||||
previewRule.includes("white-space: nowrap") && previewRule.includes("min-width: 0") &&
|
||||
previewRule.includes("overflow: hidden") && previewRule.includes("text-overflow: ellipsis"),
|
||||
previewRule.includes("white-space: nowrap") &&
|
||||
previewRule.includes("min-width: 0") &&
|
||||
previewRule.includes("overflow: hidden") &&
|
||||
previewRule.includes("text-overflow: ellipsis"),
|
||||
"Preview text must truncate instead of wrapping or expanding the row",
|
||||
);
|
||||
assert(
|
||||
/\.pending-queue li\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\) auto/.test(css) &&
|
||||
/\.pending-queue li\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\) auto/
|
||||
.test(css) &&
|
||||
css.includes("grid-template-columns: repeat(2, minmax(0, 1fr))") &&
|
||||
!section.includes("BevelLine") &&
|
||||
/\.pending-submissions\s*\{[^}]*font-size: var\(--font-size-compact\);[^}]*line-height: var\(--line-height-compact\);/.test(css) &&
|
||||
/\.pending-submissions\s*\{[^}]*font-size: var\(--font-size-compact\);[^}]*line-height: var\(--line-height-compact\);/
|
||||
.test(css) &&
|
||||
css.includes(".pending-queue li:hover .pending-icon-button") &&
|
||||
css.includes(".pending-queue li:focus-within .pending-icon-button") &&
|
||||
css.includes("@media (hover: none)") && section.includes('<path d="M5 12h14"'),
|
||||
css.includes("@media (hover: none)") &&
|
||||
section.includes('<path d="M5 12h14"'),
|
||||
"Cancel icons must retain their column when preview text is long",
|
||||
);
|
||||
});
|
||||
@@ -1581,18 +1695,24 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
|
||||
assert(
|
||||
consolePage.includes("handleQueueSubmit") &&
|
||||
consolePage.includes('delivery: "queue"') &&
|
||||
consolePage.includes('disabled={!canQueueDraft}') &&
|
||||
consolePage.includes("disabled={!canQueueDraft}") &&
|
||||
consolePage.includes('aria-label="Queue Submit"') &&
|
||||
consolePage.includes('class="composer-queue-icon"') &&
|
||||
consolePage.includes('{#if workerRunning || workerPaused}') &&
|
||||
consolePage.includes("{#if workerRunning || workerPaused}") &&
|
||||
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
|
||||
consolePage.includes('method.method === "submit" && delivery === "submit"'),
|
||||
consolePage.includes(
|
||||
'method.method === "submit" && delivery === "submit"',
|
||||
),
|
||||
"busy Console must offer explicit Queue without changing paused state or queueing commands",
|
||||
);
|
||||
const actions = consolePage.slice(consolePage.indexOf('class="composer-submit-actions"'));
|
||||
const actions = consolePage.slice(
|
||||
consolePage.indexOf('class="composer-submit-actions"'),
|
||||
);
|
||||
assert(
|
||||
actions.indexOf('class="composer-queue-button"') < actions.indexOf('class="composer-notify-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') < actions.indexOf('class="composer-send-button"'),
|
||||
actions.indexOf('class="composer-queue-button"') <
|
||||
actions.indexOf('class="composer-notify-button"') &&
|
||||
actions.indexOf('class="composer-notify-button"') <
|
||||
actions.indexOf('class="composer-send-button"'),
|
||||
"Queue must sit beside Notify and Submit/Stop",
|
||||
);
|
||||
|
||||
|
||||
@@ -12,7 +12,24 @@ import type {
|
||||
MemoryStagingEvidence,
|
||||
MemoryStagingListResponse,
|
||||
MemoryStagingRecord,
|
||||
SubjektivMemoryEvidence,
|
||||
SubjektivMemoryEvidenceCandidate,
|
||||
SubjektivMemoryListRevisionsResponse,
|
||||
SubjektivMemoryQueryItem,
|
||||
SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse,
|
||||
SubjektivMemoryRevisionItem,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
SubjektivMemoryState,
|
||||
SubjektivResidentSurfaceAvailability,
|
||||
SubjektivResidentSurfaceResponse,
|
||||
SubjektivResidentSurfaceSnapshot,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
SubjektivSubjectState,
|
||||
} from "$lib/generated/memory-api";
|
||||
import { parseWorkerLaunchWorkerSummary } from "$lib/workspace/api/workers";
|
||||
|
||||
const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
@@ -42,6 +59,27 @@ const diagnosticSeverities = new Set<DiagnosticSeverity>([
|
||||
"warning",
|
||||
"error",
|
||||
]);
|
||||
const subjectStates = new Set<SubjektivSubjectState>(["active", "retired"]);
|
||||
const surfaceAvailabilities = new Set<SubjektivResidentSurfaceAvailability>([
|
||||
"ungenerated",
|
||||
"stale",
|
||||
"failed",
|
||||
"ready",
|
||||
]);
|
||||
const memoryStates = new Set<SubjektivMemoryState>([
|
||||
"active",
|
||||
"resolved",
|
||||
"retracted",
|
||||
]);
|
||||
const MAX_SUBJECTS = 100;
|
||||
const MAX_CURRENT_MEMORIES = 100;
|
||||
const MAX_REVISIONS = 100;
|
||||
const MAX_MEMORY_REFS = MEMORY_API_LIMITS.maxCollectionItems;
|
||||
|
||||
export const MEMORY_API_LOAD_POLICY = {
|
||||
diagnosticLabel: "Memory API",
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
} as const;
|
||||
|
||||
export function parseMemoryDocumentResponse(
|
||||
value: unknown,
|
||||
@@ -190,9 +228,7 @@ function parseStagingEvidence(value: unknown): MemoryStagingEvidence {
|
||||
summary: nullableString(record, "summary"),
|
||||
};
|
||||
if ("origin" in record) {
|
||||
result.origin = record.origin === null
|
||||
? null
|
||||
: parseEvidenceOrigin(record.origin);
|
||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -223,9 +259,7 @@ function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef {
|
||||
summary: nullableString(record, "summary"),
|
||||
};
|
||||
if ("origin" in record) {
|
||||
result.origin = record.origin === null
|
||||
? null
|
||||
: parseEvidenceOrigin(record.origin);
|
||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -283,6 +317,542 @@ function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivSubjectResponse(
|
||||
value: unknown,
|
||||
expectedSubjectId?: string,
|
||||
): SubjektivSubjectResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"id",
|
||||
"role",
|
||||
"state",
|
||||
"store_revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"current_worker",
|
||||
],
|
||||
"Subject response",
|
||||
["current_worker"],
|
||||
);
|
||||
const id = requiredIdentifier(record, "id");
|
||||
if (expectedSubjectId !== undefined && id !== expectedSubjectId) {
|
||||
invalid("Subject response identity does not match the requested subject");
|
||||
}
|
||||
const state = requiredString(record, "state") as SubjektivSubjectState;
|
||||
if (!subjectStates.has(state)) invalid("unknown Subject state");
|
||||
const result: SubjektivSubjectResponse = {
|
||||
id,
|
||||
role: requiredString(record, "role"),
|
||||
state,
|
||||
store_revision: requiredNonNegativeInteger(record, "store_revision"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
if ("current_worker" in record) {
|
||||
result.current_worker = record.current_worker === null ? null : parseWorkerLaunchWorkerSummary(
|
||||
record.current_worker,
|
||||
"Subject current worker",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivSubjectListResponse(
|
||||
value: unknown,
|
||||
): SubjektivSubjectListResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["limit", "items", "has_more"],
|
||||
"Subject list response",
|
||||
);
|
||||
const limit = positiveInteger(record.limit, "limit");
|
||||
if (limit > MAX_SUBJECTS) invalid("limit exceeds the Subject list bound");
|
||||
const items = boundedArray(record.items, MAX_SUBJECTS, "items").map((item) =>
|
||||
parseSubjektivSubjectResponse(item)
|
||||
);
|
||||
if (items.length > limit) invalid("items exceeds the declared Subject limit");
|
||||
assertUnique(items.map((item) => item.id), "Subject ids");
|
||||
return {
|
||||
limit,
|
||||
items,
|
||||
has_more: requiredBoolean(record, "has_more"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivResidentSurfaceResponse(
|
||||
value: unknown,
|
||||
expectedSubjectId?: string,
|
||||
): SubjektivResidentSurfaceResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["subject_id", "availability", "snapshot"],
|
||||
"Resident surface response",
|
||||
["snapshot"],
|
||||
);
|
||||
const subjectId = requiredIdentifier(record, "subject_id");
|
||||
if (expectedSubjectId !== undefined && subjectId !== expectedSubjectId) {
|
||||
invalid("Resident surface identity does not match the requested subject");
|
||||
}
|
||||
const availability = requiredString(
|
||||
record,
|
||||
"availability",
|
||||
) as SubjektivResidentSurfaceAvailability;
|
||||
if (!surfaceAvailabilities.has(availability)) {
|
||||
invalid("unknown resident surface availability");
|
||||
}
|
||||
const snapshot = "snapshot" in record && record.snapshot !== null
|
||||
? parseResidentSurfaceSnapshot(record.snapshot)
|
||||
: null;
|
||||
if (availability === "ready" && snapshot === null) {
|
||||
invalid("ready resident surface requires a snapshot");
|
||||
}
|
||||
if (availability !== "ready" && snapshot !== null) {
|
||||
invalid("non-ready resident surface must not include a snapshot");
|
||||
}
|
||||
const result: SubjektivResidentSurfaceResponse = {
|
||||
subject_id: subjectId,
|
||||
availability,
|
||||
};
|
||||
if (snapshot !== null) result.snapshot = snapshot;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseResidentSurfaceSnapshot(
|
||||
value: unknown,
|
||||
): SubjektivResidentSurfaceSnapshot {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"snapshot_id",
|
||||
"body_md",
|
||||
"memory_refs",
|
||||
"built_from_store_revision",
|
||||
"created_at",
|
||||
],
|
||||
"Resident surface snapshot",
|
||||
);
|
||||
return {
|
||||
snapshot_id: requiredIdentifier(record, "snapshot_id"),
|
||||
body_md: requiredString(
|
||||
record,
|
||||
"body_md",
|
||||
MEMORY_API_LIMITS.maxDocumentBytes,
|
||||
),
|
||||
memory_refs: boundedArray(
|
||||
record.memory_refs,
|
||||
MAX_MEMORY_REFS,
|
||||
"memory_refs",
|
||||
).map(parseMemoryRevisionRef),
|
||||
built_from_store_revision: requiredNonNegativeInteger(
|
||||
record,
|
||||
"built_from_store_revision",
|
||||
),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryQueryResponse(
|
||||
value: unknown,
|
||||
): SubjektivMemoryQueryResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["items", "next_cursor", "has_more"],
|
||||
"Current Memory list response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const items = boundedArray(
|
||||
record.items,
|
||||
MAX_CURRENT_MEMORIES,
|
||||
"items",
|
||||
).map(parseMemoryQueryItem);
|
||||
assertUnique(items.map((item) => item.id), "Memory ids");
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory list");
|
||||
const result: SubjektivMemoryQueryResponse = { items, has_more: hasMore };
|
||||
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryQueryItem(value: unknown): SubjektivMemoryQueryItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["id", "revision", "kind", "state", "claim", "excerpt", "updated_at"],
|
||||
"Current Memory item",
|
||||
);
|
||||
return {
|
||||
id: requiredIdentifier(record, "id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
excerpt: requiredString(record, "excerpt"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryReadResponse(
|
||||
value: unknown,
|
||||
expectedMemoryId?: string,
|
||||
): SubjektivMemoryReadResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"memory_id",
|
||||
"revision",
|
||||
"current_revision",
|
||||
"kind",
|
||||
"state",
|
||||
"claim",
|
||||
"body_md",
|
||||
"why_useful",
|
||||
"staleness",
|
||||
"change_reason",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"body_offset",
|
||||
"body_byte_offset",
|
||||
"body_next_offset",
|
||||
"body_next_byte_offset",
|
||||
"body_truncated",
|
||||
"source_candidate_ids",
|
||||
"source_candidates",
|
||||
"derived_from",
|
||||
"evidence_next_cursor",
|
||||
"evidence_has_more",
|
||||
],
|
||||
"Memory detail response",
|
||||
[
|
||||
"staleness",
|
||||
"body_next_offset",
|
||||
"body_next_byte_offset",
|
||||
"evidence_next_cursor",
|
||||
],
|
||||
);
|
||||
const memoryId = requiredIdentifier(record, "memory_id");
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory detail identity does not match the requested Memory");
|
||||
}
|
||||
const revision = requiredPositiveInteger(record, "revision");
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
if (revision > currentRevision) invalid("revision exceeds current_revision");
|
||||
const bodyTruncated = requiredBoolean(record, "body_truncated");
|
||||
const bodyNextOffset = optionalNonNegativeInteger(record, "body_next_offset");
|
||||
const bodyNextByteOffset = optionalNonNegativeInteger(
|
||||
record,
|
||||
"body_next_byte_offset",
|
||||
);
|
||||
if (
|
||||
bodyTruncated !==
|
||||
(bodyNextOffset !== undefined && bodyNextByteOffset !== undefined)
|
||||
) {
|
||||
invalid("body continuation fields do not match body_truncated");
|
||||
}
|
||||
const sourceCandidateIds = boundedArray(
|
||||
record.source_candidate_ids,
|
||||
MAX_MEMORY_REFS,
|
||||
"source_candidate_ids",
|
||||
).map((item) => identifier(item, "source_candidate_ids item"));
|
||||
assertUnique(sourceCandidateIds, "source candidate ids");
|
||||
const sourceCandidates = boundedArray(
|
||||
record.source_candidates,
|
||||
MAX_MEMORY_REFS,
|
||||
"source_candidates",
|
||||
).map(parseMemoryEvidenceCandidate);
|
||||
if (
|
||||
sourceCandidateIds.length !== sourceCandidates.length ||
|
||||
sourceCandidateIds.some((id, index) => sourceCandidates[index]?.candidate_id !== id)
|
||||
) {
|
||||
invalid("source candidate ids do not match source candidates");
|
||||
}
|
||||
const evidenceHasMore = requiredBoolean(record, "evidence_has_more");
|
||||
const evidenceNextCursor = optionalNullableString(
|
||||
record,
|
||||
"evidence_next_cursor",
|
||||
);
|
||||
assertCursorInvariant(evidenceHasMore, evidenceNextCursor, "Memory evidence");
|
||||
const result: SubjektivMemoryReadResponse = {
|
||||
memory_id: memoryId,
|
||||
revision,
|
||||
current_revision: currentRevision,
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
body_md: requiredString(
|
||||
record,
|
||||
"body_md",
|
||||
MEMORY_API_LIMITS.maxDocumentBytes,
|
||||
),
|
||||
why_useful: requiredString(record, "why_useful"),
|
||||
change_reason: requiredString(record, "change_reason"),
|
||||
created_at: requiredString(record, "created_at"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
body_offset: requiredNonNegativeInteger(record, "body_offset"),
|
||||
body_byte_offset: requiredNonNegativeInteger(record, "body_byte_offset"),
|
||||
body_truncated: bodyTruncated,
|
||||
source_candidate_ids: sourceCandidateIds,
|
||||
source_candidates: sourceCandidates,
|
||||
derived_from: boundedArray(
|
||||
record.derived_from,
|
||||
MAX_MEMORY_REFS,
|
||||
"derived_from",
|
||||
).map(parseMemoryRevisionRef),
|
||||
evidence_has_more: evidenceHasMore,
|
||||
};
|
||||
if ("staleness" in record) {
|
||||
result.staleness = nullableString(record, "staleness");
|
||||
}
|
||||
if (bodyNextOffset !== undefined) result.body_next_offset = bodyNextOffset;
|
||||
if (bodyNextByteOffset !== undefined) {
|
||||
result.body_next_byte_offset = bodyNextByteOffset;
|
||||
}
|
||||
if (evidenceNextCursor !== undefined) {
|
||||
result.evidence_next_cursor = evidenceNextCursor;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryEvidenceCandidate(
|
||||
value: unknown,
|
||||
): SubjektivMemoryEvidenceCandidate {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
[
|
||||
"candidate_id",
|
||||
"evidence",
|
||||
"evidence_total",
|
||||
"evidence_truncated",
|
||||
"source_refs",
|
||||
"source_refs_total",
|
||||
"source_refs_truncated",
|
||||
],
|
||||
"Memory evidence candidate",
|
||||
);
|
||||
const evidence = boundedArray(
|
||||
record.evidence,
|
||||
MAX_EVIDENCE_PER_RECORD,
|
||||
"evidence",
|
||||
).map(parseMemoryEvidence);
|
||||
assertUnique(evidence.map((item) => item.id), "evidence ids");
|
||||
const evidenceTotal = requiredNonNegativeInteger(record, "evidence_total");
|
||||
const evidenceTruncated = requiredBoolean(record, "evidence_truncated");
|
||||
assertCollectionTotal(
|
||||
evidence.length,
|
||||
evidenceTotal,
|
||||
evidenceTruncated,
|
||||
"evidence",
|
||||
);
|
||||
const sourceRefs = boundedArray(
|
||||
record.source_refs,
|
||||
MAX_SOURCE_REFS_PER_RECORD,
|
||||
"source_refs",
|
||||
).map(parseMemorySourceEvidenceRef);
|
||||
const sourceRefsTotal = requiredNonNegativeInteger(
|
||||
record,
|
||||
"source_refs_total",
|
||||
);
|
||||
const sourceRefsTruncated = requiredBoolean(
|
||||
record,
|
||||
"source_refs_truncated",
|
||||
);
|
||||
assertCollectionTotal(
|
||||
sourceRefs.length,
|
||||
sourceRefsTotal,
|
||||
sourceRefsTruncated,
|
||||
"source refs",
|
||||
);
|
||||
return {
|
||||
candidate_id: requiredIdentifier(record, "candidate_id"),
|
||||
evidence,
|
||||
evidence_total: evidenceTotal,
|
||||
evidence_truncated: evidenceTruncated,
|
||||
source_refs: sourceRefs,
|
||||
source_refs_total: sourceRefsTotal,
|
||||
source_refs_truncated: sourceRefsTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMemoryEvidence(value: unknown): SubjektivMemoryEvidence {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["id", "kind", "entry_range", "origin", "excerpt", "summary"],
|
||||
"Memory evidence",
|
||||
["entry_range", "origin", "excerpt", "summary"],
|
||||
);
|
||||
const result: SubjektivMemoryEvidence = {
|
||||
id: requiredIdentifier(record, "id"),
|
||||
kind: requiredString(record, "kind"),
|
||||
};
|
||||
if ("entry_range" in record) {
|
||||
result.entry_range = record.entry_range === null
|
||||
? null
|
||||
: parseEntryRange(record.entry_range, "entry_range");
|
||||
}
|
||||
if ("origin" in record) {
|
||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
||||
}
|
||||
if ("excerpt" in record) result.excerpt = nullableString(record, "excerpt");
|
||||
if ("summary" in record) result.summary = nullableString(record, "summary");
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemorySourceEvidenceRef(
|
||||
value: unknown,
|
||||
): SubjektivMemorySourceEvidenceRef {
|
||||
const keys = [
|
||||
"session_id",
|
||||
"segment_id",
|
||||
"entry_range",
|
||||
"evidence_id",
|
||||
"origin",
|
||||
"evidence_kind",
|
||||
"label",
|
||||
"summary",
|
||||
] as const;
|
||||
const record = strictRecord(
|
||||
value,
|
||||
keys,
|
||||
"Memory source evidence ref",
|
||||
keys,
|
||||
);
|
||||
const result: SubjektivMemorySourceEvidenceRef = {};
|
||||
for (
|
||||
const key of [
|
||||
"session_id",
|
||||
"segment_id",
|
||||
"evidence_id",
|
||||
"evidence_kind",
|
||||
"label",
|
||||
"summary",
|
||||
] as const
|
||||
) {
|
||||
if (key in record) result[key] = nullableString(record, key);
|
||||
}
|
||||
if ("entry_range" in record) {
|
||||
result.entry_range = record.entry_range === null
|
||||
? null
|
||||
: parseEntryRange(record.entry_range, "entry_range");
|
||||
}
|
||||
if ("origin" in record) {
|
||||
result.origin = record.origin === null ? null : parseEvidenceOrigin(record.origin);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseSubjektivMemoryListRevisionsResponse(
|
||||
value: unknown,
|
||||
expectedMemoryId?: string,
|
||||
): SubjektivMemoryListRevisionsResponse {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "current_revision", "items", "next_cursor", "has_more"],
|
||||
"Memory revisions response",
|
||||
["next_cursor"],
|
||||
);
|
||||
const memoryId = requiredIdentifier(record, "memory_id");
|
||||
if (expectedMemoryId !== undefined && memoryId !== expectedMemoryId) {
|
||||
invalid("Memory revisions identity does not match the requested Memory");
|
||||
}
|
||||
const currentRevision = requiredPositiveInteger(record, "current_revision");
|
||||
const items = boundedArray(record.items, MAX_REVISIONS, "items").map(
|
||||
parseMemoryRevisionItem,
|
||||
);
|
||||
if (items.some((item) => item.revision > currentRevision)) {
|
||||
invalid("revision history contains a future revision");
|
||||
}
|
||||
assertUnique(
|
||||
items.map((item) => String(item.revision)),
|
||||
"Memory revisions",
|
||||
);
|
||||
const hasMore = requiredBoolean(record, "has_more");
|
||||
const nextCursor = optionalNullableString(record, "next_cursor");
|
||||
assertCursorInvariant(hasMore, nextCursor, "Memory revisions");
|
||||
const result: SubjektivMemoryListRevisionsResponse = {
|
||||
memory_id: memoryId,
|
||||
current_revision: currentRevision,
|
||||
items,
|
||||
has_more: hasMore,
|
||||
};
|
||||
if (nextCursor !== undefined) result.next_cursor = nextCursor;
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseMemoryRevisionItem(value: unknown): SubjektivMemoryRevisionItem {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["revision", "kind", "state", "claim", "change_reason", "updated_at"],
|
||||
"Memory revision item",
|
||||
);
|
||||
return {
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
kind: parseCandidateKind(record.kind),
|
||||
state: parseMemoryState(record.state),
|
||||
claim: requiredString(record, "claim"),
|
||||
change_reason: requiredString(record, "change_reason"),
|
||||
updated_at: requiredString(record, "updated_at"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMemoryRevisionRef(value: unknown): SubjektivMemoryRevisionRef {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
["memory_id", "revision"],
|
||||
"Memory revision ref",
|
||||
);
|
||||
return {
|
||||
memory_id: requiredIdentifier(record, "memory_id"),
|
||||
revision: requiredPositiveInteger(record, "revision"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCandidateKind(value: unknown): MemoryCandidateKind {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!candidateKinds.has(value as MemoryCandidateKind)
|
||||
) {
|
||||
invalid("unknown Memory candidate kind");
|
||||
}
|
||||
return value as MemoryCandidateKind;
|
||||
}
|
||||
|
||||
function parseMemoryState(value: unknown): SubjektivMemoryState {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!memoryStates.has(value as SubjektivMemoryState)
|
||||
) {
|
||||
invalid("unknown Memory state");
|
||||
}
|
||||
return value as SubjektivMemoryState;
|
||||
}
|
||||
|
||||
function assertCollectionTotal(
|
||||
returned: number,
|
||||
total: number,
|
||||
truncated: boolean,
|
||||
label: string,
|
||||
): void {
|
||||
if (total < returned || truncated !== (total > returned)) {
|
||||
invalid(`${label} total does not match its truncated state`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCursorInvariant(
|
||||
hasMore: boolean,
|
||||
cursor: string | null | undefined,
|
||||
label: string,
|
||||
): void {
|
||||
if (hasMore !== (typeof cursor === "string" && cursor.length > 0)) {
|
||||
invalid(`${label} cursor does not match has_more`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnique(values: string[], label: string): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
invalid(`${label} must be unique`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDiagnostic(value: unknown): Diagnostic {
|
||||
const record = strictRecord(
|
||||
value,
|
||||
@@ -384,6 +954,55 @@ function requiredNonNegativeInteger(
|
||||
return nonNegativeInteger(record[key], key);
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number {
|
||||
return positiveInteger(record[key], key);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
const result = nonNegativeInteger(value, label);
|
||||
if (result === 0) invalid(`${label} must be a positive safe integer`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalNonNegativeInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
if (!(key in record)) return undefined;
|
||||
if (record[key] === null) return undefined;
|
||||
return nonNegativeInteger(record[key], key);
|
||||
}
|
||||
|
||||
function requiredIdentifier(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string {
|
||||
return identifier(record[key], key);
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
new TextEncoder().encode(value).byteLength >
|
||||
MEMORY_API_LIMITS.maxIdentifierBytes
|
||||
) {
|
||||
invalid(`${label} must be a non-empty bounded identifier`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | null | undefined {
|
||||
if (!(key in record)) return undefined;
|
||||
return nullableString(record, key);
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
invalid(`${label} must be a non-negative safe integer`);
|
||||
@@ -395,10 +1014,10 @@ function parseEntryRange(value: unknown, label: string): [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
invalid(`${label} must be a two-item entry range`);
|
||||
}
|
||||
return [
|
||||
nonNegativeInteger(value[0], label),
|
||||
nonNegativeInteger(value[1], label),
|
||||
];
|
||||
const start = nonNegativeInteger(value[0], label);
|
||||
const end = nonNegativeInteger(value[1], label);
|
||||
if (start > end) invalid(`${label} must be ordered`);
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
function parseNullableEntryRange(
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
};
|
||||
|
||||
let { currentPath = "/", workspaceId }: Props = $props();
|
||||
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory") : "/");
|
||||
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory/staging") : "/");
|
||||
let subjectsHref = $derived(
|
||||
workspaceId ? workspaceRoute(workspaceId, "/memory") : "/",
|
||||
);
|
||||
let subjectsActive = $derived(
|
||||
currentPath === subjectsHref || currentPath.startsWith(`${subjectsHref}/`),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="sidebar-nav-section sidebar-nav-section--category">
|
||||
@@ -16,15 +20,8 @@
|
||||
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === documentHref}
|
||||
href={documentHref}
|
||||
aria-current={currentPath === documentHref ? "page" : undefined}
|
||||
>Document</a>
|
||||
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath.startsWith(stagingHref)}
|
||||
href={stagingHref}
|
||||
aria-current={currentPath.startsWith(stagingHref) ? "page" : undefined}
|
||||
>Staging</a>
|
||||
class:active={subjectsActive}
|
||||
href={subjectsHref}
|
||||
aria-current={subjectsActive ? "page" : undefined}
|
||||
>Subjects</a>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const designLabBasePath = '/design-lab/workspace-web-ux';
|
||||
export const designLabBasePath = "/design-lab/workspace-web-ux";
|
||||
|
||||
export type WorkspaceNavigationItem = {
|
||||
label: string;
|
||||
@@ -7,31 +7,36 @@ export type WorkspaceNavigationItem = {
|
||||
};
|
||||
|
||||
export const workspaceNavigation: WorkspaceNavigationItem[] = [
|
||||
{ label: 'Tickets', href: `${designLabBasePath}?resource=tickets` },
|
||||
{ label: 'Objectives', href: `${designLabBasePath}?resource=objectives` },
|
||||
{ label: 'Merge Requests', href: `${designLabBasePath}?resource=merge-requests` },
|
||||
{ label: "Tickets", href: `${designLabBasePath}?resource=tickets` },
|
||||
{ label: "Objectives", href: `${designLabBasePath}?resource=objectives` },
|
||||
{
|
||||
label: 'Memory',
|
||||
label: "Merge Requests",
|
||||
href: `${designLabBasePath}?resource=merge-requests`,
|
||||
},
|
||||
{
|
||||
label: "Memory",
|
||||
href: `${designLabBasePath}?resource=memory`,
|
||||
children: [
|
||||
{ label: 'Document', href: `${designLabBasePath}?resource=memory-document` },
|
||||
{ label: 'Staging', href: `${designLabBasePath}?resource=memory-staging` },
|
||||
{
|
||||
label: "Subjects",
|
||||
href: `${designLabBasePath}?resource=memory-subjects`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: 'Workers', href: `${designLabBasePath}?resource=workers` },
|
||||
{ label: "Workers", href: `${designLabBasePath}?resource=workers` },
|
||||
];
|
||||
|
||||
export const workspaceWorkers = [
|
||||
{
|
||||
key: 'wrk-language-review',
|
||||
label: 'Language review',
|
||||
state: 'Running',
|
||||
repository: 'yoi',
|
||||
key: "wrk-language-review",
|
||||
label: "Language review",
|
||||
state: "Running",
|
||||
repository: "yoi",
|
||||
},
|
||||
{
|
||||
key: 'wrk-accessibility-check',
|
||||
label: 'Accessibility check with a deliberately long display name',
|
||||
state: 'Idle',
|
||||
repository: 'workspace-web',
|
||||
key: "wrk-accessibility-check",
|
||||
label: "Accessibility check with a deliberately long display name",
|
||||
state: "Idle",
|
||||
repository: "workspace-web",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,66 +1,74 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const subjects = $derived(data.subjects.data?.items ?? []);
|
||||
|
||||
function subjectHref(subjectId: string): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
<title>Subjects · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory subjects" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
<section class="memory-page memory-subject-index" aria-labelledby="memory-subjects-heading" data-memory-view="subjects">
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory</p>
|
||||
<h1 id="memory-subjects-heading">Subjects</h1>
|
||||
<p>Read-only durable context, organized by explicit subject.</p>
|
||||
</div>
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if data.memory.data.body_md.trim().length === 0}
|
||||
<div class="memory-document-state" role="status">
|
||||
<strong>Memory document is empty.</strong>
|
||||
<p>Durable Workspace context will appear here when it is available.</p>
|
||||
{#if data.subjects.data}
|
||||
{#if subjects.length === 0}
|
||||
<div class="memory-state" role="status" data-memory-state="empty">
|
||||
<strong>No Memory subjects.</strong>
|
||||
<p>Subjects will appear here after they are created for this Workspace.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="memory-document-content" aria-label="Memory document content">
|
||||
<DocumentMarkdown text={data.memory.data.body_md} />
|
||||
</article>
|
||||
<div class="subject-list" aria-label="Memory subjects">
|
||||
{#each subjects as subject (subject.id)}
|
||||
<a class="subject-row" href={subjectHref(subject.id)}>
|
||||
<div class="subject-copy">
|
||||
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
|
||||
<h2>{subject.role}</h2>
|
||||
<code title={subject.id}>{subject.id}</code>
|
||||
</div>
|
||||
<dl class="subject-meta">
|
||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.subjects.data.has_more}
|
||||
<p class="memory-note">Only the first {data.subjects.data.limit} subjects are shown.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if data.memory.error}
|
||||
<div class="memory-document-state memory-document-error">
|
||||
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
|
||||
{:else if data.subjects.error}
|
||||
<div class="memory-state is-error" role="alert" data-memory-state="error">
|
||||
<strong>Subjects unavailable.</strong>
|
||||
<p>{data.subjects.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-document-state" role="status">
|
||||
<p>Memory document data is unavailable.</p>
|
||||
<div class="memory-state" role="status" data-memory-state="unavailable">
|
||||
<p>Subject data is unavailable.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-page {
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
@@ -70,96 +78,161 @@
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-meta {
|
||||
.memory-page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
gap: var(--space-5);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta > p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
.memory-page-header h1,
|
||||
.subject-copy h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-document-meta summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-document-meta dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-document-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-document-state p {
|
||||
.memory-page-header h1 {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.memory-page-header p:not(.memory-eyebrow) {
|
||||
max-width: 46rem;
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subject-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.8fr);
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.subject-row:hover,
|
||||
.subject-row:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.subject-copy {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-copy h2 {
|
||||
font-size: var(--font-size-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-copy code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state-pill {
|
||||
color: var(--success);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-state-pill.is-retired {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.subject-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.subject-meta div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-meta dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subject-meta dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p,
|
||||
.memory-note {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.memory-document-error,
|
||||
.memory-document-error strong {
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-document-meta {
|
||||
.memory-page-header {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
.subject-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
.subject-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { MEMORY_API_LIMITS } from "$lib/generated/memory-api";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivSubjectListResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
memory: await loadJson(
|
||||
subjects: await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/memory"),
|
||||
`${workspaceApiPath(params.workspaceId, "/subjektiv/subjects")}?limit=100`,
|
||||
undefined,
|
||||
parseMemoryDocumentResponse,
|
||||
{
|
||||
diagnosticLabel: "Memory API",
|
||||
maxResponseBytes: MEMORY_API_LIMITS.maxResponseBytes,
|
||||
},
|
||||
parseSubjektivSubjectListResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { SubjektivMemoryState } from '$lib/generated/memory-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const surface = $derived(data.surface.data);
|
||||
const snapshot = $derived(surface?.snapshot ?? null);
|
||||
const memories = $derived(data.memories.data?.items ?? []);
|
||||
|
||||
function subjectsHref(): string {
|
||||
return workspaceRoute(data.workspaceId, '/memory');
|
||||
}
|
||||
|
||||
function memoryHref(memoryId: string): string {
|
||||
return workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function stateLabel(state: SubjektivMemoryState): string {
|
||||
return state[0].toUpperCase() + state.slice(1);
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return kind.replaceAll('_', ' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Subject resident surface and current committed Memories" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-page memory-subject-page" aria-labelledby="subject-heading" data-memory-view="subject" data-memory-state={surface?.availability ?? (data.surface.error ? 'error' : 'unavailable')}>
|
||||
<a class="memory-back-link" href={subjectsHref()}>← All subjects</a>
|
||||
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Memory subject</p>
|
||||
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
|
||||
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
|
||||
{#if data.subject.data}
|
||||
<dl class="subject-facts" aria-label="Subject details">
|
||||
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
|
||||
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
|
||||
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
|
||||
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
{:else if data.subject.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Subject details unavailable.</strong>
|
||||
<p>{data.subject.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
|
||||
{/if}
|
||||
|
||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Resident context</p>
|
||||
<h2 id="resident-surface-heading">Resident surface</h2>
|
||||
</div>
|
||||
{#if surface}
|
||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if surface?.availability === 'ready' && snapshot}
|
||||
<div class="surface-meta">
|
||||
<span>Built from store revision {snapshot.built_from_store_revision}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
{#if snapshot.body_md.trim().length === 0}
|
||||
<div class="memory-state" role="status" data-surface-ready-empty>
|
||||
<strong>Resident surface is ready and empty.</strong>
|
||||
<p>This subject currently has no resident context to display.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="surface-document" aria-label="Resident surface">
|
||||
<DocumentMarkdown text={snapshot.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
<details class="surface-details">
|
||||
<summary>Surface provenance</summary>
|
||||
<dl>
|
||||
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Memory refs</dt>
|
||||
<dd>
|
||||
{#if snapshot.memory_refs.length === 0}
|
||||
None
|
||||
{:else}
|
||||
<ul class="reference-list">
|
||||
{#each snapshot.memory_refs as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={memoryHref(reference.memory_id)}><code>{reference.memory_id}</code> · revision {reference.revision}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
{:else if surface?.availability === 'ungenerated'}
|
||||
<div class="memory-state" role="status">
|
||||
<strong>Resident surface has not been generated.</strong>
|
||||
<p>Committed Memories remain available below.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'stale'}
|
||||
<div class="memory-state is-warning" role="status">
|
||||
<strong>Resident surface is stale.</strong>
|
||||
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'failed'}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface generation failed.</strong>
|
||||
<p>No failed or partial snapshot is displayed.</p>
|
||||
</div>
|
||||
{:else if data.surface.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface unavailable.</strong>
|
||||
<p>{data.surface.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="memories-section" aria-labelledby="current-memories-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Committed records</p>
|
||||
<h2 id="current-memories-heading">Current Memories</h2>
|
||||
</div>
|
||||
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
|
||||
</header>
|
||||
|
||||
{#if data.memories.data}
|
||||
{#if memories.length === 0}
|
||||
<div class="memory-state" role="status" data-memories-empty>
|
||||
<strong>No committed Memories.</strong>
|
||||
<p>This subject has no current Memory revisions.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-list" aria-label="Current committed Memories">
|
||||
{#each memories as memory (memory.id)}
|
||||
<a class="memory-row" href={memoryHref(memory.id)}>
|
||||
<div class="memory-row-heading">
|
||||
<div class="pill-row">
|
||||
<span class="memory-pill is-{memory.state}">{stateLabel(memory.state)}</span>
|
||||
<span class="memory-kind">{kindLabel(memory.kind)}</span>
|
||||
</div>
|
||||
<h3>{memory.claim}</h3>
|
||||
<p>{memory.excerpt || 'No excerpt.'}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if data.memories.data.has_more}
|
||||
<p class="memory-note">More current Memories are available after this bounded page.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if data.memories.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Current Memories unavailable.</strong>
|
||||
<p>{data.memories.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Current Memory data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-back-link {
|
||||
width: fit-content;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-page-header,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.memory-page-header {
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-page-header h1,
|
||||
.section-heading h2,
|
||||
.memory-row h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-page-header h1 {
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--accent);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subject-id {
|
||||
display: block;
|
||||
max-width: min(60rem, 100%);
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.read-only-label,
|
||||
.memory-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subject-facts > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.subject-facts dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subject-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability,
|
||||
.memory-pill,
|
||||
.memory-kind {
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability.is-ready,
|
||||
.memory-pill.is-active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.subject-state.is-retired,
|
||||
.availability.is-ungenerated,
|
||||
.memory-pill.is-retracted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.availability.is-stale,
|
||||
.memory-pill.is-resolved {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.availability.is-failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.surface-section,
|
||||
.memories-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.surface-meta,
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.surface-document {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.surface-details {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.surface-details summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.surface-details dl {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.surface-details dd,
|
||||
.reference-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reference-list {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(19rem, 0.8fr);
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.memory-row:hover,
|
||||
.memory-row:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.memory-row-heading {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-row h3 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.memory-row p,
|
||||
.memory-note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-kind {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-row dl {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.memory-row dl > div {
|
||||
grid-template-columns: 6.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-row dd,
|
||||
.memory-row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-state.is-warning,
|
||||
.memory-state.is-warning strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-page-header,
|
||||
.section-heading {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.memory-row dl > div,
|
||||
.surface-details dl > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const subjectId = params.subjectId;
|
||||
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
||||
const [subject, surface, memories] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, subjectPath),
|
||||
undefined,
|
||||
(value) => parseSubjektivSubjectResponse(value, subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, `${subjectPath}/surface`),
|
||||
undefined,
|
||||
(value) => parseSubjektivResidentSurfaceResponse(value, subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, `${subjectPath}/memories`)}?limit=100`,
|
||||
undefined,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
subject,
|
||||
surface,
|
||||
memories,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,626 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type {
|
||||
MemoryEvidenceOrigin,
|
||||
SubjektivMemoryRevisionRef,
|
||||
SubjektivMemorySourceEvidenceRef,
|
||||
} from '$lib/generated/memory-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const memory = $derived(data.memory.data);
|
||||
const revisions = $derived(data.revisions.data?.items ?? []);
|
||||
|
||||
function subjectHref(): string {
|
||||
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`);
|
||||
}
|
||||
|
||||
function memoryPath(memoryId = data.memoryId): string {
|
||||
return workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/memory/${encodeURIComponent(data.subjectId)}/${encodeURIComponent(memoryId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function revisionHref(revision: number): string {
|
||||
return `${memoryPath()}?revision=${revision}`;
|
||||
}
|
||||
|
||||
function derivedHref(reference: SubjektivMemoryRevisionRef): string {
|
||||
return `${memoryPath(reference.memory_id)}?revision=${reference.revision}`;
|
||||
}
|
||||
|
||||
function bodyContinuationHref(): string | null {
|
||||
if (!memory?.body_truncated || memory.body_next_offset == null || memory.body_next_byte_offset == null) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
offset: String(memory.body_next_offset),
|
||||
byte_offset: String(memory.body_next_byte_offset),
|
||||
});
|
||||
return `${memoryPath()}?${query}`;
|
||||
}
|
||||
|
||||
function evidenceContinuationHref(): string | null {
|
||||
if (!memory?.evidence_has_more || !memory.evidence_next_cursor) return null;
|
||||
const query = new URLSearchParams({
|
||||
revision: String(memory.revision),
|
||||
evidence_cursor: memory.evidence_next_cursor,
|
||||
});
|
||||
return `${memoryPath()}?${query}`;
|
||||
}
|
||||
|
||||
function label(value: string): string {
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||
}
|
||||
|
||||
function originLabel(origin: MemoryEvidenceOrigin | null | undefined): string {
|
||||
if (!origin) return 'Origin unavailable';
|
||||
const identity = [
|
||||
origin.account_id && `account ${origin.account_id}`,
|
||||
origin.workspace_id && `workspace ${origin.workspace_id}`,
|
||||
origin.runtime_id && `runtime ${origin.runtime_id}`,
|
||||
origin.worker_id && `worker ${origin.worker_id}`,
|
||||
origin.flow_selector && `flow ${origin.flow_selector}`,
|
||||
origin.flow_definition_id && `definition ${origin.flow_definition_id}`,
|
||||
origin.flow_definition_revision != null && `definition revision ${origin.flow_definition_revision}`,
|
||||
].filter(Boolean);
|
||||
return `${label(origin.kind)}${identity.length ? ` · ${identity.join(' · ')}` : ''}`;
|
||||
}
|
||||
|
||||
function sourceRefTitle(reference: SubjektivMemorySourceEvidenceRef): string {
|
||||
return reference.label || reference.summary || reference.evidence_id || reference.segment_id || 'Source reference';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{memory?.claim ?? data.memoryId} · Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Committed Memory detail, immutable revisions, and provenance" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="memory-page memory-detail-page" aria-labelledby="memory-detail-heading" data-memory-view="detail" data-memory-state={data.memory.error ? 'error' : memory ? memory.state : 'unavailable'}>
|
||||
<a class="memory-back-link" href={subjectHref()}>← {data.subject.data?.role ?? 'Subject'}</a>
|
||||
|
||||
{#if memory}
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<div class="pill-row">
|
||||
<span class="memory-pill is-{memory.state}">{memory.state}</span>
|
||||
<span class="memory-kind">{label(memory.kind)}</span>
|
||||
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
||||
</div>
|
||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||
<code class="memory-id" title={memory.memory_id}>{memory.memory_id}</code>
|
||||
</div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
|
||||
<dl class="memory-facts" aria-label="Memory details">
|
||||
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>State</dt><dd>{memory.state}</dd></div>
|
||||
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
|
||||
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
||||
<header class="section-heading"><h2 id="memory-body-heading">Memory</h2></header>
|
||||
<p class="why-useful"><strong>Why useful:</strong> {memory.why_useful}</p>
|
||||
{#if memory.staleness}<p class="staleness"><strong>Staleness:</strong> {memory.staleness}</p>{/if}
|
||||
<p class="change-reason"><strong>Change reason:</strong> {memory.change_reason}</p>
|
||||
|
||||
{#if memory.body_md.trim().length === 0}
|
||||
<div class="memory-state" role="status"><strong>This Memory body is empty.</strong></div>
|
||||
{:else}
|
||||
<article class="memory-document" aria-label="Committed Memory body">
|
||||
<DocumentMarkdown text={memory.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
{#if bodyContinuationHref()}
|
||||
<div class="continuation-note" role="status">
|
||||
<p>This bounded body segment continues at line {memory.body_next_offset}, byte {memory.body_next_byte_offset}.</p>
|
||||
<a href={bodyContinuationHref() ?? undefined}>View next body segment</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
|
||||
<header class="section-heading">
|
||||
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
|
||||
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
|
||||
</header>
|
||||
|
||||
{#if memory.source_candidates.length === 0}
|
||||
<div class="memory-state" role="status"><p>No candidate evidence is included on this page.</p></div>
|
||||
{:else}
|
||||
<div class="candidate-list">
|
||||
{#each memory.source_candidates as candidate, candidateIndex (candidate.candidate_id)}
|
||||
<article class="candidate-record">
|
||||
<header>
|
||||
<h3>Candidate</h3>
|
||||
<code title={candidate.candidate_id}>{candidate.candidate_id}</code>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby={`evidence-${candidateIndex}`}>
|
||||
<h4 id={`evidence-${candidateIndex}`}>Evidence <span>{candidate.evidence.length} of {candidate.evidence_total}</span></h4>
|
||||
{#if candidate.evidence.length === 0}
|
||||
<p class="empty-copy">No evidence anchors on this page.</p>
|
||||
{:else}
|
||||
<ul class="evidence-list">
|
||||
{#each candidate.evidence as evidence (evidence.id)}
|
||||
<li>
|
||||
<div class="evidence-heading"><strong>{evidence.id}</strong><span>{label(evidence.kind)}</span></div>
|
||||
<dl>
|
||||
<div><dt>Entry range</dt><dd>{rangeLabel(evidence.entry_range)}</dd></div>
|
||||
<div><dt>Origin</dt><dd>{originLabel(evidence.origin)}</dd></div>
|
||||
</dl>
|
||||
{#if evidence.summary}<p>{evidence.summary}</p>{/if}
|
||||
{#if evidence.excerpt}<blockquote>{evidence.excerpt}</blockquote>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if candidate.evidence_truncated}<p class="bounded-note">Additional evidence anchors are available.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`sources-${candidateIndex}`}>
|
||||
<h4 id={`sources-${candidateIndex}`}>Source refs <span>{candidate.source_refs.length} of {candidate.source_refs_total}</span></h4>
|
||||
{#if candidate.source_refs.length === 0}
|
||||
<p class="empty-copy">No source refs on this page.</p>
|
||||
{:else}
|
||||
<ul class="source-list">
|
||||
{#each candidate.source_refs as reference, index (`${candidate.candidate_id}:source:${index}`)}
|
||||
<li>
|
||||
<strong>{sourceRefTitle(reference)}</strong>
|
||||
<dl>
|
||||
<div><dt>Session</dt><dd><code>{reference.session_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Segment</dt><dd><code>{reference.segment_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Entry range</dt><dd>{rangeLabel(reference.entry_range)}</dd></div>
|
||||
<div><dt>Evidence id</dt><dd><code>{reference.evidence_id ?? 'None'}</code></dd></div>
|
||||
<div><dt>Evidence kind</dt><dd>{reference.evidence_kind ? label(reference.evidence_kind) : 'None'}</dd></div>
|
||||
<div><dt>Origin</dt><dd>{originLabel(reference.origin)}</dd></div>
|
||||
</dl>
|
||||
{#if reference.summary}<p>{reference.summary}</p>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if candidate.source_refs_truncated}<p class="bounded-note">Additional source refs are available.</p>{/if}
|
||||
</section>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if evidenceContinuationHref()}
|
||||
<div class="continuation-note" role="status">
|
||||
<p>Candidate evidence continues on another bounded page.</p>
|
||||
<a href={evidenceContinuationHref() ?? undefined}>View next evidence page</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section derived-section" aria-labelledby="derived-heading">
|
||||
<header class="section-heading"><h2 id="derived-heading">Derived from</h2><span>{memory.derived_from.length}</span></header>
|
||||
{#if memory.derived_from.length === 0}
|
||||
<p class="empty-copy">No derivation references.</p>
|
||||
{:else}
|
||||
<ul class="derived-list">
|
||||
{#each memory.derived_from as reference (`${reference.memory_id}:${reference.revision}`)}
|
||||
<li><a href={derivedHref(reference)}><code>{reference.memory_id}</code><span>Revision {reference.revision}</span></a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
||||
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
|
||||
{#if data.revisions.data}
|
||||
{#if revisions.length === 0}
|
||||
<p class="empty-copy">No revision history is available.</p>
|
||||
{:else}
|
||||
<ol class="revision-list">
|
||||
{#each revisions as revision (revision.revision)}
|
||||
<li class:current={revision.revision === memory.revision}>
|
||||
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
||||
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
|
||||
<p>{revision.claim}</p>
|
||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if data.revisions.data.has_more}<p class="bounded-note">More immutable revisions are available after this bounded page.</p>{/if}
|
||||
{/if}
|
||||
{:else if data.revisions.error}
|
||||
<div class="memory-state is-error" role="alert"><strong>Revision history unavailable.</strong><p>{data.revisions.error}</p></div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Revision history data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if data.memory.error}
|
||||
<header class="memory-page-header">
|
||||
<div><p class="memory-eyebrow">Committed Memory</p><h1 id="memory-detail-heading">{data.memoryId}</h1></div>
|
||||
<span class="read-only-label">Read-only</span>
|
||||
</header>
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Memory unavailable.</strong>
|
||||
<p>{data.memory.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<header class="memory-page-header"><h1 id="memory-detail-heading">{data.memoryId}</h1></header>
|
||||
<div class="memory-state" role="status"><p>Memory data is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-page {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-back-link {
|
||||
width: fit-content;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.memory-page-header,
|
||||
.section-heading,
|
||||
.candidate-record > header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-page-header {
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-page-header > div,
|
||||
.section-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-page-header h1,
|
||||
.section-heading h2,
|
||||
.candidate-record h3,
|
||||
.candidate-record h4 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-page-header h1 {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.section-heading > span,
|
||||
.candidate-record h4 span,
|
||||
.read-only-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.pill-row,
|
||||
.revision-list a > div,
|
||||
.evidence-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-pill,
|
||||
.memory-kind,
|
||||
.historical-pill,
|
||||
.memory-eyebrow {
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.memory-pill.is-active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.memory-pill.is-resolved,
|
||||
.historical-pill {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-pill.is-retracted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-kind,
|
||||
.memory-eyebrow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.memory-id {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-facts > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.memory-facts dt {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.memory-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.why-useful,
|
||||
.staleness,
|
||||
.change-reason,
|
||||
.empty-copy,
|
||||
.bounded-note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.why-useful strong,
|
||||
.change-reason strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.staleness,
|
||||
.staleness strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.memory-document {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.continuation-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.continuation-note p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.candidate-record {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.candidate-record > header {
|
||||
align-items: baseline;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.candidate-record > header code {
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.candidate-record section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.evidence-list,
|
||||
.source-list,
|
||||
.derived-list,
|
||||
.revision-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.evidence-list li,
|
||||
.source-list li {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.evidence-heading span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.evidence-list dl,
|
||||
.source-list dl {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.evidence-list dd,
|
||||
.source-list dd,
|
||||
.source-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.evidence-list p,
|
||||
.source-list p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.evidence-list blockquote {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding-left: var(--space-3);
|
||||
border-left: 3px solid var(--line-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.derived-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.derived-list a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.derived-list a:hover,
|
||||
.derived-list a:focus-visible,
|
||||
.revision-list a:hover,
|
||||
.revision-list a:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.derived-list code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.derived-list span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.revision-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.revision-list li.current {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.revision-list a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.revision-list p,
|
||||
.revision-list small {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.revision-list small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-state.is-error,
|
||||
.memory-state.is-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-page-header,
|
||||
.section-heading,
|
||||
.candidate-record > header,
|
||||
.continuation-note {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memory-facts,
|
||||
.derived-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.memory-facts > div,
|
||||
.evidence-list dl > div,
|
||||
.source-list dl > div {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.candidate-record {
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "$lib/workspace/memory/api";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const SAFE_INTEGER_TEXT = /^(0|[1-9][0-9]{0,15})$/;
|
||||
|
||||
function safeIntegerQuery(
|
||||
source: URLSearchParams,
|
||||
key: string,
|
||||
positive = false,
|
||||
): string | null {
|
||||
const value = source.get(key);
|
||||
if (value === null || !SAFE_INTEGER_TEXT.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || (positive ? parsed < 1 : parsed < 0)) {
|
||||
return null;
|
||||
}
|
||||
return String(parsed);
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const subjectId = params.subjectId;
|
||||
const memoryId = params.memoryId;
|
||||
const subjectPath = `/subjektiv/subjects/${encodeURIComponent(subjectId)}`;
|
||||
const memoryPath = `${subjectPath}/memories/${encodeURIComponent(memoryId)}`;
|
||||
const detailQuery = new URLSearchParams({ limit: "1000" });
|
||||
for (
|
||||
const [key, positive] of [
|
||||
["revision", true],
|
||||
["offset", false],
|
||||
["byte_offset", false],
|
||||
] as const
|
||||
) {
|
||||
const value = safeIntegerQuery(url.searchParams, key, positive);
|
||||
if (value !== null) detailQuery.set(key, value);
|
||||
}
|
||||
const evidenceCursor = url.searchParams.get("evidence_cursor");
|
||||
if (
|
||||
evidenceCursor !== null && evidenceCursor.length > 0 &&
|
||||
new TextEncoder().encode(evidenceCursor).byteLength <= 512
|
||||
) {
|
||||
detailQuery.set("evidence_cursor", evidenceCursor);
|
||||
}
|
||||
|
||||
const [subject, memory, revisions] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, subjectPath),
|
||||
undefined,
|
||||
(value) => parseSubjektivSubjectResponse(value, subjectId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, memoryPath)}?${detailQuery}`,
|
||||
undefined,
|
||||
(value) => parseSubjektivMemoryReadResponse(value, memoryId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
`${workspaceApiPath(params.workspaceId, `${memoryPath}/revisions`)}?limit=100`,
|
||||
undefined,
|
||||
(value) => parseSubjektivMemoryListRevisionsResponse(value, memoryId),
|
||||
MEMORY_API_LOAD_POLICY,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
subjectId,
|
||||
memoryId,
|
||||
subject,
|
||||
memory,
|
||||
revisions,
|
||||
};
|
||||
};
|
||||
@@ -2,75 +2,250 @@
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import MemoryPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
function pageProps(memory: {
|
||||
data: {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}): PageProps {
|
||||
return {
|
||||
data: { workspaceId: "workspace-1", memory },
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
import SubjectIndexPage from "./+page.svelte";
|
||||
import SubjectPage from "./[subjectId]/+page.svelte";
|
||||
import MemoryDetailPage from "./[subjectId]/[memoryId]/+page.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
|
||||
const { container } = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: "# Durable context\n\nRead the **current** document.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 48,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
const result = <T>(data: T | null, error: string | null = null) => ({
|
||||
data,
|
||||
error,
|
||||
});
|
||||
|
||||
function subject() {
|
||||
return {
|
||||
id: "subject-1",
|
||||
role: "Release coordinator",
|
||||
state: "active" as const,
|
||||
store_revision: 9,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
test("renders the subject index as the Memory product entry", () => {
|
||||
render(SubjectIndexPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjects: result({ limit: 100, items: [subject()], has_more: false }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Subjects", level: 1 })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||
expect(screen.queryByText("Staging")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders a ready resident surface and committed Memory lifecycle states", async () => {
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-1",
|
||||
body_md: "# Resident context\n\nUse the **current** committed record.",
|
||||
memory_refs: [{ memory_id: "memory-1", revision: 3 }],
|
||||
built_from_store_revision: 9,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
memories: result({
|
||||
items: [
|
||||
{
|
||||
id: "memory-1",
|
||||
revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current decision",
|
||||
excerpt: "Current excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
{
|
||||
id: "memory-2",
|
||||
revision: 2,
|
||||
kind: "lesson" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Resolved lesson",
|
||||
excerpt: "Resolved excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
{
|
||||
id: "memory-3",
|
||||
revision: 1,
|
||||
kind: "constraint" as const,
|
||||
state: "retracted" as const,
|
||||
claim: "Retracted constraint",
|
||||
excerpt: "Retracted excerpt",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Read-only")).not.toBeNull();
|
||||
expect(screen.getByText("Document details")).not.toBeNull();
|
||||
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(screen.getByText("resolved", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("retracted", { exact: true })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("distinguishes an empty document from request failure and unavailable data", async () => {
|
||||
const view = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
};
|
||||
const view = render(SubjectPage, {
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 9,
|
||||
created_at: "2026-01-02T03:04:05Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
body_md: " \n",
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "stale" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "failed" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
||||
|
||||
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
||||
expect(screen.getByText("Resident surface data is unavailable.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: { ...base, surface: result(null, "Memory API request failed") },
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Resident surface unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders committed Memory detail, provenance, derivation, and immutable revisions", async () => {
|
||||
render(MemoryDetailPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
memoryId: "memory-1",
|
||||
subject: result(subject()),
|
||||
memory: result({
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Keep provenance typed",
|
||||
body_md: "# Committed detail\n\nRead-only body.",
|
||||
why_useful: "Prevents origin loss.",
|
||||
change_reason: "Resolved after implementation.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 3,
|
||||
record_source: "fixture",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_truncated: false,
|
||||
source_candidate_ids: ["candidate-1"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-1",
|
||||
evidence: [{
|
||||
id: "evidence-1",
|
||||
kind: "message",
|
||||
summary: "Explicit decision.",
|
||||
}],
|
||||
evidence_total: 1,
|
||||
evidence_truncated: false,
|
||||
source_refs: [{
|
||||
session_id: "session-1",
|
||||
segment_id: "segment-1",
|
||||
evidence_id: "evidence-1",
|
||||
label: "Decision discussion",
|
||||
}],
|
||||
source_refs_total: 1,
|
||||
source_refs_truncated: false,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
evidence_has_more: false,
|
||||
}),
|
||||
revisions: result({
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
items: [{
|
||||
revision: 3,
|
||||
kind: "decision" as const,
|
||||
state: "active" as const,
|
||||
claim: "Current claim",
|
||||
change_reason: "Clarified",
|
||||
updated_at: "2026-01-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
kind: "decision" as const,
|
||||
state: "resolved" as const,
|
||||
claim: "Earlier claim",
|
||||
change_reason: "Resolved",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}],
|
||||
has_more: false,
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
|
||||
expect(screen.queryByText("Memory document is empty.")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: null }));
|
||||
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Committed detail", level: 1 }))
|
||||
.not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("candidate-1")).not.toBeNull();
|
||||
expect(screen.getByText("Decision discussion")).not.toBeNull();
|
||||
expect(screen.getByText("memory-parent")).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Revision history" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("Historical revision")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,12 +3,29 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
import {
|
||||
parseMemoryDocumentResponse,
|
||||
parseMemoryStagingListResponse,
|
||||
parseSubjektivMemoryListRevisionsResponse,
|
||||
parseSubjektivMemoryQueryResponse,
|
||||
parseSubjektivMemoryReadResponse,
|
||||
parseSubjektivResidentSurfaceResponse,
|
||||
parseSubjektivSubjectListResponse,
|
||||
parseSubjektivSubjectResponse,
|
||||
} from "../src/lib/workspace/memory/api.ts";
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, stable(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
if (JSON.stringify(stable(actual)) !== JSON.stringify(stable(expected))) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
@@ -27,8 +44,386 @@ function assertThrows(fn: () => void, expectedMessage: string): void {
|
||||
throw new Error(`expected function to throw ${expectedMessage}`);
|
||||
}
|
||||
|
||||
function fixture(origin: Record<string, unknown>) {
|
||||
function subject(id = "subject-1") {
|
||||
return {
|
||||
id,
|
||||
role: "Release coordinator",
|
||||
state: "active",
|
||||
store_revision: 12,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function queryItem(id = "memory-1") {
|
||||
return {
|
||||
id,
|
||||
revision: 3,
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Keep provenance typed.",
|
||||
excerpt: "Preserve source and derivation references.",
|
||||
updated_at: "2026-09-02T03:04:05Z",
|
||||
};
|
||||
}
|
||||
|
||||
function detailFixture() {
|
||||
const origin = {
|
||||
kind: "flow_instruction",
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-1",
|
||||
flow_definition_revision: 7,
|
||||
};
|
||||
return {
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
current_revision: 3,
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Keep provenance typed.",
|
||||
body_md: "# Committed Memory\n\nBounded body segment.",
|
||||
why_useful: "Prevents origin loss.",
|
||||
staleness: null,
|
||||
change_reason: "Resolved after implementation.",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T03:04:05Z",
|
||||
body_offset: 0,
|
||||
body_byte_offset: 0,
|
||||
body_next_offset: 2,
|
||||
body_next_byte_offset: 0,
|
||||
body_truncated: true,
|
||||
source_candidate_ids: ["candidate-1"],
|
||||
source_candidates: [{
|
||||
candidate_id: "candidate-1",
|
||||
evidence: [{
|
||||
id: "evidence-1",
|
||||
kind: "message",
|
||||
entry_range: [10, 12],
|
||||
origin,
|
||||
excerpt: "Use exact DTOs.",
|
||||
summary: "The decision was explicit.",
|
||||
}],
|
||||
evidence_total: 2,
|
||||
evidence_truncated: true,
|
||||
source_refs: [{
|
||||
session_id: "session-1",
|
||||
segment_id: "segment-1",
|
||||
entry_range: [10, 12],
|
||||
evidence_id: "evidence-1",
|
||||
origin,
|
||||
evidence_kind: "message",
|
||||
label: "Decision discussion",
|
||||
summary: "Bounded host summary.",
|
||||
}],
|
||||
source_refs_total: 2,
|
||||
source_refs_truncated: true,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", revision: 4 }],
|
||||
evidence_next_cursor: "evidence-page-2",
|
||||
evidence_has_more: true,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bounds", () => {
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse(subject(), "subject-1"),
|
||||
subject(),
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse(subject(), "another-subject"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), state: "paused" }),
|
||||
"unknown Subject state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
store_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
"safe integer",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), id: "x".repeat(513) }),
|
||||
"bounded identifier",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivSubjectResponse({ ...subject(), future: true }),
|
||||
"unknown field",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
const response = { limit: 100, items: [subject()], has_more: false };
|
||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: [subject("duplicate"), subject("duplicate")],
|
||||
has_more: false,
|
||||
}),
|
||||
"Subject ids must be unique",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
||||
const readyEmpty = {
|
||||
subject_id: "subject-1",
|
||||
availability: "ready",
|
||||
snapshot: {
|
||||
snapshot_id: "snapshot-empty",
|
||||
body_md: "",
|
||||
memory_refs: [],
|
||||
built_from_store_revision: 0,
|
||||
created_at: "2026-09-02T00:00:00Z",
|
||||
},
|
||||
};
|
||||
assertEquals(
|
||||
parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-1"),
|
||||
readyEmpty,
|
||||
);
|
||||
assertEquals(
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated",
|
||||
}),
|
||||
{ subject_id: "subject-1", availability: "ungenerated" },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready",
|
||||
}),
|
||||
"requires a snapshot",
|
||||
);
|
||||
for (const availability of ["ungenerated", "stale", "failed"]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
...readyEmpty,
|
||||
availability,
|
||||
}),
|
||||
"must not include a snapshot",
|
||||
);
|
||||
}
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivResidentSurfaceResponse({
|
||||
subject_id: "subject-1",
|
||||
availability: "refreshing",
|
||||
}),
|
||||
"unknown resident surface availability",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivResidentSurfaceResponse(readyEmpty, "subject-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, cursors, and oversized pages", () => {
|
||||
const response = {
|
||||
items: [queryItem()],
|
||||
has_more: true,
|
||||
next_cursor: "memory-page-2",
|
||||
};
|
||||
assertEquals(parseSubjektivMemoryQueryResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), state: "pending" }],
|
||||
}),
|
||||
"unknown Memory state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), kind: "future_kind" }],
|
||||
}),
|
||||
"unknown Memory candidate kind",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
...response,
|
||||
items: [{ ...queryItem(), revision: 0 }],
|
||||
}),
|
||||
"positive safe integer",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: [queryItem()],
|
||||
has_more: true,
|
||||
}),
|
||||
"cursor does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory detail parser preserves body/evidence continuation, sources, origins, and derivation", () => {
|
||||
const fixture = detailFixture();
|
||||
const parsed = parseSubjektivMemoryReadResponse(fixture, "memory-1");
|
||||
assertEquals(parsed, fixture);
|
||||
assertEquals(parsed.body_next_offset, 2);
|
||||
assertEquals(parsed.body_next_byte_offset, 0);
|
||||
assertEquals(parsed.evidence_next_cursor, "evidence-page-2");
|
||||
assertEquals(
|
||||
parsed.source_candidates[0].evidence[0].origin,
|
||||
fixture.source_candidates[0].evidence[0].origin,
|
||||
);
|
||||
assertEquals(
|
||||
parsed.source_candidates[0].source_refs,
|
||||
fixture.source_candidates[0].source_refs,
|
||||
);
|
||||
assertEquals(parsed.derived_from, [{
|
||||
memory_id: "memory-parent",
|
||||
revision: 4,
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("Memory detail parser fails closed on identity, variants, bounds, and continuation mismatch", () => {
|
||||
const fixture = detailFixture();
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(fixture, "memory-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
current_revision: Number.MAX_SAFE_INTEGER + 1,
|
||||
}),
|
||||
"safe integer",
|
||||
);
|
||||
const missingBodyContinuation = structuredClone(fixture) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete missingBodyContinuation.body_next_byte_offset;
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(missingBodyContinuation),
|
||||
"body continuation fields",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
source_candidate_ids: ["different-candidate"],
|
||||
}),
|
||||
"source candidate ids do not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
source_candidates: [{
|
||||
...fixture.source_candidates[0],
|
||||
evidence: [{
|
||||
...fixture.source_candidates[0].evidence[0],
|
||||
entry_range: [12, 10],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
"must be ordered",
|
||||
);
|
||||
const unknownOrigin = structuredClone(fixture);
|
||||
unknownOrigin.source_candidates[0].evidence[0].origin.kind = "future_origin";
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryReadResponse(unknownOrigin),
|
||||
"unknown Memory evidence origin kind",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryReadResponse({
|
||||
...fixture,
|
||||
evidence_has_more: false,
|
||||
}),
|
||||
"cursor does not match",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory revision parser preserves immutable states and checks response identity", () => {
|
||||
const response = {
|
||||
memory_id: "memory-1",
|
||||
current_revision: 3,
|
||||
items: [{
|
||||
revision: 3,
|
||||
kind: "decision",
|
||||
state: "active",
|
||||
claim: "Current claim.",
|
||||
change_reason: "Clarified wording.",
|
||||
updated_at: "2026-09-03T00:00:00Z",
|
||||
}, {
|
||||
revision: 2,
|
||||
kind: "decision",
|
||||
state: "resolved",
|
||||
claim: "Earlier claim.",
|
||||
change_reason: "Resolved.",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
}, {
|
||||
revision: 1,
|
||||
kind: "decision",
|
||||
state: "retracted",
|
||||
claim: "Original claim.",
|
||||
change_reason: "Retracted.",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
}],
|
||||
has_more: false,
|
||||
};
|
||||
assertEquals(
|
||||
parseSubjektivMemoryListRevisionsResponse(response, "memory-1"),
|
||||
response,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseSubjektivMemoryListRevisionsResponse(response, "memory-2"),
|
||||
"identity does not match",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], state: "archived" }],
|
||||
}),
|
||||
"unknown Memory state",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryListRevisionsResponse({
|
||||
...response,
|
||||
items: [{ ...response.items[0], revision: 4 }],
|
||||
}),
|
||||
"future revision",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Legacy staging parser remains strict for deprecated compatibility", () => {
|
||||
const origin = { kind: "worker_input", worker_id: "worker-1" };
|
||||
const response = {
|
||||
limit: 100,
|
||||
returned_count: 1,
|
||||
total_valid_count: 1,
|
||||
@@ -70,97 +465,19 @@ function fixture(origin: Record<string, unknown>) {
|
||||
}],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Memory document response requires the generated DTO fields", () => {
|
||||
assertEquals(
|
||||
parseMemoryDocumentResponse({
|
||||
body_md: "# Memory\n",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
bytes: 9,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
}).bytes,
|
||||
9,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseMemoryDocumentResponse({ body_md: "# Memory\n" }),
|
||||
"missing a required field",
|
||||
);
|
||||
});
|
||||
|
||||
for (
|
||||
const [kind, fields] of [
|
||||
["human_input", { account_id: "account-1" }],
|
||||
[
|
||||
"worker_input",
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
},
|
||||
],
|
||||
["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||
["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||
["legacy_unknown", {}],
|
||||
] as const
|
||||
) {
|
||||
Deno.test(`Memory staging parser preserves ${kind} origin`, () => {
|
||||
const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields }));
|
||||
assertEquals(parsed.items[0].record.evidence[0].origin, {
|
||||
kind,
|
||||
...fields,
|
||||
});
|
||||
assertEquals(parsed.items[0].record.source_refs[0].origin, {
|
||||
kind,
|
||||
...fields,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("Memory staging parser preserves Flow origin fields", () => {
|
||||
const origin = {
|
||||
kind: "flow_instruction" as const,
|
||||
workspace_id: "workspace-1",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
flow_selector: "builtin:coder-review",
|
||||
flow_definition_id: "flow-1",
|
||||
flow_definition_revision: 7,
|
||||
};
|
||||
const parsed = parseMemoryStagingListResponse(fixture(origin));
|
||||
assertEquals(parsed.items[0].record.source_refs[0].origin, origin);
|
||||
});
|
||||
|
||||
Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => {
|
||||
assertThrows(
|
||||
() => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })),
|
||||
"unknown Memory evidence origin kind",
|
||||
);
|
||||
assertEquals(parseMemoryStagingListResponse(response), response);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMemoryStagingListResponse(
|
||||
fixture({ kind: "human_input", future_field: "must not be accepted" }),
|
||||
),
|
||||
"unknown field",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => {
|
||||
const malformed = fixture({ kind: "legacy_unknown" });
|
||||
malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [
|
||||
number,
|
||||
number,
|
||||
];
|
||||
assertThrows(
|
||||
() => parseMemoryStagingListResponse(malformed),
|
||||
"two-item entry range",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseMemoryStagingListResponse(
|
||||
fixture({ kind: "worker_input", worker_id: "x".repeat(513) }),
|
||||
),
|
||||
"exceeds the Memory origin limit",
|
||||
parseMemoryStagingListResponse({
|
||||
...response,
|
||||
items: [{
|
||||
...response.items[0],
|
||||
record: {
|
||||
...response.items[0].record,
|
||||
source: { segment_id: "segment-1", range: [20, 10] },
|
||||
},
|
||||
}],
|
||||
}),
|
||||
"must be ordered",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user