feat: connect subjektiv extraction lifecycle
This commit is contained in:
@@ -19,9 +19,10 @@ use crate::{
|
|||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig,
|
||||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig,
|
||||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||||
|
WorkerManifest, WorkerMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
@@ -76,6 +77,8 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: Option<MemoryFeatureConfigPartial>,
|
pub memory: Option<MemoryFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub subjektiv: Option<SubjektivFeatureConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
pub web: Option<FeatureFlagConfigPartial>,
|
pub web: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub image: Option<FeatureFlagConfigPartial>,
|
pub image: Option<FeatureFlagConfigPartial>,
|
||||||
@@ -104,6 +107,11 @@ impl FeatureConfigPartial {
|
|||||||
Self {
|
Self {
|
||||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||||
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
||||||
|
subjektiv: merge_option(
|
||||||
|
self.subjektiv,
|
||||||
|
other.subjektiv,
|
||||||
|
SubjektivFeatureConfigPartial::merge,
|
||||||
|
),
|
||||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||||
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
||||||
sub_worker: merge_option(
|
sub_worker: merge_option(
|
||||||
@@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial {
|
|||||||
pub request_enabled: Option<bool>,
|
pub request_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivFeatureConfigPartial {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjektivFeatureConfigPartial {
|
||||||
|
fn merge(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: other.enabled.or(self.enabled),
|
||||||
|
extraction: merge_option(
|
||||||
|
self.extraction,
|
||||||
|
other.extraction,
|
||||||
|
MemoryExtractionProfileConfigPartial::merge,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MemoryFeatureConfigPartial {
|
impl MemoryFeatureConfigPartial {
|
||||||
fn merge(self, other: Self) -> Self {
|
fn merge(self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -331,6 +361,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.memory
|
.memory
|
||||||
.map(ResolvedMemoryFeatureConfig::from)
|
.map(ResolvedMemoryFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
subjektiv: value
|
||||||
|
.subjektiv
|
||||||
|
.map(ResolvedSubjektivFeatureConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
sub_worker: value
|
sub_worker: value
|
||||||
@@ -447,6 +481,42 @@ impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||||
|
fn from(value: SubjektivFeatureConfigPartial) -> Self {
|
||||||
|
let extraction = value.extraction.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
profile: SubjektivFeatureProfileConfig {
|
||||||
|
enabled: value.enabled.unwrap_or_default(),
|
||||||
|
extraction: MemoryExtractionProfileConfig {
|
||||||
|
enabled: extraction.enabled.unwrap_or(true),
|
||||||
|
model: extraction.model,
|
||||||
|
reasoning: extraction.reasoning,
|
||||||
|
threshold: extraction.threshold.or(Some(50_000)),
|
||||||
|
worker_max_turns: extraction
|
||||||
|
.worker_max_turns
|
||||||
|
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace_settings: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||||
|
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: Some(value.profile.enabled),
|
||||||
|
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||||
|
enabled: Some(value.profile.extraction.enabled),
|
||||||
|
model: value.profile.extraction.model,
|
||||||
|
reasoning: value.profile.extraction.reasoning,
|
||||||
|
threshold: value.profile.extraction.threshold,
|
||||||
|
worker_max_turns: value.profile.extraction.worker_max_turns,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -500,6 +570,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
Self {
|
Self {
|
||||||
task: Some(value.task.into()),
|
task: Some(value.task.into()),
|
||||||
memory: Some(value.memory.into()),
|
memory: Some(value.memory.into()),
|
||||||
|
subjektiv: Some(value.subjektiv.into()),
|
||||||
web: Some(value.web.into()),
|
web: Some(value.web.into()),
|
||||||
image: Some(value.image.into()),
|
image: Some(value.image.into()),
|
||||||
sub_worker: Some(value.sub_worker.into()),
|
sub_worker: Some(value.sub_worker.into()),
|
||||||
@@ -2490,6 +2561,36 @@ directories = [".claude/skills", ".cursor/skills"]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subjektiv_config_is_explicit_and_roundtrips_without_subject_identity() {
|
||||||
|
let config = WorkerManifestConfig::from_toml(
|
||||||
|
r#"
|
||||||
|
[worker]
|
||||||
|
name = "subject-worker"
|
||||||
|
|
||||||
|
[feature.subjektiv]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[feature.subjektiv.extraction]
|
||||||
|
enabled = true
|
||||||
|
threshold = 321
|
||||||
|
worker_max_turns = 7
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let partial = config.feature.subjektiv.unwrap();
|
||||||
|
assert_eq!(partial.enabled, Some(true));
|
||||||
|
assert_eq!(partial.extraction.as_ref().unwrap().threshold, Some(321));
|
||||||
|
|
||||||
|
let resolved = ResolvedSubjektivFeatureConfig::from(partial);
|
||||||
|
assert!(resolved.profile.enabled);
|
||||||
|
assert_eq!(resolved.profile.extraction.threshold, Some(321));
|
||||||
|
assert_eq!(resolved.profile.extraction.worker_max_turns, Some(7));
|
||||||
|
let encoded = serde_json::to_value(SubjektivFeatureConfigPartial::from(resolved)).unwrap();
|
||||||
|
assert!(encoded.get("subject_id").is_none());
|
||||||
|
assert_eq!(encoded["enabled"], true);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_preserves_ref() {
|
fn merge_preserves_ref() {
|
||||||
let lower = WorkerManifestConfig {
|
let lower = WorkerManifestConfig {
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ pub struct FeatureConfig {
|
|||||||
pub task: FeatureFlagConfig,
|
pub task: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: ResolvedMemoryFeatureConfig,
|
pub memory: ResolvedMemoryFeatureConfig,
|
||||||
|
/// Subject-scoped Memory extraction. Subject identity is resolved by the
|
||||||
|
/// Workspace host from the Worker's keyed singleton lease, never from this
|
||||||
|
/// model-visible manifest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub subjektiv: ResolvedSubjektivFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: FeatureFlagConfig,
|
pub web: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -139,6 +144,7 @@ impl Default for FeatureConfig {
|
|||||||
Self {
|
Self {
|
||||||
task: FeatureFlagConfig::disabled(),
|
task: FeatureFlagConfig::disabled(),
|
||||||
memory: ResolvedMemoryFeatureConfig::default(),
|
memory: ResolvedMemoryFeatureConfig::default(),
|
||||||
|
subjektiv: ResolvedSubjektivFeatureConfig::default(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
image: FeatureFlagConfig::disabled(),
|
image: FeatureFlagConfig::disabled(),
|
||||||
sub_worker: FeatureFlagConfig::disabled(),
|
sub_worker: FeatureFlagConfig::disabled(),
|
||||||
@@ -350,6 +356,79 @@ impl ResolvedMemoryFeatureConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Profile-owned subjektiv activation and extraction policy. The subject id is
|
||||||
|
/// deliberately absent: the Workspace host binds it from `subjektiv:<id>`
|
||||||
|
/// singleton ownership for each operation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct SubjektivFeatureProfileConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub extraction: MemoryExtractionProfileConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubjektivFeatureProfileConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
extraction: MemoryExtractionProfileConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable subjektiv execution configuration persisted in a resolved Worker
|
||||||
|
/// Manifest. Workspace settings provide only trusted extraction language and
|
||||||
|
/// are not subject identity.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct ResolvedSubjektivFeatureConfig {
|
||||||
|
pub profile: SubjektivFeatureProfileConfig,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedSubjektivFeatureConfig {
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.profile.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_workspace_settings(
|
||||||
|
&mut self,
|
||||||
|
settings: WorkspaceMemorySettingsSnapshot,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
if !self.profile.enabled {
|
||||||
|
if self.workspace_settings.is_some() {
|
||||||
|
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.workspace_settings.is_some() {
|
||||||
|
return Err("subjektiv Workspace settings are already bound");
|
||||||
|
}
|
||||||
|
self.workspace_settings = Some(settings);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||||
|
self.workspace_settings.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
||||||
|
if self.profile.enabled && self.workspace_settings.is_none() {
|
||||||
|
return Err("enabled subjektiv feature requires trusted Workspace settings");
|
||||||
|
}
|
||||||
|
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||||
|
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||||
|
}
|
||||||
|
if let Some(settings) = &self.workspace_settings
|
||||||
|
&& (settings.settings_revision == 0
|
||||||
|
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||||
|
{
|
||||||
|
return Err("subjektiv Workspace settings snapshot metadata is invalid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
pub struct TicketFeatureConfig {
|
pub struct TicketFeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -963,7 +1042,9 @@ impl Default for CompactionConfig {
|
|||||||
|
|
||||||
impl WorkerManifest {
|
impl WorkerManifest {
|
||||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
self.profile.is_some()
|
||||||
|
|| self.feature.memory.workspace_settings.is_some()
|
||||||
|
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement {
|
|||||||
Flow,
|
Flow,
|
||||||
ManageWorkdir,
|
ManageWorkdir,
|
||||||
Memory,
|
Memory,
|
||||||
|
Subjektiv,
|
||||||
MergeRequest,
|
MergeRequest,
|
||||||
Objective,
|
Objective,
|
||||||
Orchestration,
|
Orchestration,
|
||||||
@@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
|||||||
Self::Flow => formatter.write_str("feature.flow"),
|
Self::Flow => formatter.write_str("feature.flow"),
|
||||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
||||||
Self::Memory => formatter.write_str("feature.memory"),
|
Self::Memory => formatter.write_str("feature.memory"),
|
||||||
|
Self::Subjektiv => formatter.write_str("feature.subjektiv"),
|
||||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||||
Self::Objective => formatter.write_str("feature.objective"),
|
Self::Objective => formatter.write_str("feature.objective"),
|
||||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
||||||
@@ -185,6 +187,9 @@ pub fn validate_profile_execution_target(
|
|||||||
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||||
}
|
}
|
||||||
|
if feature.subjektiv.profile.enabled {
|
||||||
|
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
|
||||||
|
}
|
||||||
if feature.merge_request.show
|
if feature.merge_request.show
|
||||||
|| feature.merge_request.open
|
|| feature.merge_request.open
|
||||||
|| feature.merge_request.review
|
|| feature.merge_request.review
|
||||||
|
|||||||
@@ -234,6 +234,13 @@ impl_openapi_schema!(
|
|||||||
MemoryBackendResponse,
|
MemoryBackendResponse,
|
||||||
MemoryConsolidateStagingRequest,
|
MemoryConsolidateStagingRequest,
|
||||||
MemoryConsolidationResponse,
|
MemoryConsolidationResponse,
|
||||||
|
SubjektivSubjectCreateRequest,
|
||||||
|
SubjektivSubjectResponse,
|
||||||
|
SubjektivSubjectWorkerStartRequest,
|
||||||
|
SubjektivStageCandidateRequest,
|
||||||
|
SubjektivStageCandidateResponse,
|
||||||
|
SubjektivRecordSessionRequest,
|
||||||
|
SubjektivRecordSessionResponse,
|
||||||
SkillCatalogResponse,
|
SkillCatalogResponse,
|
||||||
SkillDetailResponse,
|
SkillDetailResponse,
|
||||||
SkillActivationResponse,
|
SkillActivationResponse,
|
||||||
@@ -1089,6 +1096,87 @@ pub trait ServerApi {
|
|||||||
#[body] request: MemoryConsolidateStagingRequest,
|
#[body] request: MemoryConsolidateStagingRequest,
|
||||||
) -> Result<MemoryConsolidationResponse, RepositoryApiError>;
|
) -> Result<MemoryConsolidationResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects",
|
||||||
|
status = 201,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_subject_create(
|
||||||
|
&self,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: SubjektivSubjectCreateRequest,
|
||||||
|
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
|
#[get(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}",
|
||||||
|
status = 200,
|
||||||
|
error_status = 404,
|
||||||
|
additional_error_statuses = [400, 401, 403, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_subject_get(
|
||||||
|
&self,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] subject_id: String,
|
||||||
|
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_subject_retire(
|
||||||
|
&self,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] subject_id: String,
|
||||||
|
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/worker",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500, 502, 503],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_subject_worker_start(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] subject_id: String,
|
||||||
|
#[body] request: SubjektivSubjectWorkerStartRequest,
|
||||||
|
) -> Result<BrowserCreateWorkerResponse, RepositoryApiError>;
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/staging",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||||
|
openapi = false,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_stage_candidate(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: SubjektivStageCandidateRequest,
|
||||||
|
) -> Result<SubjektivStageCandidateResponse, RepositoryApiError>;
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/sessions",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||||
|
openapi = false,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_record_session(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: SubjektivRecordSessionRequest,
|
||||||
|
) -> Result<SubjektivRecordSessionResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/skills",
|
"/api/w/{workspace_id}/skills",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -4356,6 +4444,68 @@ pub struct MemoryConsolidationResponse {
|
|||||||
pub total_bytes: u64,
|
pub total_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSubjectState {
|
||||||
|
Active,
|
||||||
|
Retired,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSubjectCreateRequest {
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSubjectResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub role: String,
|
||||||
|
pub state: SubjektivSubjectState,
|
||||||
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
pub store_revision: u64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
/// Current keyed-singleton owner, when one exists. This is a live Yoi
|
||||||
|
/// projection, not state duplicated in the subjektiv store.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_worker: Option<WorkerLaunchWorkerSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSubjectWorkerStartRequest {
|
||||||
|
pub worker: CreateWorkspaceWorkerRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivStageCandidateRequest {
|
||||||
|
/// Committed parent Session identity captured by the Worker host.
|
||||||
|
pub session_id: String,
|
||||||
|
pub operation: memory::backend::MemoryStageCandidateOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivStageCandidateResponse {
|
||||||
|
pub staging_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivRecordSessionRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivRecordSessionResponse {
|
||||||
|
pub subject_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||||
@@ -11521,6 +11671,8 @@ mod openapi_artifact_tests {
|
|||||||
"worker_control_stop",
|
"worker_control_stop",
|
||||||
"worker_observation_capture",
|
"worker_observation_capture",
|
||||||
"worker_observation_sessions",
|
"worker_observation_sessions",
|
||||||
|
"subjektiv_stage_candidate",
|
||||||
|
"subjektiv_record_session",
|
||||||
"workspace_worker_discovery",
|
"workspace_worker_discovery",
|
||||||
"workspace_worker_remove",
|
"workspace_worker_remove",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -960,16 +960,30 @@ fn bind_workspace_memory_settings(
|
|||||||
snapshot.workspace_id, workspace_api.workspace_id
|
snapshot.workspace_id, workspace_api.workspace_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if manifest.feature.memory.profile.enabled {
|
||||||
manifest
|
manifest
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.bind_workspace_settings(snapshot.clone())
|
.bind_workspace_settings(snapshot.clone())
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
|
}
|
||||||
|
if manifest.feature.subjektiv.profile.enabled {
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.subjektiv
|
||||||
|
.bind_workspace_settings(snapshot.clone())
|
||||||
|
.map_err(str::to_string)?;
|
||||||
|
}
|
||||||
manifest
|
manifest
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
|
manifest
|
||||||
|
.feature
|
||||||
|
.subjektiv
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(str::to_string)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,25 +999,36 @@ fn validate_worker_memory_settings(
|
|||||||
.memory
|
.memory
|
||||||
.validate_execution()
|
.validate_execution()
|
||||||
.map_err(str::to_string)?;
|
.map_err(str::to_string)?;
|
||||||
if !manifest.feature.memory.profile.enabled {
|
manifest
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let actual = manifest
|
|
||||||
.feature
|
.feature
|
||||||
.memory
|
.subjektiv
|
||||||
.workspace_settings()
|
.validate_execution()
|
||||||
.ok_or_else(|| {
|
.map_err(str::to_string)?;
|
||||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
for (feature, enabled, actual) in [
|
||||||
|
(
|
||||||
|
"Memory",
|
||||||
|
manifest.feature.memory.profile.enabled,
|
||||||
|
manifest.feature.memory.workspace_settings(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subjektiv",
|
||||||
|
manifest.feature.subjektiv.profile.enabled,
|
||||||
|
manifest.feature.subjektiv.workspace_settings(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if enabled && actual.as_ref() != Some(expected) {
|
||||||
|
let actual = actual.ok_or_else(|| {
|
||||||
|
format!("Workspace Worker restored without its bound {feature} settings snapshot")
|
||||||
})?;
|
})?;
|
||||||
if &actual != expected {
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
"Workspace Worker {feature} settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||||
expected.workspace_id,
|
expected.workspace_id,
|
||||||
expected.settings_revision,
|
expected.settings_revision,
|
||||||
actual.workspace_id,
|
actual.workspace_id,
|
||||||
actual.settings_revision
|
actual.settings_revision
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -564,6 +564,18 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
|
||||||
|
generated_workspace_response(
|
||||||
|
client
|
||||||
|
.subjektiv_stage_candidate(workspace_id, request)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||||
|
generated_workspace_response(
|
||||||
|
client.subjektiv_record_session(workspace_id, request).await,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1242,6 +1242,18 @@ where
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||||
|
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||||
|
if memory_profile.enabled
|
||||||
|
&& memory_profile.extraction.enabled
|
||||||
|
&& subjektiv_profile.enabled
|
||||||
|
&& subjektiv_profile.extraction.enabled
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"Workspace Memory and subjektiv automatic extraction cannot both be enabled for one Worker",
|
||||||
|
));
|
||||||
|
}
|
||||||
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
|
||||||
worker.manifest(),
|
worker.manifest(),
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
@@ -1276,6 +1288,22 @@ where
|
|||||||
{
|
{
|
||||||
feature_registry.add_module(memory_lifecycle);
|
feature_registry.add_module(memory_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(),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
feature_registry.add_module(subjektiv_lifecycle);
|
||||||
|
}
|
||||||
if sub_worker_enabled && !worker_enabled {
|
if sub_worker_enabled && !worker_enabled {
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
|
|||||||
@@ -39,8 +39,16 @@ use manifest::WorkerManifest;
|
|||||||
use protocol::Event;
|
use protocol::Event;
|
||||||
|
|
||||||
const TASK_NAME: &str = "memory-lifecycle";
|
const TASK_NAME: &str = "memory-lifecycle";
|
||||||
|
const SUBJEKTIV_TASK_NAME: &str = "subjektiv-memory-lifecycle";
|
||||||
|
const SUBJEKTIV_EXTRACT_DOMAIN: &str = "subjektiv.extract.v1";
|
||||||
const TASK_TIMEOUT: Duration = Duration::from_secs(300);
|
const TASK_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ExtractionTarget {
|
||||||
|
WorkspaceMemory,
|
||||||
|
Subjektiv,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parent-Worker lifecycle Feature that observes committed runs and schedules
|
/// Parent-Worker lifecycle Feature that observes committed runs and schedules
|
||||||
/// bounded extraction work. It owns the Memory pointer, audit, restricted
|
/// bounded extraction work. It owns the Memory pointer, audit, restricted
|
||||||
/// Internal Worker, and staging disposition; Worker core owns only generic
|
/// Internal Worker, and staging disposition; Worker core owns only generic
|
||||||
@@ -50,9 +58,17 @@ pub(crate) struct MemoryLifecycleFeature {
|
|||||||
task: MemoryLifecycleTask,
|
task: MemoryLifecycleTask,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subject-scoped extraction is a distinct Feature module even though it reuses
|
||||||
|
/// the mature committed-capture/extract/pointer lifecycle implementation.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct SubjektivLifecycleFeature {
|
||||||
|
task: MemoryLifecycleTask,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MemoryLifecycleTask {
|
struct MemoryLifecycleTask {
|
||||||
config: manifest::ResolvedMemoryFeatureConfig,
|
config: manifest::ResolvedMemoryFeatureConfig,
|
||||||
|
target: ExtractionTarget,
|
||||||
capture: CommittedSessionCaptureHandle,
|
capture: CommittedSessionCaptureHandle,
|
||||||
extensions: SessionExtensionHandle,
|
extensions: SessionExtensionHandle,
|
||||||
workspace_client: Arc<dyn WorkspaceClient>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
@@ -120,6 +136,7 @@ impl MemoryLifecycleFeature {
|
|||||||
Self {
|
Self {
|
||||||
task: MemoryLifecycleTask {
|
task: MemoryLifecycleTask {
|
||||||
config,
|
config,
|
||||||
|
target: ExtractionTarget::WorkspaceMemory,
|
||||||
capture,
|
capture,
|
||||||
extensions,
|
extensions,
|
||||||
workspace_client,
|
workspace_client,
|
||||||
@@ -133,6 +150,63 @@ impl MemoryLifecycleFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SubjektivLifecycleFeature {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn from_resolved_config(
|
||||||
|
lifecycle_enabled: bool,
|
||||||
|
config: manifest::ResolvedSubjektivFeatureConfig,
|
||||||
|
capture: CommittedSessionCaptureHandle,
|
||||||
|
extensions: SessionExtensionHandle,
|
||||||
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
|
manifest: WorkerManifest,
|
||||||
|
client: Box<dyn LlmClient>,
|
||||||
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
|
workspace_context: WorkerWorkspaceContext,
|
||||||
|
event_tx: Option<broadcast::Sender<Event>>,
|
||||||
|
) -> std::io::Result<Option<Self>> {
|
||||||
|
if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
.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,
|
||||||
|
"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,
|
||||||
|
target: ExtractionTarget::Subjektiv,
|
||||||
|
capture,
|
||||||
|
extensions,
|
||||||
|
workspace_client,
|
||||||
|
manifest,
|
||||||
|
client,
|
||||||
|
prompts,
|
||||||
|
workspace_context,
|
||||||
|
event_tx,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FeatureModule for MemoryLifecycleFeature {
|
impl FeatureModule for MemoryLifecycleFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
FeatureDescriptor::builtin("memory-lifecycle", "Memory Lifecycle")
|
FeatureDescriptor::builtin("memory-lifecycle", "Memory Lifecycle")
|
||||||
@@ -152,17 +226,54 @@ impl FeatureModule for MemoryLifecycleFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for SubjektivLifecycleFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
FeatureDescriptor::builtin("subjektiv-memory-lifecycle", "subjektiv Memory Lifecycle")
|
||||||
|
.with_description(
|
||||||
|
"Observes terminal committed parent runs and stages bounded Memory candidates for the host-connected subject.",
|
||||||
|
)
|
||||||
|
.with_background_task(BackgroundTaskDeclaration::worker_managed(
|
||||||
|
SUBJEKTIV_TASK_NAME,
|
||||||
|
"Extract provenance-preserving candidates into subject-scoped staging after committed runs.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
context
|
||||||
|
.background_tasks()
|
||||||
|
.register(subjektiv_lifecycle_task_spec(), self.task.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn memory_lifecycle_task_spec() -> BackgroundTaskSpec {
|
fn memory_lifecycle_task_spec() -> BackgroundTaskSpec {
|
||||||
let declaration = BackgroundTaskDeclaration::worker_managed(
|
lifecycle_task_spec(
|
||||||
TASK_NAME,
|
TASK_NAME,
|
||||||
"Extract provenance-preserving Memory candidates and request Backend consolidation after committed runs.",
|
"Extract provenance-preserving Memory candidates and request Backend consolidation after committed runs.",
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjektiv_lifecycle_task_spec() -> BackgroundTaskSpec {
|
||||||
|
lifecycle_task_spec(
|
||||||
|
SUBJEKTIV_TASK_NAME,
|
||||||
|
"Extract provenance-preserving candidates into subject-scoped staging after committed runs.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifecycle_task_spec(name: &'static str, description: &'static str) -> BackgroundTaskSpec {
|
||||||
|
let declaration = BackgroundTaskDeclaration::worker_managed(name, description);
|
||||||
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
|
let mut spec = BackgroundTaskSpec::single_flight(declaration, TASK_TIMEOUT);
|
||||||
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
spec.trigger = BackgroundTaskTrigger::RunCommitted;
|
||||||
spec
|
spec
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryLifecycleTask {
|
impl MemoryLifecycleTask {
|
||||||
|
fn pointer_domain(&self) -> &'static str {
|
||||||
|
match self.target {
|
||||||
|
ExtractionTarget::WorkspaceMemory => extract::EXTRACT_DOMAIN,
|
||||||
|
ExtractionTarget::Subjektiv => SUBJEKTIV_EXTRACT_DOMAIN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn extraction_manifest(&self) -> WorkerManifest {
|
fn extraction_manifest(&self) -> WorkerManifest {
|
||||||
let mut manifest = self.manifest.clone();
|
let mut manifest = self.manifest.clone();
|
||||||
if let Some(model) = self.config.profile.extraction.model.clone() {
|
if let Some(model) = self.config.profile.extraction.model.clone() {
|
||||||
@@ -192,6 +303,11 @@ impl MemoryLifecycleTask {
|
|||||||
.map(model_audit_from_manifest),
|
.map(model_audit_from_manifest),
|
||||||
)
|
)
|
||||||
.with_memory_settings(&self.config);
|
.with_memory_settings(&self.config);
|
||||||
|
let audit = if self.target == ExtractionTarget::Subjektiv {
|
||||||
|
audit.without_legacy_backend()
|
||||||
|
} else {
|
||||||
|
audit
|
||||||
|
};
|
||||||
let capture = match self.capture.capture() {
|
let capture = match self.capture.capture() {
|
||||||
Ok(capture) => capture,
|
Ok(capture) => capture,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -223,7 +339,7 @@ impl MemoryLifecycleTask {
|
|||||||
.await;
|
.await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let pointer = match extract_pointer(&capture) {
|
let pointer = match extract_pointer(&capture, self.pointer_domain()) {
|
||||||
Ok(pointer) => pointer,
|
Ok(pointer) => pointer,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
audit
|
audit
|
||||||
@@ -333,12 +449,21 @@ impl MemoryLifecycleTask {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let output_state = MemoryStagingOutputState::new(
|
let output_state = match self.target {
|
||||||
|
ExtractionTarget::WorkspaceMemory => MemoryStagingOutputState::new(
|
||||||
view.clone(),
|
view.clone(),
|
||||||
Arc::clone(&self.workspace_client),
|
Arc::clone(&self.workspace_client),
|
||||||
source,
|
source,
|
||||||
audit.run_id.to_string(),
|
audit.run_id.to_string(),
|
||||||
);
|
),
|
||||||
|
ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv(
|
||||||
|
view.clone(),
|
||||||
|
Arc::clone(&self.workspace_client),
|
||||||
|
source,
|
||||||
|
audit.run_id.to_string(),
|
||||||
|
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.profile.extraction.model.as_ref() {
|
||||||
match crate::model_client::build_client(model) {
|
match crate::model_client::build_client(model) {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
@@ -485,6 +610,23 @@ impl MemoryLifecycleTask {
|
|||||||
ExtractionDisposition::Completed => {}
|
ExtractionDisposition::Completed => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.target == ExtractionTarget::Subjektiv
|
||||||
|
&& let Err(reason) = self.record_subjektiv_session(&capture.session_id)
|
||||||
|
{
|
||||||
|
audit
|
||||||
|
.emit(
|
||||||
|
self.workspace_client.as_ref(),
|
||||||
|
self.event_tx.as_ref(),
|
||||||
|
memory::audit::WorkerLifecycleStatus::Failed,
|
||||||
|
reason,
|
||||||
|
usage_audit,
|
||||||
|
extract_audit,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
context.generation_fence.ensure_current()?;
|
context.generation_fence.ensure_current()?;
|
||||||
let next_pointer = memory::ExtractPointerPayload {
|
let next_pointer = memory::ExtractPointerPayload {
|
||||||
processed_through_entry: capture.entry_count - 1,
|
processed_through_entry: capture.entry_count - 1,
|
||||||
@@ -494,7 +636,7 @@ impl MemoryLifecycleTask {
|
|||||||
let payload = serde_json::to_value(&next_pointer).map_err(hook_internal)?;
|
let payload = serde_json::to_value(&next_pointer).map_err(hook_internal)?;
|
||||||
if !self
|
if !self
|
||||||
.extensions
|
.extensions
|
||||||
.append_if_current(&capture.location(), extract::EXTRACT_DOMAIN, payload)
|
.append_if_current(&capture.location(), self.pointer_domain(), payload)
|
||||||
.map_err(hook_internal)?
|
.map_err(hook_internal)?
|
||||||
{
|
{
|
||||||
audit
|
audit
|
||||||
@@ -549,6 +691,35 @@ impl FeatureBackgroundTask for MemoryLifecycleTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryLifecycleTask {
|
impl MemoryLifecycleTask {
|
||||||
|
fn record_subjektiv_session(&self, session_id: &str) -> Result<(), String> {
|
||||||
|
let response = self
|
||||||
|
.workspace_client
|
||||||
|
.execute_server_operation(
|
||||||
|
crate::worker::WorkspaceServerOperation::SubjektivRecordSession(
|
||||||
|
server_api::SubjektivRecordSessionRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("record subjektiv Session attribution failed: {error}"))?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"record subjektiv Session attribution returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let output: server_api::SubjektivRecordSessionResponse =
|
||||||
|
serde_json::from_str(&response.body).map_err(|error| {
|
||||||
|
format!("decode subjektiv Session attribution response: {error}")
|
||||||
|
})?;
|
||||||
|
if output.session_id != session_id {
|
||||||
|
return Err(
|
||||||
|
"subjektiv Session attribution response changed session identity".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn request_consolidation(&self) {
|
async fn request_consolidation(&self) {
|
||||||
let audit = WorkerAuditBase::new(
|
let audit = WorkerAuditBase::new(
|
||||||
memory::audit::AuditWorker::MemoryConsolidation,
|
memory::audit::AuditWorker::MemoryConsolidation,
|
||||||
@@ -668,13 +839,19 @@ fn hook_internal(error: impl std::fmt::Display) -> HookError {
|
|||||||
|
|
||||||
fn extract_pointer(
|
fn extract_pointer(
|
||||||
capture: &CommittedSessionCapture,
|
capture: &CommittedSessionCapture,
|
||||||
|
domain: &str,
|
||||||
) -> Result<Option<memory::ExtractPointerPayload>, HookError> {
|
) -> Result<Option<memory::ExtractPointerPayload>, HookError> {
|
||||||
let pointer = memory::extract::fold_pointer(&capture.extensions);
|
let pointer = capture
|
||||||
|
.extensions
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(candidate, _)| candidate == domain)
|
||||||
|
.and_then(|(_, payload)| serde_json::from_value(payload.clone()).ok());
|
||||||
if pointer.is_none()
|
if pointer.is_none()
|
||||||
&& capture
|
&& capture
|
||||||
.extensions
|
.extensions
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(domain, _)| domain == extract::EXTRACT_DOMAIN)
|
.any(|(candidate, _)| candidate == domain)
|
||||||
{
|
{
|
||||||
return Err(hook_internal(
|
return Err(hook_internal(
|
||||||
"latest committed Memory extraction pointer is malformed",
|
"latest committed Memory extraction pointer is malformed",
|
||||||
@@ -732,6 +909,7 @@ struct WorkerAuditBase {
|
|||||||
trigger: memory::audit::AuditTrigger,
|
trigger: memory::audit::AuditTrigger,
|
||||||
memory_settings: Option<memory::audit::MemorySettingsAudit>,
|
memory_settings: Option<memory::audit::MemorySettingsAudit>,
|
||||||
model: Option<memory::audit::ModelAudit>,
|
model: Option<memory::audit::ModelAudit>,
|
||||||
|
persist_legacy_backend: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerAuditBase {
|
impl WorkerAuditBase {
|
||||||
@@ -746,9 +924,15 @@ impl WorkerAuditBase {
|
|||||||
trigger,
|
trigger,
|
||||||
memory_settings: None,
|
memory_settings: None,
|
||||||
model,
|
model,
|
||||||
|
persist_legacy_backend: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn without_legacy_backend(mut self) -> Self {
|
||||||
|
self.persist_legacy_backend = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self {
|
fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self {
|
||||||
self.memory_settings =
|
self.memory_settings =
|
||||||
config
|
config
|
||||||
@@ -784,15 +968,19 @@ impl WorkerAuditBase {
|
|||||||
extract,
|
extract,
|
||||||
consolidation,
|
consolidation,
|
||||||
};
|
};
|
||||||
|
if self.persist_legacy_backend {
|
||||||
let _ = workspace_client
|
let _ = workspace_client
|
||||||
.execute_memory_backend_operation(memory::backend::MemoryBackendOperation::AppendAudit(
|
.execute_memory_backend_operation(
|
||||||
|
memory::backend::MemoryBackendOperation::AppendAudit(
|
||||||
memory::backend::MemoryAppendAuditOperation {
|
memory::backend::MemoryAppendAuditOperation {
|
||||||
event: memory::audit::AuditEvent::new(
|
event: memory::audit::AuditEvent::new(
|
||||||
memory::audit::AuditPayload::WorkerLifecycle(payload),
|
memory::audit::AuditPayload::WorkerLifecycle(payload),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
))
|
),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
if let Some(tx) = event_tx {
|
if let Some(tx) = event_tx {
|
||||||
let _ = tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
let _ = tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
||||||
worker: self.worker.label().to_string(),
|
worker: self.worker.label().to_string(),
|
||||||
@@ -859,6 +1047,8 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
request: crate::worker::WorkspaceRequest,
|
request: crate::worker::WorkspaceRequest,
|
||||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||||
|
let is_subjektiv_stage = request.path.ends_with("/subjektiv/staging");
|
||||||
|
let is_subjektiv_session = request.path.ends_with("/subjektiv/sessions");
|
||||||
let is_stage_candidate = request
|
let is_stage_candidate = request
|
||||||
.body
|
.body
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -868,6 +1058,25 @@ mod tests {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|body| body.contains("append_audit"));
|
.is_some_and(|body| body.contains("append_audit"));
|
||||||
self.requests.lock().unwrap().push(request);
|
self.requests.lock().unwrap().push(request);
|
||||||
|
if is_subjektiv_session {
|
||||||
|
return Ok(crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&server_api::SubjektivRecordSessionResponse {
|
||||||
|
subject_id: "subject-1".to_string(),
|
||||||
|
session_id: "session-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if is_subjektiv_stage {
|
||||||
|
return Ok(crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
|
||||||
|
staging_id: "subject-candidate-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
});
|
||||||
|
}
|
||||||
if is_stage_candidate {
|
if is_stage_candidate {
|
||||||
return Ok(crate::worker::WorkspaceResponse {
|
return Ok(crate::worker::WorkspaceResponse {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -1072,6 +1281,7 @@ permission = "write"
|
|||||||
});
|
});
|
||||||
MemoryLifecycleTask {
|
MemoryLifecycleTask {
|
||||||
config: test_config(),
|
config: test_config(),
|
||||||
|
target: ExtractionTarget::WorkspaceMemory,
|
||||||
capture: capture_handle,
|
capture: capture_handle,
|
||||||
extensions,
|
extensions,
|
||||||
workspace_client,
|
workspace_client,
|
||||||
@@ -1122,6 +1332,30 @@ permission = "write"
|
|||||||
registry.shutdown().await.unwrap();
|
registry.shutdown().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_subjektiv_feature_installs_nothing_without_workspace_side_effects() {
|
||||||
|
let capture = CommittedSessionCaptureHandle::new(|| {
|
||||||
|
panic!("disabled subjektiv must not capture a Session")
|
||||||
|
});
|
||||||
|
let extensions = SessionExtensionHandle::new(|_, _, _| {
|
||||||
|
panic!("disabled subjektiv must not write a pointer")
|
||||||
|
});
|
||||||
|
let feature = SubjektivLifecycleFeature::from_resolved_config(
|
||||||
|
true,
|
||||||
|
manifest::ResolvedSubjektivFeatureConfig::default(),
|
||||||
|
capture,
|
||||||
|
extensions,
|
||||||
|
crate::worker::marker_workspace_client(None, "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]
|
#[test]
|
||||||
fn extraction_manifest_applies_memory_model_and_reasoning_overrides() {
|
fn extraction_manifest_applies_memory_model_and_reasoning_overrides() {
|
||||||
use agen::llm_client::capability::{ReasoningControl, ReasoningEffort};
|
use agen::llm_client::capability::{ReasoningControl, ReasoningEffort};
|
||||||
@@ -1236,6 +1470,136 @@ permission = "write"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subjektiv_extraction_stages_once_and_commits_its_independent_pointer() {
|
||||||
|
let source = capture(2, 250);
|
||||||
|
let entry_ref =
|
||||||
|
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
|
||||||
|
.overview()[0]
|
||||||
|
.id
|
||||||
|
.to_string();
|
||||||
|
let client = ScriptClient::new(vec![
|
||||||
|
stage_candidate_events("stage-1", &entry_ref),
|
||||||
|
finish_events("finish-1", 1),
|
||||||
|
completed_events(),
|
||||||
|
]);
|
||||||
|
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (event_tx, _) = broadcast::channel(64);
|
||||||
|
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let mut task = test_task(
|
||||||
|
source,
|
||||||
|
Box::new(client),
|
||||||
|
Arc::clone(&extension_writes),
|
||||||
|
event_tx,
|
||||||
|
workspace_client.clone(),
|
||||||
|
);
|
||||||
|
task.target = ExtractionTarget::Subjektiv;
|
||||||
|
task.config.profile.consolidation.request_enabled = false;
|
||||||
|
run_background_task(task).await;
|
||||||
|
|
||||||
|
let writes = extension_writes.lock().unwrap();
|
||||||
|
assert_eq!(writes.len(), 1);
|
||||||
|
assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN);
|
||||||
|
let pointer: memory::ExtractPointerPayload =
|
||||||
|
serde_json::from_value(writes[0].2.clone()).unwrap();
|
||||||
|
assert_eq!(pointer.staging_id, "subject-candidate-1");
|
||||||
|
let requests = workspace_client.requests.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.path.ends_with("/subjektiv/staging"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.path.ends_with("/subjektiv/sessions"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(requests.iter().all(|request| {
|
||||||
|
request
|
||||||
|
.body
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|body| !body.contains("append_audit") && body != "{\"force\":false}")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_subjektiv_extraction_records_session_and_commits_pointer_without_candidate() {
|
||||||
|
let client = ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]);
|
||||||
|
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (event_tx, _) = broadcast::channel(32);
|
||||||
|
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let mut task = test_task(
|
||||||
|
capture(2, 250),
|
||||||
|
Box::new(client),
|
||||||
|
Arc::clone(&extension_writes),
|
||||||
|
event_tx,
|
||||||
|
workspace_client.clone(),
|
||||||
|
);
|
||||||
|
task.target = ExtractionTarget::Subjektiv;
|
||||||
|
task.config.profile.consolidation.request_enabled = false;
|
||||||
|
run_background_task(task).await;
|
||||||
|
|
||||||
|
let writes = extension_writes.lock().unwrap();
|
||||||
|
assert_eq!(writes.len(), 1);
|
||||||
|
assert_eq!(writes[0].1, SUBJEKTIV_EXTRACT_DOMAIN);
|
||||||
|
let pointer: memory::ExtractPointerPayload =
|
||||||
|
serde_json::from_value(writes[0].2.clone()).unwrap();
|
||||||
|
assert!(pointer.staging_id.is_empty());
|
||||||
|
let requests = workspace_client.requests.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!requests
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.path.ends_with("/subjektiv/staging"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.path.ends_with("/subjektiv/sessions"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subjektiv_candidate_remains_staged_when_later_extraction_fails_without_pointer() {
|
||||||
|
let source = capture(2, 250);
|
||||||
|
let entry_ref =
|
||||||
|
SessionCapture::from_history_entries(source.segment_id.clone(), source.history.clone())
|
||||||
|
.overview()[0]
|
||||||
|
.id
|
||||||
|
.to_string();
|
||||||
|
let client = ScriptClient::new(vec![stage_candidate_events("stage-1", &entry_ref)]);
|
||||||
|
let extension_writes = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (event_tx, _) = broadcast::channel(32);
|
||||||
|
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let mut task = test_task(
|
||||||
|
source,
|
||||||
|
Box::new(client),
|
||||||
|
Arc::clone(&extension_writes),
|
||||||
|
event_tx,
|
||||||
|
workspace_client.clone(),
|
||||||
|
);
|
||||||
|
task.target = ExtractionTarget::Subjektiv;
|
||||||
|
task.config.profile.consolidation.request_enabled = false;
|
||||||
|
run_background_task(task).await;
|
||||||
|
|
||||||
|
assert!(extension_writes.lock().unwrap().is_empty());
|
||||||
|
let requests = workspace_client.requests.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.path.ends_with("/subjektiv/staging"))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"the immutable partial candidate remains staged for retry/deduplication"
|
||||||
|
);
|
||||||
|
assert!(!requests
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.path.ends_with("/subjektiv/sessions")));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn failed_extraction_emits_failure_event_and_durable_audit_without_pointer() {
|
async fn failed_extraction_emits_failure_event_and_durable_audit_without_pointer() {
|
||||||
let client = ScriptClient::new(Vec::new());
|
let client = ScriptClient::new(Vec::new());
|
||||||
@@ -1562,7 +1926,10 @@ permission = "write"
|
|||||||
serde_json::to_value(&latest).unwrap(),
|
serde_json::to_value(&latest).unwrap(),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
assert_eq!(extract_pointer(&capture).unwrap(), Some(latest));
|
assert_eq!(
|
||||||
|
extract_pointer(&capture, extract::EXTRACT_DOMAIN).unwrap(),
|
||||||
|
Some(latest)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1583,7 +1950,7 @@ permission = "write"
|
|||||||
serde_json::json!({"invalid": true}),
|
serde_json::json!({"invalid": true}),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
assert!(extract_pointer(&capture).is_err());
|
assert!(extract_pointer(&capture, extract::EXTRACT_DOMAIN).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -33,10 +33,17 @@ pub(crate) struct MemoryStagingOutputState {
|
|||||||
workspace_client: Arc<dyn WorkspaceClient>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
source: SourceRef,
|
source: SourceRef,
|
||||||
extract_run_id: String,
|
extract_run_id: String,
|
||||||
|
destination: MemoryStagingDestination,
|
||||||
staged: Arc<Mutex<Vec<String>>>,
|
staged: Arc<Mutex<Vec<String>>>,
|
||||||
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum MemoryStagingDestination {
|
||||||
|
WorkspaceMemory,
|
||||||
|
Subjektiv { session_id: String },
|
||||||
|
}
|
||||||
|
|
||||||
impl MemoryStagingOutputState {
|
impl MemoryStagingOutputState {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
view: SessionCapture,
|
view: SessionCapture,
|
||||||
@@ -49,6 +56,25 @@ impl MemoryStagingOutputState {
|
|||||||
workspace_client,
|
workspace_client,
|
||||||
source,
|
source,
|
||||||
extract_run_id,
|
extract_run_id,
|
||||||
|
destination: MemoryStagingDestination::WorkspaceMemory,
|
||||||
|
staged: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
finished: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_subjektiv(
|
||||||
|
view: SessionCapture,
|
||||||
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
|
source: SourceRef,
|
||||||
|
extract_run_id: String,
|
||||||
|
session_id: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
view: Arc::new(view),
|
||||||
|
workspace_client,
|
||||||
|
source,
|
||||||
|
extract_run_id,
|
||||||
|
destination: MemoryStagingDestination::Subjektiv { session_id },
|
||||||
staged: Arc::new(Mutex::new(Vec::new())),
|
staged: Arc::new(Mutex::new(Vec::new())),
|
||||||
finished: Arc::new(Mutex::new(None)),
|
finished: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -201,22 +227,27 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
staleness: params.staleness,
|
staleness: params.staleness,
|
||||||
evidence_ids: params.entry_refs,
|
evidence_ids: params.entry_refs,
|
||||||
};
|
};
|
||||||
let result = self
|
let operation = MemoryStageCandidateOperation {
|
||||||
.state
|
|
||||||
.workspace_client
|
|
||||||
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
|
||||||
MemoryStageCandidateOperation {
|
|
||||||
source: self.state.source.clone(),
|
source: self.state.source.clone(),
|
||||||
extract_run_id: self.state.extract_run_id.clone(),
|
extract_run_id: self.state.extract_run_id.clone(),
|
||||||
candidate,
|
candidate,
|
||||||
evidence,
|
evidence,
|
||||||
source_refs,
|
source_refs,
|
||||||
},
|
};
|
||||||
|
let staging_ids = match &self.state.destination {
|
||||||
|
MemoryStagingDestination::WorkspaceMemory => {
|
||||||
|
let result = self
|
||||||
|
.state
|
||||||
|
.workspace_client
|
||||||
|
.execute_memory_backend_operation(MemoryBackendOperation::StageCandidate(
|
||||||
|
operation,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(map_memory_stage_error)?;
|
.map_err(map_memory_stage_error)?;
|
||||||
let staging_ids = match result {
|
match result {
|
||||||
MemoryBackendOperationResult::StagingWritten(output) if output.staging_count == 1 => {
|
MemoryBackendOperationResult::StagingWritten(output)
|
||||||
|
if output.staging_count == 1 =>
|
||||||
|
{
|
||||||
output.staging_ids
|
output.staging_ids
|
||||||
}
|
}
|
||||||
MemoryBackendOperationResult::StagingWritten(output) => {
|
MemoryBackendOperationResult::StagingWritten(output) => {
|
||||||
@@ -230,6 +261,39 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
"unexpected Memory backend result for StageMemoryCandidate: {other:?}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MemoryStagingDestination::Subjektiv { session_id } => {
|
||||||
|
let response = self
|
||||||
|
.state
|
||||||
|
.workspace_client
|
||||||
|
.execute_server_operation(
|
||||||
|
crate::worker::WorkspaceServerOperation::SubjektivStageCandidate(
|
||||||
|
server_api::SubjektivStageCandidateRequest {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
operation,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"write subjektiv staging failed: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"write subjektiv staging returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let output: server_api::SubjektivStageCandidateResponse =
|
||||||
|
serde_json::from_str(&response.body).map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"decode subjektiv staging response: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
vec![output.staging_id]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
|
let staging_id = staging_ids.into_iter().next().ok_or_else(|| {
|
||||||
ToolError::ExecutionFailed(
|
ToolError::ExecutionFailed(
|
||||||
@@ -425,10 +489,45 @@ fn truncate_line(text: &str, max_chars: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use agen::Item;
|
use agen::Item;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct RecordingSubjektivClient {
|
||||||
|
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for RecordingSubjektivClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"subjektiv-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: crate::worker::WorkspaceRequest,
|
||||||
|
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
Ok(crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&server_api::SubjektivStageCandidateResponse {
|
||||||
|
staging_id: "subject-candidate-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn state() -> MemoryStagingOutputState {
|
fn state() -> MemoryStagingOutputState {
|
||||||
MemoryStagingOutputState::new(
|
MemoryStagingOutputState::new(
|
||||||
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||||
@@ -503,6 +602,39 @@ mod tests {
|
|||||||
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
|
||||||
|
let client = Arc::new(RecordingSubjektivClient::default());
|
||||||
|
let state = MemoryStagingOutputState::new_subjektiv(
|
||||||
|
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
|
||||||
|
client.clone(),
|
||||||
|
SourceRef {
|
||||||
|
segment_id: "segment-1".to_string(),
|
||||||
|
range: [0, 0],
|
||||||
|
},
|
||||||
|
"run-1".to_string(),
|
||||||
|
"session-committed".to_string(),
|
||||||
|
);
|
||||||
|
let tool = StageMemoryCandidateTool {
|
||||||
|
state: state.clone(),
|
||||||
|
};
|
||||||
|
tool.execute(
|
||||||
|
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
|
||||||
|
agen::tool::ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.staged(), vec!["subject-candidate-1"]);
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert!(requests[0].path.ends_with("/subjektiv/staging"));
|
||||||
|
let body = requests[0].body.as_deref().unwrap();
|
||||||
|
assert!(body.contains("session-committed"));
|
||||||
|
assert!(body.contains("why_useful"));
|
||||||
|
assert!(!body.contains("subject_id"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
|
||||||
let tool = StageMemoryCandidateTool { state: state() };
|
let tool = StageMemoryCandidateTool { state: state() };
|
||||||
|
|||||||
@@ -624,6 +624,8 @@ pub enum WorkspaceServerOperation {
|
|||||||
},
|
},
|
||||||
WorkerObservationSessions,
|
WorkerObservationSessions,
|
||||||
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
||||||
|
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
||||||
|
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_server_json_request<T: serde::Serialize>(
|
fn workspace_server_json_request<T: serde::Serialize>(
|
||||||
@@ -702,6 +704,12 @@ fn workspace_server_operation_request(
|
|||||||
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
WorkspaceServerOperation::WorkerObservationCapture(request) => {
|
||||||
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
|
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
|
||||||
}
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivStageCandidate(request) => {
|
||||||
|
workspace_server_json_request(format!("{base}/subjektiv/staging"), &request)
|
||||||
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||||
|
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1388,6 +1388,7 @@ pub struct WorkspaceApi {
|
|||||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||||
feature_storage: crate::WorkspaceFeatureStorage,
|
feature_storage: crate::WorkspaceFeatureStorage,
|
||||||
|
subjektiv_registration: crate::RegisteredFeature,
|
||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
_worker_projection_shutdown: Arc<WorkerProjectionShutdownGuard>,
|
_worker_projection_shutdown: Arc<WorkerProjectionShutdownGuard>,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
@@ -3215,6 +3216,10 @@ impl WorkspaceApi {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
Error::Store(format!("failed to initialize Feature storage: {error}"))
|
Error::Store(format!("failed to initialize Feature storage: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let subjektiv_registration = crate::subjektiv::SubjektivStore::register(&feature_storage)
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::Store(format!("failed to register subjektiv storage: {error}"))
|
||||||
|
})?;
|
||||||
let api = Self {
|
let api = Self {
|
||||||
config_store,
|
config_store,
|
||||||
repository_secrets,
|
repository_secrets,
|
||||||
@@ -3223,6 +3228,7 @@ impl WorkspaceApi {
|
|||||||
prompt_projection_cache:
|
prompt_projection_cache:
|
||||||
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
||||||
feature_storage,
|
feature_storage,
|
||||||
|
subjektiv_registration,
|
||||||
authority: SqliteWorkspaceAuthority::new(
|
authority: SqliteWorkspaceAuthority::new(
|
||||||
config.database_path.clone(),
|
config.database_path.clone(),
|
||||||
config.workspace_id.clone(),
|
config.workspace_id.clone(),
|
||||||
@@ -5432,6 +5438,24 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::memory_consolidation(
|
.merge(server_api::server_api_axum::memory_consolidation(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_subject_create(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_subject_retire(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_subject_worker_start(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_stage_candidate(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::subjektiv_record_session(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
.merge(server_api::server_api_axum::skill_list(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_lint(service.clone()))
|
||||||
.merge(server_api::server_api_axum::skill_get(service.clone()))
|
.merge(server_api::server_api_axum::skill_get(service.clone()))
|
||||||
@@ -7006,6 +7030,119 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.map_err(ApiError::into_repository_api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn subjektiv_subject_create(
|
||||||
|
&self,
|
||||||
|
workspace_id: String,
|
||||||
|
request: server_api::SubjektivSubjectCreateRequest,
|
||||||
|
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||||
|
{
|
||||||
|
scoped_create_subjektiv_subject(
|
||||||
|
State(self.workspace_api()?.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|(_, Json(response))| response)
|
||||||
|
.map_err(ApiError::into_repository_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subjektiv_subject_get(
|
||||||
|
&self,
|
||||||
|
workspace_id: String,
|
||||||
|
subject_id: String,
|
||||||
|
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||||
|
{
|
||||||
|
scoped_get_subjektiv_subject(
|
||||||
|
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_subject_retire(
|
||||||
|
&self,
|
||||||
|
workspace_id: String,
|
||||||
|
subject_id: String,
|
||||||
|
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||||
|
{
|
||||||
|
scoped_retire_subjektiv_subject(
|
||||||
|
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_subject_worker_start(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
subject_id: String,
|
||||||
|
request: server_api::SubjektivSubjectWorkerStartRequest,
|
||||||
|
) -> std::result::Result<BrowserCreateWorkerResponse, server_api::RepositoryApiError> {
|
||||||
|
scoped_start_subjektiv_subject_worker(
|
||||||
|
State(self.workspace_api()?.clone()),
|
||||||
|
AxumPath(ScopedSubjektivSubjectPath {
|
||||||
|
workspace_id,
|
||||||
|
subject_id,
|
||||||
|
}),
|
||||||
|
contract_request_headers(&context)?,
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|Json(response)| response)
|
||||||
|
.map_err(ApiError::into_repository_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subjektiv_stage_candidate(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
request: server_api::SubjektivStageCandidateRequest,
|
||||||
|
) -> std::result::Result<
|
||||||
|
server_api::SubjektivStageCandidateResponse,
|
||||||
|
server_api::RepositoryApiError,
|
||||||
|
> {
|
||||||
|
scoped_stage_subjektiv_candidate(
|
||||||
|
State(self.workspace_api()?.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
|
context,
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|Json(response)| response)
|
||||||
|
.map_err(ApiError::into_repository_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subjektiv_record_session(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
request: server_api::SubjektivRecordSessionRequest,
|
||||||
|
) -> std::result::Result<
|
||||||
|
server_api::SubjektivRecordSessionResponse,
|
||||||
|
server_api::RepositoryApiError,
|
||||||
|
> {
|
||||||
|
scoped_record_subjektiv_session(
|
||||||
|
State(self.workspace_api()?.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
|
context,
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|Json(response)| response)
|
||||||
|
.map_err(ApiError::into_repository_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn skill_list(
|
async fn skill_list(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -10217,6 +10354,12 @@ struct ScopedWorkspacePath {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct ScopedSubjektivSubjectPath {
|
||||||
|
workspace_id: String,
|
||||||
|
subject_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScopedFlowPath {
|
struct ScopedFlowPath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -16352,6 +16495,255 @@ async fn scoped_memory_backend_operation(
|
|||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||||
|
|
||||||
|
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
|
||||||
|
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
|
||||||
|
.map_err(|error| ApiError::from(Error::Store(error.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjektiv_subject_id_from_singleton_key(key: &str) -> ApiResult<&str> {
|
||||||
|
key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX)
|
||||||
|
.filter(|subject_id| {
|
||||||
|
!subject_id.is_empty()
|
||||||
|
&& !subject_id.chars().any(char::is_control)
|
||||||
|
&& subject_id.trim() == *subject_id
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::WorkspacePermissionDenied(
|
||||||
|
"current Worker is not connected to a subjektiv subject".to_string(),
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjektiv_singleton_key(subject_id: &str) -> ApiResult<String> {
|
||||||
|
if subject_id.trim().is_empty() || subject_id.chars().any(char::is_control) {
|
||||||
|
return Err(Error::InvalidInput("subjektiv subject id is invalid".to_string()).into());
|
||||||
|
}
|
||||||
|
let key = format!("{SUBJEKTIV_SINGLETON_PREFIX}{subject_id}");
|
||||||
|
crate::store::validate_worker_singleton_key(&key)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjektiv_session_attribution(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
context: &server_api::ServerRequestContext,
|
||||||
|
session_id: &str,
|
||||||
|
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
|
||||||
|
let source = context.runtime_source.as_ref().ok_or_else(|| {
|
||||||
|
Error::WorkspacePermissionDenied(
|
||||||
|
"subjektiv extraction requires authenticated Runtime-owned source authority"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let worker_id = source.worker_id.as_deref().ok_or_else(|| {
|
||||||
|
Error::WorkspacePermissionDenied(
|
||||||
|
"subjektiv extraction requires Runtime-bound Worker identity".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id);
|
||||||
|
let lease = api
|
||||||
|
.store
|
||||||
|
.require_current_worker_singleton_owner(workspace_id, &worker)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::WorkspacePermissionDenied(
|
||||||
|
"current Worker has no keyed singleton ownership".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?;
|
||||||
|
let session_id = session_id.trim();
|
||||||
|
if session_id.is_empty() || session_id.chars().any(char::is_control) {
|
||||||
|
return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into());
|
||||||
|
}
|
||||||
|
crate::subjektiv::SubjectSessionAttribution::new(
|
||||||
|
subject_id,
|
||||||
|
worker.runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
.map_err(|error| Error::InvalidInput(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjektiv_subject_response(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
subject: crate::subjektiv::SubjectRecord,
|
||||||
|
) -> server_api::SubjektivSubjectResponse {
|
||||||
|
let current_worker = subjektiv_singleton_key(&subject.id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|key| {
|
||||||
|
api.store
|
||||||
|
.current_worker_singleton_owner(&api.config.workspace_id, &key)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
.and_then(|lease| api.runtime.worker(&lease.worker).ok())
|
||||||
|
.map(worker_launch_worker_summary);
|
||||||
|
server_api::SubjektivSubjectResponse {
|
||||||
|
id: subject.id,
|
||||||
|
role: subject.role.as_str().to_string(),
|
||||||
|
state: match subject.state {
|
||||||
|
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
|
||||||
|
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired,
|
||||||
|
},
|
||||||
|
store_revision: subject.store_revision,
|
||||||
|
created_at: subject.created_at,
|
||||||
|
updated_at: subject.updated_at,
|
||||||
|
current_worker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_create_subjektiv_subject(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<server_api::SubjektivSubjectCreateRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<server_api::SubjektivSubjectResponse>)> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let role = crate::subjektiv::SubjectRole::new(request.role)
|
||||||
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
|
let subject = open_subjektiv_store(&api)?
|
||||||
|
.create_subject(role)
|
||||||
|
.map_err(|error| Error::Store(error.to_string()))?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(subjektiv_subject_response(&api, subject)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_subjektiv_subject(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||||
|
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let subject = open_subjektiv_store(&api)?
|
||||||
|
.subject(&path.subject_id)
|
||||||
|
.map_err(|error| Error::Store(error.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id))
|
||||||
|
})?;
|
||||||
|
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_retire_subjektiv_subject(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||||
|
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
|
||||||
|
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()))?;
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_start_subjektiv_subject_worker(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<server_api::SubjektivSubjectWorkerStartRequest>,
|
||||||
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let subject = open_subjektiv_store(&api)?
|
||||||
|
.subject(&path.subject_id)
|
||||||
|
.map_err(|error| Error::Store(error.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!("unknown subjektiv subject `{}`", path.subject_id))
|
||||||
|
})?;
|
||||||
|
if subject.state != crate::subjektiv::SubjectState::Active {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"subjektiv subject `{}` is retired",
|
||||||
|
path.subject_id
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let mut worker = request.worker;
|
||||||
|
if worker.singleton_key.is_some() {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"subjektiv subject Worker start owns singleton selection; singleton_key must be omitted"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
worker.singleton_key = Some(subjektiv_singleton_key(&path.subject_id)?);
|
||||||
|
create_workspace_worker(State(api), headers, Json(worker)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_stage_subjektiv_candidate(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
Json(mut request): Json<server_api::SubjektivStageCandidateRequest>,
|
||||||
|
) -> ApiResult<Json<server_api::SubjektivStageCandidateResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let attribution =
|
||||||
|
subjektiv_session_attribution(&api, &path.workspace_id, &context, &request.session_id)?;
|
||||||
|
let subject_id = attribution.subject_id.clone();
|
||||||
|
let session_id = attribution.session_id.as_str();
|
||||||
|
for source_ref in &mut request.operation.source_refs {
|
||||||
|
if source_ref
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| value != session_id)
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"candidate source_ref belongs to a different Session".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
source_ref.session_id = Some(session_id.to_string());
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
request.operation.candidate.kind,
|
||||||
|
memory::extract::CandidateKind::Preference
|
||||||
|
) && request.operation.evidence.iter().any(|evidence| {
|
||||||
|
!matches!(
|
||||||
|
evidence.origin.as_ref().map(|origin| &origin.kind),
|
||||||
|
Some(memory::schema::EvidenceOriginKind::HumanInput)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"preference candidates require exclusively HumanInput evidence".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let candidate_id = Uuid::now_v7().to_string();
|
||||||
|
let record = memory::extract::StagingRecord::from_candidate(
|
||||||
|
candidate_id,
|
||||||
|
request.operation.extract_run_id,
|
||||||
|
request.operation.source,
|
||||||
|
request.operation.candidate,
|
||||||
|
request.operation.evidence,
|
||||||
|
request.operation.source_refs,
|
||||||
|
);
|
||||||
|
let record = crate::subjektiv::SubjectStagingRecord::attach(&subject_id, record);
|
||||||
|
let (staged, _) = open_subjektiv_store(&api)?
|
||||||
|
.stage_candidate_with_attribution(record, attribution)
|
||||||
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
|
Ok(Json(server_api::SubjektivStageCandidateResponse {
|
||||||
|
staging_id: staged.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_record_subjektiv_session(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
Json(request): Json<server_api::SubjektivRecordSessionRequest>,
|
||||||
|
) -> ApiResult<Json<server_api::SubjektivRecordSessionResponse>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let attribution =
|
||||||
|
subjektiv_session_attribution(&api, &path.workspace_id, &context, &request.session_id)?;
|
||||||
|
let attribution = open_subjektiv_store(&api)?
|
||||||
|
.record_session_attribution(attribution)
|
||||||
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
|
Ok(Json(server_api::SubjektivRecordSessionResponse {
|
||||||
|
subject_id: attribution.subject_id,
|
||||||
|
session_id: attribution.session_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||||
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||||
|
|
||||||
@@ -33523,6 +33915,120 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let subject = open_subjektiv_store(&api)
|
||||||
|
.unwrap()
|
||||||
|
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let request = server_api::SubjektivSubjectWorkerStartRequest {
|
||||||
|
worker: CreateWorkspaceWorkerRequest {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
display_name: "Subject Worker".to_string(),
|
||||||
|
singleton_key: None,
|
||||||
|
profile: Some("builtin:companion".to_string()),
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_submit: Vec::new(),
|
||||||
|
workdir_attachments: Vec::new(),
|
||||||
|
control_operation_id: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let Json(created) = scoped_start_subjektiv_subject_worker(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedSubjektivSubjectPath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
subject_id: subject.id.clone(),
|
||||||
|
}),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
created.worker.singleton_key.as_deref(),
|
||||||
|
Some(format!("subjektiv:{}", subject.id).as_str())
|
||||||
|
);
|
||||||
|
let context = server_api::ServerRequestContext {
|
||||||
|
actor: None,
|
||||||
|
worker_source: None,
|
||||||
|
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||||
|
runtime_id: created.runtime_id.clone(),
|
||||||
|
worker_id: Some(created.worker_id.clone()),
|
||||||
|
}),
|
||||||
|
origin: None,
|
||||||
|
transport_headers: Vec::new(),
|
||||||
|
};
|
||||||
|
let operation = memory::backend::MemoryStageCandidateOperation {
|
||||||
|
source: memory::schema::SourceRef {
|
||||||
|
segment_id: "segment-1".to_string(),
|
||||||
|
range: [0, 0],
|
||||||
|
},
|
||||||
|
extract_run_id: "extract-1".to_string(),
|
||||||
|
candidate: memory::extract::ExtractedCandidate {
|
||||||
|
kind: memory::extract::CandidateKind::Decision,
|
||||||
|
claim: "Keep subject scope host-derived".to_string(),
|
||||||
|
why_useful: "Prevents cross-subject writes".to_string(),
|
||||||
|
staleness: None,
|
||||||
|
evidence_ids: vec!["E00000000".to_string()],
|
||||||
|
},
|
||||||
|
evidence: vec![memory::extract::StagingEvidence {
|
||||||
|
id: "E00000000".to_string(),
|
||||||
|
kind: memory::schema::EvidenceKind::new(memory::schema::EvidenceKind::MESSAGE),
|
||||||
|
entry_range: Some([0, 0]),
|
||||||
|
origin: Some(memory::schema::EvidenceOrigin {
|
||||||
|
kind: memory::schema::EvidenceOriginKind::HumanInput,
|
||||||
|
account_id: None,
|
||||||
|
workspace_id: None,
|
||||||
|
runtime_id: None,
|
||||||
|
worker_id: None,
|
||||||
|
flow_selector: None,
|
||||||
|
flow_definition_id: None,
|
||||||
|
flow_definition_revision: None,
|
||||||
|
}),
|
||||||
|
excerpt: Some("Keep subject scope host-derived".to_string()),
|
||||||
|
summary: Some("Scope decision".to_string()),
|
||||||
|
}],
|
||||||
|
source_refs: vec![memory::schema::SourceEvidenceRef {
|
||||||
|
segment_id: Some("segment-1".to_string()),
|
||||||
|
entry_range: Some([0, 0]),
|
||||||
|
evidence_id: Some("E00000000".to_string()),
|
||||||
|
evidence_kind: Some(memory::schema::EvidenceKind::new(
|
||||||
|
memory::schema::EvidenceKind::MESSAGE,
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let Json(staged) = scoped_stage_subjektiv_candidate(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
}),
|
||||||
|
context,
|
||||||
|
Json(server_api::SubjektivStageCandidateRequest {
|
||||||
|
session_id: "session-1".to_string(),
|
||||||
|
operation,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let store = open_subjektiv_store(&api).unwrap();
|
||||||
|
let candidate = store
|
||||||
|
.staging_candidate(&subject.id, &staged.staging_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(candidate.subject_id, subject.id);
|
||||||
|
assert_eq!(
|
||||||
|
candidate.source_refs[0].session_id.as_deref(),
|
||||||
|
Some("session-1")
|
||||||
|
);
|
||||||
|
let attribution = store.session_attribution("session-1").unwrap().unwrap();
|
||||||
|
assert_eq!(attribution.subject_id, subject.id);
|
||||||
|
assert_eq!(attribution.worker_id, created.worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn singleton_retry_disconnect_replacement_and_restore_are_generation_fenced() {
|
async fn singleton_retry_disconnect_replacement_and_restore_are_generation_fenced() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -48089,6 +48595,21 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
|
|||||||
serde_json::from_slice(&bytes).unwrap()
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subjektiv_singleton_scope_is_explicit_and_opaque() {
|
||||||
|
assert_eq!(
|
||||||
|
subjektiv_subject_id_from_singleton_key("subjektiv:subject-42").unwrap(),
|
||||||
|
"subject-42"
|
||||||
|
);
|
||||||
|
assert!(subjektiv_subject_id_from_singleton_key("workspace-orchestrator").is_err());
|
||||||
|
assert!(subjektiv_subject_id_from_singleton_key("subjektiv:").is_err());
|
||||||
|
assert!(subjektiv_subject_id_from_singleton_key("subjektiv: subject-42").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
subjektiv_singleton_key("subject-42").unwrap(),
|
||||||
|
"subjektiv:subject-42"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn write_ticket(
|
fn write_ticket(
|
||||||
database_path: &Path,
|
database_path: &Path,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ pub enum SubjektivError {
|
|||||||
CandidateConflict(String),
|
CandidateConflict(String),
|
||||||
#[error("staging candidate `{0}` is already resolved")]
|
#[error("staging candidate `{0}` is already resolved")]
|
||||||
CandidateResolved(String),
|
CandidateResolved(String),
|
||||||
|
#[error(
|
||||||
|
"session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`"
|
||||||
|
)]
|
||||||
|
SessionSubjectConflict {
|
||||||
|
session_id: String,
|
||||||
|
existing_subject_id: String,
|
||||||
|
requested_subject_id: String,
|
||||||
|
},
|
||||||
|
#[error("session `{0}` already has different historical attribution")]
|
||||||
|
SessionAttributionConflict(String),
|
||||||
#[error("memory `{0}` was not found for this subject")]
|
#[error("memory `{0}` was not found for this subject")]
|
||||||
MemoryNotFound(String),
|
MemoryNotFound(String),
|
||||||
#[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")]
|
#[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")]
|
||||||
@@ -117,6 +127,40 @@ pub struct SubjectRecord {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjectSessionAttribution {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub subject_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub attributed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjectSessionAttribution {
|
||||||
|
pub fn new(
|
||||||
|
subject_id: impl Into<String>,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
worker_id: impl Into<String>,
|
||||||
|
session_id: impl Into<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let record = Self {
|
||||||
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
|
subject_id: subject_id.into(),
|
||||||
|
runtime_id: runtime_id.into(),
|
||||||
|
worker_id: worker_id.into(),
|
||||||
|
session_id: session_id.into(),
|
||||||
|
attributed_at: now(),
|
||||||
|
};
|
||||||
|
validate_session_attribution(&record)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The existing extraction schema with only host-owned subject scope and
|
/// The existing extraction schema with only host-owned subject scope and
|
||||||
/// persistence time added. Model input still contains neither field.
|
/// persistence time added. Model input still contains neither field.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -602,11 +646,45 @@ END;
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
static MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new(
|
fn add_subject_session_attribution(
|
||||||
1,
|
transaction: &Transaction<'_>,
|
||||||
"create subjektiv subject memory store",
|
) -> crate::feature_storage::Result<()> {
|
||||||
create_schema,
|
transaction.execute_batch(
|
||||||
)];
|
r#"
|
||||||
|
CREATE TABLE subject_session_attributions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
subject_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
record_json TEXT NOT NULL,
|
||||||
|
attributed_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX subject_session_attributions_by_subject
|
||||||
|
ON subject_session_attributions(subject_id, attributed_at, session_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER subject_session_attributions_no_update
|
||||||
|
BEFORE UPDATE ON subject_session_attributions BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'subjektiv session attribution is immutable');
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER subject_session_attributions_no_delete
|
||||||
|
BEFORE DELETE ON subject_session_attributions BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'subjektiv session attribution is retained');
|
||||||
|
END;
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
static MIGRATIONS: &[FeatureMigration] = &[
|
||||||
|
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||||
|
FeatureMigration::new(
|
||||||
|
2,
|
||||||
|
"add immutable subject session attribution",
|
||||||
|
add_subject_session_attribution,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
pub const REGISTRATION: FeatureRegistration =
|
pub const REGISTRATION: FeatureRegistration =
|
||||||
FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS);
|
FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS);
|
||||||
@@ -740,33 +818,89 @@ impl SubjektivStore {
|
|||||||
let raw = serde_json::to_string(&record)?;
|
let raw = serde_json::to_string(&record)?;
|
||||||
self.database.try_transaction(|transaction| {
|
self.database.try_transaction(|transaction| {
|
||||||
require_active_subject(transaction, &record.subject_id)?;
|
require_active_subject(transaction, &record.subject_id)?;
|
||||||
let existing = transaction
|
write_staging_candidate(transaction, self.workspace_id(), record, raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically records immutable host attribution for the committed Session
|
||||||
|
/// and stages its extracted candidate. Exact retries return the first stored
|
||||||
|
/// records, including their original host timestamps.
|
||||||
|
pub fn stage_candidate_with_attribution(
|
||||||
|
&self,
|
||||||
|
mut record: SubjectStagingRecord,
|
||||||
|
attribution: SubjectSessionAttribution,
|
||||||
|
) -> Result<(SubjectStagingRecord, SubjectSessionAttribution)> {
|
||||||
|
validate_session_attribution(&attribution)?;
|
||||||
|
if record.subject_id != attribution.subject_id {
|
||||||
|
return Err(SubjektivError::SubjectScopeMismatch {
|
||||||
|
subject_id: record.subject_id,
|
||||||
|
reference: format!(
|
||||||
|
"session {} is attributed to subject {}",
|
||||||
|
attribution.session_id, attribution.subject_id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attach_session_to_candidate(&mut record, &attribution.session_id)?;
|
||||||
|
validate_staging_record(self.workspace_id(), &record)?;
|
||||||
|
let candidate_raw = serde_json::to_string(&record)?;
|
||||||
|
let attribution_raw = serde_json::to_string(&attribution)?;
|
||||||
|
|
||||||
|
self.database.try_transaction(|transaction| {
|
||||||
|
require_active_subject(transaction, &record.subject_id)?;
|
||||||
|
let attribution = write_session_attribution(transaction, attribution, attribution_raw)?;
|
||||||
|
let candidate =
|
||||||
|
write_staging_candidate(transaction, self.workspace_id(), record, candidate_raw)?;
|
||||||
|
Ok((candidate, attribution))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_session_attribution(
|
||||||
|
&self,
|
||||||
|
attribution: SubjectSessionAttribution,
|
||||||
|
) -> Result<SubjectSessionAttribution> {
|
||||||
|
validate_session_attribution(&attribution)?;
|
||||||
|
let raw = serde_json::to_string(&attribution)?;
|
||||||
|
self.database.try_transaction(|transaction| {
|
||||||
|
require_active_subject(transaction, &attribution.subject_id)?;
|
||||||
|
write_session_attribution(transaction, attribution, raw)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_attribution(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<Option<SubjectSessionAttribution>> {
|
||||||
|
validate_label("session id", session_id)?;
|
||||||
|
self.database.try_with_connection(|connection| {
|
||||||
|
let raw = connection
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT record_json FROM staging_records
|
"SELECT record_json FROM subject_session_attributions
|
||||||
WHERE subject_id = ?1 AND candidate_id = ?2",
|
WHERE session_id = ?1",
|
||||||
params![record.subject_id, record.id],
|
[session_id],
|
||||||
|row| row.get::<_, String>(0),
|
|row| row.get::<_, String>(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if let Some(existing) = existing {
|
raw.map(|raw| parse_session_attribution(&raw)).transpose()
|
||||||
if equivalent_staging_payload(&existing, &raw)? {
|
})
|
||||||
return parse_staging(self.workspace_id(), &existing);
|
|
||||||
}
|
}
|
||||||
return Err(SubjektivError::CandidateConflict(record.id.clone()));
|
|
||||||
}
|
pub fn subject_session_attributions(
|
||||||
transaction.execute(
|
&self,
|
||||||
"INSERT INTO staging_records (
|
subject_id: &str,
|
||||||
subject_id, candidate_id, kind, record_json, created_at
|
) -> Result<Vec<SubjectSessionAttribution>> {
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
validate_label("subject id", subject_id)?;
|
||||||
params![
|
self.database.try_with_connection(|connection| {
|
||||||
record.subject_id,
|
let mut statement = connection.prepare(
|
||||||
record.id,
|
"SELECT record_json FROM subject_session_attributions
|
||||||
candidate_kind_name(&record.kind),
|
WHERE subject_id = ?1
|
||||||
raw,
|
ORDER BY attributed_at ASC, session_id ASC",
|
||||||
record.created_at
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
Ok(record)
|
let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
records.push(parse_session_attribution(&row?)?);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,6 +1362,85 @@ fn write_memory_revision(
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_staging_candidate(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
workspace_id: &str,
|
||||||
|
record: SubjectStagingRecord,
|
||||||
|
raw: String,
|
||||||
|
) -> Result<SubjectStagingRecord> {
|
||||||
|
let existing = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT record_json FROM staging_records
|
||||||
|
WHERE subject_id = ?1 AND candidate_id = ?2",
|
||||||
|
params![record.subject_id, record.id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
if equivalent_staging_payload(&existing, &raw)? {
|
||||||
|
return parse_staging(workspace_id, &existing);
|
||||||
|
}
|
||||||
|
return Err(SubjektivError::CandidateConflict(record.id.clone()));
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO staging_records (
|
||||||
|
subject_id, candidate_id, kind, record_json, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
record.subject_id,
|
||||||
|
record.id,
|
||||||
|
candidate_kind_name(&record.kind),
|
||||||
|
raw,
|
||||||
|
record.created_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_session_attribution(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
record: SubjectSessionAttribution,
|
||||||
|
raw: String,
|
||||||
|
) -> Result<SubjectSessionAttribution> {
|
||||||
|
let existing = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT record_json FROM subject_session_attributions WHERE session_id = ?1",
|
||||||
|
[&record.session_id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
let existing = parse_session_attribution(&existing)?;
|
||||||
|
if existing.subject_id != record.subject_id {
|
||||||
|
return Err(SubjektivError::SessionSubjectConflict {
|
||||||
|
session_id: record.session_id,
|
||||||
|
existing_subject_id: existing.subject_id,
|
||||||
|
requested_subject_id: record.subject_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if equivalent_session_attribution(&existing, &record) {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
return Err(SubjektivError::SessionAttributionConflict(
|
||||||
|
record.session_id,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO subject_session_attributions (
|
||||||
|
session_id, subject_id, runtime_id, worker_id, record_json, attributed_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
record.session_id,
|
||||||
|
record.subject_id,
|
||||||
|
record.runtime_id,
|
||||||
|
record.worker_id,
|
||||||
|
raw,
|
||||||
|
record.attributed_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_resolution(
|
fn insert_resolution(
|
||||||
transaction: &Transaction<'_>,
|
transaction: &Transaction<'_>,
|
||||||
subject_id: &str,
|
subject_id: &str,
|
||||||
@@ -1436,6 +1649,48 @@ fn validate_memory_refs(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_session_attribution(record: &SubjectSessionAttribution) -> Result<()> {
|
||||||
|
validate_schema_version(record.schema_version, "subject Session attribution")?;
|
||||||
|
validate_label("subject id", &record.subject_id)?;
|
||||||
|
validate_label("runtime id", &record.runtime_id)?;
|
||||||
|
validate_label("worker id", &record.worker_id)?;
|
||||||
|
validate_label("session id", &record.session_id)?;
|
||||||
|
validate_label("Session attribution timestamp", &record.attributed_at)?;
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&record.attributed_at).map_err(|error| {
|
||||||
|
SubjektivError::InvalidRecord(format!(
|
||||||
|
"Session attribution timestamp is not RFC 3339: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_session_to_candidate(record: &mut SubjectStagingRecord, session_id: &str) -> Result<()> {
|
||||||
|
if record.source_refs.is_empty() {
|
||||||
|
record.source_refs.push(SourceEvidenceRef {
|
||||||
|
session_id: Some(session_id.to_string()),
|
||||||
|
segment_id: Some(record.source.segment_id.clone()),
|
||||||
|
entry_range: Some(record.source.range),
|
||||||
|
..SourceEvidenceRef::default()
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for source in &mut record.source_refs {
|
||||||
|
match source.session_id.as_deref() {
|
||||||
|
Some(existing) if existing != session_id => {
|
||||||
|
return Err(SubjektivError::SubjectScopeMismatch {
|
||||||
|
subject_id: record.subject_id.clone(),
|
||||||
|
reference: format!(
|
||||||
|
"candidate source Session `{existing}` does not match attributed Session `{session_id}`"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
None => source.session_id = Some(session_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Result<()> {
|
fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Result<()> {
|
||||||
if record.schema_version != STAGING_SCHEMA_VERSION {
|
if record.schema_version != STAGING_SCHEMA_VERSION {
|
||||||
return Err(SubjektivError::InvalidRecord(format!(
|
return Err(SubjektivError::InvalidRecord(format!(
|
||||||
@@ -1450,6 +1705,18 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) ->
|
|||||||
validate_nonempty("candidate claim", &record.claim)?;
|
validate_nonempty("candidate claim", &record.claim)?;
|
||||||
validate_nonempty("candidate why_useful", &record.why_useful)?;
|
validate_nonempty("candidate why_useful", &record.why_useful)?;
|
||||||
validate_range("candidate source", record.source.range)?;
|
validate_range("candidate source", record.source.range)?;
|
||||||
|
if matches!(record.kind, CandidateKind::Preference)
|
||||||
|
&& record.evidence.iter().any(|evidence| {
|
||||||
|
!matches!(
|
||||||
|
evidence.origin.as_ref().map(|origin| &origin.kind),
|
||||||
|
Some(memory::schema::EvidenceOriginKind::HumanInput)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(SubjektivError::InvalidRecord(
|
||||||
|
"preference candidates require exclusively HumanInput evidence".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut evidence_ids = HashSet::new();
|
let mut evidence_ids = HashSet::new();
|
||||||
for evidence in &record.evidence {
|
for evidence in &record.evidence {
|
||||||
@@ -1552,6 +1819,17 @@ fn parse_subject(raw: &str) -> Result<SubjectRecord> {
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn equivalent_session_attribution(
|
||||||
|
left: &SubjectSessionAttribution,
|
||||||
|
right: &SubjectSessionAttribution,
|
||||||
|
) -> bool {
|
||||||
|
left.schema_version == right.schema_version
|
||||||
|
&& left.subject_id == right.subject_id
|
||||||
|
&& left.runtime_id == right.runtime_id
|
||||||
|
&& left.worker_id == right.worker_id
|
||||||
|
&& left.session_id == right.session_id
|
||||||
|
}
|
||||||
|
|
||||||
fn equivalent_staging_payload(left: &str, right: &str) -> Result<bool> {
|
fn equivalent_staging_payload(left: &str, right: &str) -> Result<bool> {
|
||||||
let mut left = serde_json::from_str::<serde_json::Value>(left)?;
|
let mut left = serde_json::from_str::<serde_json::Value>(left)?;
|
||||||
let mut right = serde_json::from_str::<serde_json::Value>(right)?;
|
let mut right = serde_json::from_str::<serde_json::Value>(right)?;
|
||||||
@@ -1569,6 +1847,12 @@ fn equivalent_staging_payload(left: &str, right: &str) -> Result<bool> {
|
|||||||
Ok(left == right)
|
Ok(left == right)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_session_attribution(raw: &str) -> Result<SubjectSessionAttribution> {
|
||||||
|
let record: SubjectSessionAttribution = serde_json::from_str(raw)?;
|
||||||
|
validate_session_attribution(&record)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_staging(workspace_id: &str, raw: &str) -> Result<SubjectStagingRecord> {
|
fn parse_staging(workspace_id: &str, raw: &str) -> Result<SubjectStagingRecord> {
|
||||||
let record = parse_staging_without_workspace(raw)?;
|
let record = parse_staging_without_workspace(raw)?;
|
||||||
validate_staging_record(workspace_id, &record)?;
|
validate_staging_record(workspace_id, &record)?;
|
||||||
@@ -1736,6 +2020,12 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::FeatureStorage;
|
use crate::FeatureStorage;
|
||||||
|
|
||||||
|
static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new(
|
||||||
|
1,
|
||||||
|
"create subjektiv subject memory store",
|
||||||
|
create_schema,
|
||||||
|
)];
|
||||||
|
|
||||||
fn role() -> SubjectRole {
|
fn role() -> SubjectRole {
|
||||||
SubjectRole::new("workspace_companion").unwrap()
|
SubjectRole::new("workspace_companion").unwrap()
|
||||||
}
|
}
|
||||||
@@ -1787,6 +2077,19 @@ mod tests {
|
|||||||
SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into())
|
SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attribution(
|
||||||
|
subject_id: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
attributed_at: &str,
|
||||||
|
) -> SubjectSessionAttribution {
|
||||||
|
let mut record =
|
||||||
|
SubjectSessionAttribution::new(subject_id, runtime_id, worker_id, session_id).unwrap();
|
||||||
|
record.attributed_at = attributed_at.to_string();
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
fn draft(claim: &str, reason: &str) -> MemoryDraft {
|
fn draft(claim: &str, reason: &str) -> MemoryDraft {
|
||||||
MemoryDraft::active(
|
MemoryDraft::active(
|
||||||
CandidateKind::Constraint,
|
CandidateKind::Constraint,
|
||||||
@@ -1851,12 +2154,217 @@ mod tests {
|
|||||||
assert!(serde_json::from_str::<MemoryRecord>(&raw.replace("active", "unknown")).is_err());
|
assert!(serde_json::from_str::<MemoryRecord>(&raw.replace("active", "unknown")).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_store_migrates_before_attributed_staging() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("storage");
|
||||||
|
let subject_id = {
|
||||||
|
let manager = FeatureStorage::new(&root);
|
||||||
|
let workspace = manager.workspace("workspace-a").unwrap();
|
||||||
|
let registration = workspace
|
||||||
|
.register(FeatureRegistration::new(
|
||||||
|
SUBJEKTIV_FEATURE_ID,
|
||||||
|
V1_MIGRATIONS,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||||
|
store.create_subject(role()).unwrap().id
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||||
|
let (staged, stored_attribution) = store
|
||||||
|
.stage_candidate_with_attribution(
|
||||||
|
candidate(&subject_id, "candidate-after-migration", "workspace-a"),
|
||||||
|
attribution(
|
||||||
|
&subject_id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-1",
|
||||||
|
"2026-09-28T10:00:00.000Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(staged.subject_id, subject_id);
|
||||||
|
assert_eq!(stored_attribution.session_id, "session-1");
|
||||||
|
assert_eq!(
|
||||||
|
store.session_attribution("session-1").unwrap().unwrap(),
|
||||||
|
stored_attribution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attributed_staging_is_atomic_and_retry_idempotent() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
||||||
|
let subject = store.create_subject(role()).unwrap();
|
||||||
|
let first_attribution = attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-1",
|
||||||
|
"2026-09-28T10:00:00.000Z",
|
||||||
|
);
|
||||||
|
let (first_candidate, first_attribution) = store
|
||||||
|
.stage_candidate_with_attribution(
|
||||||
|
candidate(&subject.id, "candidate-1", "workspace-a"),
|
||||||
|
first_attribution,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut retried_candidate = first_candidate.clone();
|
||||||
|
retried_candidate.created_at = "2026-09-28T11:00:00.000Z".into();
|
||||||
|
let retried_attribution = attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-1",
|
||||||
|
"2026-09-28T11:00:00.000Z",
|
||||||
|
);
|
||||||
|
let (retried_candidate, retried_attribution) = store
|
||||||
|
.stage_candidate_with_attribution(retried_candidate, retried_attribution)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retried_candidate.created_at, first_candidate.created_at);
|
||||||
|
assert_eq!(retried_attribution, first_attribution);
|
||||||
|
|
||||||
|
let mut conflicting_candidate = candidate(&subject.id, "candidate-1", "workspace-a");
|
||||||
|
conflicting_candidate.claim = "different content".into();
|
||||||
|
conflicting_candidate.source_refs[0].session_id = Some("session-2".into());
|
||||||
|
let result = store.stage_candidate_with_attribution(
|
||||||
|
conflicting_candidate,
|
||||||
|
attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-2",
|
||||||
|
"2026-09-28T12:00:00.000Z",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(SubjektivError::CandidateConflict(candidate_id))
|
||||||
|
if candidate_id == "candidate-1"
|
||||||
|
));
|
||||||
|
assert!(store.session_attribution("session-2").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
||||||
|
let subject = store.create_subject(role()).unwrap();
|
||||||
|
let other_subject = store.create_subject(role()).unwrap();
|
||||||
|
|
||||||
|
store
|
||||||
|
.stage_candidate_with_attribution(
|
||||||
|
candidate(&subject.id, "candidate-1", "workspace-a"),
|
||||||
|
attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-1",
|
||||||
|
"2026-09-28T10:00:00.000Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut replacement_candidate = candidate(&subject.id, "candidate-2", "workspace-a");
|
||||||
|
replacement_candidate.source_refs[0].session_id = None;
|
||||||
|
let (replacement_candidate, _) = store
|
||||||
|
.stage_candidate_with_attribution(
|
||||||
|
replacement_candidate,
|
||||||
|
attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-2",
|
||||||
|
"worker-2",
|
||||||
|
"session-2",
|
||||||
|
"2026-09-28T11:00:00.000Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replacement_candidate.source_refs[0].session_id.as_deref(),
|
||||||
|
Some("session-2")
|
||||||
|
);
|
||||||
|
|
||||||
|
let history = store.subject_session_attributions(&subject.id).unwrap();
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history[0].worker_id, "worker-1");
|
||||||
|
assert_eq!(history[1].worker_id, "worker-2");
|
||||||
|
assert!(history.iter().all(|entry| entry.subject_id == subject.id));
|
||||||
|
|
||||||
|
let update = store
|
||||||
|
.database
|
||||||
|
.try_with_connection::<_, SubjektivError>(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE subject_session_attributions SET worker_id = 'worker-other'
|
||||||
|
WHERE session_id = 'session-1'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(update.is_err());
|
||||||
|
let delete = store
|
||||||
|
.database
|
||||||
|
.try_with_connection::<_, SubjektivError>(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM subject_session_attributions WHERE session_id = 'session-1'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert!(delete.is_err());
|
||||||
|
|
||||||
|
let mut foreign_candidate =
|
||||||
|
candidate(&other_subject.id, "candidate-foreign", "workspace-a");
|
||||||
|
foreign_candidate.source_refs[0].session_id = Some("session-1".into());
|
||||||
|
let result = store.stage_candidate_with_attribution(
|
||||||
|
foreign_candidate,
|
||||||
|
attribution(
|
||||||
|
&other_subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
"session-1",
|
||||||
|
"2026-09-28T12:00:00.000Z",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(SubjektivError::SessionSubjectConflict {
|
||||||
|
session_id,
|
||||||
|
existing_subject_id,
|
||||||
|
requested_subject_id,
|
||||||
|
}) if session_id == "session-1"
|
||||||
|
&& existing_subject_id == subject.id
|
||||||
|
&& requested_subject_id == other_subject.id
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.staging_candidate(&other_subject.id, "candidate-foreign")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_nested_provenance_is_rejected_without_staging() {
|
fn malformed_nested_provenance_is_rejected_without_staging() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap();
|
let subject = store.create_subject(role()).unwrap();
|
||||||
|
|
||||||
|
let mut non_human_preference =
|
||||||
|
candidate(&subject.id, "candidate-preference", "workspace-a");
|
||||||
|
non_human_preference.kind = CandidateKind::Preference;
|
||||||
|
non_human_preference.evidence[0]
|
||||||
|
.origin
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.kind = EvidenceOriginKind::ModelOutput;
|
||||||
|
assert!(matches!(
|
||||||
|
store.stage_candidate(non_human_preference),
|
||||||
|
Err(SubjektivError::InvalidRecord(message))
|
||||||
|
if message.contains("HumanInput")
|
||||||
|
));
|
||||||
|
|
||||||
let mut reversed = candidate(&subject.id, "candidate-reversed", "workspace-a");
|
let mut reversed = candidate(&subject.id, "candidate-reversed", "workspace-a");
|
||||||
reversed.evidence[0].entry_range = Some([12, 10]);
|
reversed.evidence[0].entry_range = Some([12, 10]);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -19,14 +19,17 @@ cutover of the existing Workspace Memory authority.
|
|||||||
Async callers must run these synchronous repository calls on a blocking
|
Async callers must run these synchronous repository calls on a blocking
|
||||||
executor.
|
executor.
|
||||||
- The store contains no `current_worker_id` or equivalent current link. Yoi's
|
- The store contains no `current_worker_id` or equivalent current link. Yoi's
|
||||||
keyed singleton remains the sole authority for the current Worker. Historical
|
keyed singleton remains the sole authority for the current Worker. T-668
|
||||||
Worker/Session attribution is provenance and will be connected separately.
|
connects Workers using the opaque `subjektiv:<subject-id>` key and records
|
||||||
|
immutable historical Worker/Session attribution as provenance; that history is
|
||||||
|
never consulted as a current Worker link.
|
||||||
|
|
||||||
Registration is deliberately separate from open. Trusted Server construction
|
Registration is deliberately separate from open. Trusted Server construction
|
||||||
registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its
|
registers `REGISTRATION` once per `FeatureStorage` manager, then reuses its
|
||||||
`RegisteredFeature` for each Workspace. Merely adding this module does not open
|
`RegisteredFeature` for each Workspace. Merely adding this module does not open
|
||||||
or create a subjektiv database; product enablement and old-Memory cutover are
|
or create a subjektiv database. T-668 opens it lazily for explicit subject
|
||||||
later work.
|
operations or an authenticated subject extraction write; leaving the Feature
|
||||||
|
disabled has no subject-store side effect.
|
||||||
|
|
||||||
## Versioned records
|
## Versioned records
|
||||||
|
|
||||||
@@ -120,3 +123,39 @@ online backup/restore includes the entire subjektiv database, including immutabl
|
|||||||
history and provenance. Workspace deletion and shutdown use the common lifecycle
|
history and provenance. Workspace deletion and shutdown use the common lifecycle
|
||||||
fences and close retained repository handles. Disabling subjektiv does not erase
|
fences and close retained repository handles. Disabling subjektiv does not erase
|
||||||
its database.
|
its database.
|
||||||
|
|
||||||
|
## Extraction connection (T-668)
|
||||||
|
|
||||||
|
`feature.subjektiv` is an explicit Worker profile boundary. It reuses the
|
||||||
|
committed-run threshold, restricted Internal extraction Worker,
|
||||||
|
`SessionEntryRef` evidence resolution, explicit finish, generation fence, and
|
||||||
|
success-only pointer progression from the established Memory lifecycle. Its
|
||||||
|
pointer domain is separate from legacy Workspace Memory, and a Worker is rejected
|
||||||
|
at installation when both automatic extraction destinations are enabled. Ordinary
|
||||||
|
SubWorkers and Reviewer SubWorkers have lifecycle Features disabled by their host;
|
||||||
|
only evidence actually committed into the parent Session can enter the parent's
|
||||||
|
capture.
|
||||||
|
|
||||||
|
The staging request contains the existing model contract (`kind`, `claim`,
|
||||||
|
`why_useful`, optional `staleness`, and entry references after host resolution)
|
||||||
|
plus the committed parent `session_id`. It contains no subject id. The Server
|
||||||
|
derives subject scope from authenticated Runtime/Worker source proof and the
|
||||||
|
current keyed-singleton lease, then attaches the session id to every source
|
||||||
|
reference and records immutable historical `{subject, runtime, worker, session}`
|
||||||
|
provenance atomically with staging. A Session already attributed to another
|
||||||
|
subject is rejected. Preference candidates still require exclusively HumanInput
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Subject retirement does not stop a Worker, and stopping/replacing a Worker does
|
||||||
|
not retire the subject. A replacement uses the same `subjektiv:<subject-id>` key;
|
||||||
|
retries therefore recover through Yoi's singleton authority without a second
|
||||||
|
current-worker mapping in this database.
|
||||||
|
|
||||||
|
Candidates are immutable writes that occur before `FinishMemoryExtraction`. If a
|
||||||
|
run stages one or more candidates and then fails, is cancelled, or loses its
|
||||||
|
generation fence, those candidates remain recorded and the extraction pointer
|
||||||
|
does not advance. A retry may therefore produce semantically duplicate candidates;
|
||||||
|
subsequent consolidation must compare them using the existing candidate
|
||||||
|
identity/content boundary. The system must not claim that the partial writes were
|
||||||
|
rolled back or silently delete them. Subject recall, consolidation, and legacy
|
||||||
|
Memory migration remain separate work.
|
||||||
|
|||||||
Reference in New Issue
Block a user