diff --git a/src/dashboard/analytics_api.rs b/src/dashboard/analytics_api.rs index 6e2d3534..f52e89e0 100644 --- a/src/dashboard/analytics_api.rs +++ b/src/dashboard/analytics_api.rs @@ -10,7 +10,10 @@ use axum::extract::State; use axum::response::Json; use serde_json::{json, Value}; -use crate::analytics::{categorize_skill, infer_usage_events, UsageKind}; +use crate::analytics::{ + categorize_skill, infer_usage_events, underused_tool_family_signals, ToolUsageObservation, + UsageKind, +}; use crate::global_db::{AnalyticsEventQuery, AnalyticsEventRecord, GlobalDb}; use super::util::{i64_field, query_i64, query_rows, str_field}; @@ -30,12 +33,6 @@ const HINT_CATEGORIES: &[&str] = &[ ]; const ANALYTICS_EVENT_LIMIT: usize = 10_000; -#[derive(Default)] -struct FamilyCounts { - relevant_events: i64, - usage_events: i64, -} - #[derive(Default)] struct HintCounts { emitted: i64, @@ -49,6 +46,7 @@ pub(crate) async fn overview(State(state): State) -> Json let durable_events = durable_analytics_rows_for_state(&state).await; let hints = hint_summary(state.lcm_conn.as_ref(), durable_events.as_deref()).await; let usage = usage_summary(state.lcm_conn.as_ref(), durable_events.as_deref()).await; + let diagnostics = diagnostics_summary(&state, durable_events.as_deref()).await; let underused = underused_tool_families(state.lcm_conn.as_ref()).await; Json(json!({ @@ -57,6 +55,7 @@ pub(crate) async fn overview(State(state): State) -> Json "scope": state.lcm_scope, "hints": hints, "usage": usage, + "diagnostics": diagnostics, "underused_tool_families": underused, })) } @@ -73,6 +72,12 @@ pub(crate) async fn usage(State(state): State) -> Json { Json(usage_summary(state.lcm_conn.as_ref(), durable_events.as_deref()).await) } +/// `GET /api/plugins/analytics/diagnostics` +pub(crate) async fn diagnostics(State(state): State) -> Json { + let durable_events = durable_analytics_rows_for_state(&state).await; + Json(diagnostics_summary(&state, durable_events.as_deref()).await) +} + /// `GET /api/plugins/analytics/underused` pub(crate) async fn underused(State(state): State) -> Json { Json(json!({ @@ -130,11 +135,11 @@ async fn durable_analytics_rows( let rows = query_rows( lcm_conn?, - "SELECT event_kind, tool_name, tool_category, skill_name, - hint_category, outcome, metadata_json + "SELECT provider, timestamp, event_kind, hook_name, tool_name, + tool_category, skill_name, hint_category, outcome, metadata_json FROM ( - SELECT event_kind, tool_name, tool_category, skill_name, - hint_category, outcome, metadata_json, timestamp, id + SELECT provider, timestamp, event_kind, hook_name, tool_name, + tool_category, skill_name, hint_category, outcome, metadata_json, id FROM analytics_events WHERE project_id = ?1 ORDER BY timestamp DESC, id DESC @@ -154,7 +159,10 @@ async fn durable_analytics_rows( fn durable_analytics_event_row(event: &AnalyticsEventRecord) -> Value { json!({ + "provider": &event.provider, + "timestamp": event.timestamp, "event_kind": &event.event_kind, + "hook_name": &event.hook_name, "tool_name": &event.tool_name, "tool_category": &event.tool_category, "skill_name": &event.skill_name, @@ -429,89 +437,230 @@ fn usage_count_rows(counts: BTreeMap<(String, String), i64>) -> Vec { .collect() } -async fn underused_tool_families(conn: Option<&libsql::Connection>) -> Value { - let Some(rows) = session_message_rows(conn).await else { - return Value::Array(Vec::new()); +async fn diagnostics_summary(state: &DashboardState, durable_events: Option<&[Value]>) -> Value { + let message_count = session_message_rows(state.lcm_conn.as_ref()) + .await + .map_or(0, |rows| rows.len() as i64); + let hook_rows = read_hook_analytics_rows(state); + let hook_call_count = hook_invocation_count(&hook_rows); + + let Some(events) = durable_events else { + return json!({ + "available": !hook_rows.is_empty() || message_count > 0, + "source": "session_messages_and_hook_analytics", + "message_count": message_count, + "event_count": 0, + "tool_call_count": 0, + "mcp_tool_call_count": 0, + "tracedecay_call_count": 0, + "hook_call_count": hook_call_count, + "ratios": diagnostics_ratios(message_count, 0, 0, 0, hook_call_count), + "by_event_kind": [], + "by_tool": [], + "by_mcp_tool": [], + "by_tool_category": [], + "by_outcome": [], + "by_hook": hook_count_rows(&hook_rows), + "by_prompt_category": hook_prompt_category_rows(&hook_rows), + "recent_events": [], + "recent_hooks": recent_hook_rows(&hook_rows, 20), + }); }; - let mut families: BTreeMap = [ - ("code_context".to_string(), FamilyCounts::default()), - ("code_search".to_string(), FamilyCounts::default()), - ("call_graph".to_string(), FamilyCounts::default()), - ("impact_analysis".to_string(), FamilyCounts::default()), - ] - .into_iter() - .collect(); + let mut by_event_kind = BTreeMap::new(); + let mut by_tool = BTreeMap::new(); + let mut by_mcp_tool = BTreeMap::new(); + let mut by_tool_category = BTreeMap::new(); + let mut by_outcome = BTreeMap::new(); + let mut tool_call_count = 0; + let mut mcp_tool_call_count = 0; + let mut tracedecay_call_count = 0; + let mut first_ts: Option = None; + let mut last_ts: Option = None; - for row in &rows { - let text = str_field(row, "text"); - for event in infer_usage_events( - Some(str_field(row, "tool_names")), - Some(str_field(row, "metadata_json")), - Some(text), - ) { - if event.kind == UsageKind::Tool { - record_tool_family(&mut families, &event.name, text); + for event in events { + let event_kind = str_field(event, "event_kind"); + let tool_name = str_field(event, "tool_name"); + increment_string_count(&mut by_event_kind, event_kind); + increment_string_count(&mut by_tool_category, str_field(event, "tool_category")); + increment_string_count(&mut by_outcome, str_field(event, "outcome")); + + if let Some(ts) = event.get("timestamp").and_then(Value::as_i64) { + first_ts = Some(first_ts.map_or(ts, |current| current.min(ts))); + last_ts = Some(last_ts.map_or(ts, |current| current.max(ts))); + } + + if !tool_name.is_empty() { + tool_call_count += 1; + increment_string_count(&mut by_tool, tool_name); + if event_kind == "mcp_tool_call" || tool_name.starts_with("mcp__") { + mcp_tool_call_count += 1; + increment_string_count(&mut by_mcp_tool, tool_name); + } + if crate::analytics::normalize_tool_name(tool_name).starts_with("tracedecay_") { + tracedecay_call_count += 1; } } } - Value::Array( - families - .into_iter() - .map(|(family, counts)| { - let missed = counts.relevant_events.saturating_sub(counts.usage_events); - json!({ - "family": family, - "relevant_events": counts.relevant_events, - "usage_events": counts.usage_events, - "missed_events": missed, - "underused": missed > 0, - }) - }) - .collect(), - ) + let span_secs = match (first_ts, last_ts) { + (Some(first), Some(last)) => last.saturating_sub(first).max(1), + _ => 0, + }; + let events_per_hour = if span_secs > 0 { + (events.len() as f64) * 3600.0 / span_secs as f64 + } else { + 0.0 + }; + + json!({ + "available": true, + "source": "analytics_events", + "message_count": message_count, + "event_count": events.len() as i64, + "tool_call_count": tool_call_count, + "mcp_tool_call_count": mcp_tool_call_count, + "tracedecay_call_count": tracedecay_call_count, + "hook_call_count": hook_call_count, + "events_per_hour": events_per_hour, + "ratios": diagnostics_ratios( + message_count, + events.len() as i64, + tool_call_count, + mcp_tool_call_count, + hook_call_count, + ), + "by_event_kind": count_rows("event_kind", by_event_kind), + "by_tool": count_rows("tool_name", by_tool), + "by_mcp_tool": count_rows("tool_name", by_mcp_tool), + "by_tool_category": count_rows("tool_category", by_tool_category), + "by_outcome": count_rows("outcome", by_outcome), + "by_hook": hook_count_rows(&hook_rows), + "by_prompt_category": hook_prompt_category_rows(&hook_rows), + "recent_events": recent_event_rows(events, 20), + "recent_hooks": recent_hook_rows(&hook_rows, 20), + }) } -fn record_tool_family(families: &mut BTreeMap, tool: &str, text: &str) { - let normalized = normalize(tool); - let text = text.to_ascii_lowercase(); - if normalized.contains("tracedecay_context") - || normalized.contains("tracedecay_node") - || normalized.contains("tracedecay_files") - { - increment_family_usage(families, "code_context"); - } - if normalized.contains("tracedecay_search") || normalized.contains("find_exact_symbol") { - increment_family_usage(families, "code_search"); - } - if normalized.contains("tracedecay_call") || normalized.contains("tracedecay_graph") { - increment_family_usage(families, "call_graph"); +fn diagnostics_ratios( + message_count: i64, + event_count: i64, + tool_call_count: i64, + mcp_tool_call_count: i64, + hook_call_count: i64, +) -> Value { + json!({ + "events_per_message": per_message(event_count, message_count), + "tool_calls_per_message": per_message(tool_call_count, message_count), + "mcp_tool_calls_per_message": per_message(mcp_tool_call_count, message_count), + "hook_calls_per_message": per_message(hook_call_count, message_count), + }) +} + +fn per_message(count: i64, message_count: i64) -> f64 { + if message_count <= 0 { + 0.0 + } else { + count as f64 / message_count as f64 } - if normalized.contains("tracedecay_impact") || normalized.contains("tracedecay_affected") { - increment_family_usage(families, "impact_analysis"); +} + +fn increment_string_count(counts: &mut BTreeMap, key: &str) { + if !key.is_empty() { + *counts.entry(key.to_string()).or_default() += 1; } +} + +fn count_rows(label: &str, counts: BTreeMap) -> Vec { + counts + .into_iter() + .map(|(key, count)| json!({ label: key, "count": count })) + .collect() +} - if normalized == "read" || normalized == "cat" || normalized == "sed" { - increment_family_relevance(families, "code_context"); +fn read_hook_analytics_rows(state: &DashboardState) -> Vec { + let Ok(text) = std::fs::read_to_string(state.store_root.join("hook_analytics.jsonl")) else { + return Vec::new(); + }; + text.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() +} + +fn hook_invocation_count(rows: &[Value]) -> i64 { + rows.iter() + .filter(|row| str_field(row, "event") == "hook_invoked") + .count() as i64 +} + +fn hook_count_rows(rows: &[Value]) -> Vec { + let mut counts = BTreeMap::new(); + for row in rows { + if str_field(row, "event") == "hook_invoked" { + increment_string_count(&mut counts, str_field(row, "hook_name")); + } } - if matches!(normalized.as_str(), "grep" | "rg" | "glob" | "search") - || (matches!(normalized.as_str(), "bash" | "shell" | "exec_command") - && (text.contains(" rg ") || text.contains("grep") || text.contains("find "))) - { - increment_family_relevance(families, "code_search"); + count_rows("hook_name", counts) +} + +fn hook_prompt_category_rows(rows: &[Value]) -> Vec { + let mut counts = BTreeMap::new(); + for row in rows { + if str_field(row, "event") == "hook_invoked" { + increment_string_count(&mut counts, str_field(row, "prompt_category")); + } } + count_rows("prompt_category", counts) } -fn increment_family_usage(families: &mut BTreeMap, family: &str) { - families.entry(family.to_string()).or_default().usage_events += 1; +fn recent_event_rows(events: &[Value], limit: usize) -> Vec { + events + .iter() + .rev() + .take(limit) + .map(|event| { + json!({ + "timestamp": event.get("timestamp").cloned().unwrap_or(Value::Null), + "event_kind": str_field(event, "event_kind"), + "hook_name": str_field(event, "hook_name"), + "tool_name": str_field(event, "tool_name"), + "outcome": str_field(event, "outcome"), + }) + }) + .collect() } -fn increment_family_relevance(families: &mut BTreeMap, family: &str) { - families - .entry(family.to_string()) - .or_default() - .relevant_events += 1; +fn recent_hook_rows(rows: &[Value], limit: usize) -> Vec { + rows.iter() + .rev() + .filter(|row| str_field(row, "event") == "hook_invoked") + .take(limit) + .map(|row| { + json!({ + "ts_unix_ms": row.get("ts_unix_ms").cloned().unwrap_or(Value::Null), + "agent": str_field(row, "agent"), + "hook_name": str_field(row, "hook_name"), + "session_id": str_field(row, "session_id"), + "tool_name": str_field(row, "tool_name"), + "prompt_category": str_field(row, "prompt_category"), + }) + }) + .collect() +} + +async fn underused_tool_families(conn: Option<&libsql::Connection>) -> Value { + let Some(rows) = session_message_rows(conn).await else { + return Value::Array(Vec::new()); + }; + + json!(underused_tool_family_signals(rows.iter().map(|row| { + let text = str_field(row, "text"); + ToolUsageObservation { + tool_names: Some(str_field(row, "tool_names")), + metadata_json: Some(str_field(row, "metadata_json")), + text: Some(text), + } + }))) } fn normalize(value: &str) -> String { diff --git a/src/dashboard/automation_config_api.rs b/src/dashboard/automation_config_api.rs new file mode 100644 index 00000000..eb6623dd --- /dev/null +++ b/src/dashboard/automation_config_api.rs @@ -0,0 +1,173 @@ +//! Dashboard endpoints for project/profile self-improvement automation config. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; + +use super::util::{http_detail, JsonError}; +use super::DashboardState; +use crate::automation::backend; +use crate::automation::config::{ + clear_project_config, effective_config, load_project_config, merge_project_config, + save_project_config, AutomationBackend, AutomationConfig, AutomationConfigPatch, +}; +use crate::user_config::UserConfig; + +type ApiResult = std::result::Result, JsonError>; + +pub(crate) async fn get_config(State(state): State) -> ApiResult { + let global = UserConfig::load().automation; + let project = load_project_or_error(&state).await?; + config_payload(&state, &global, project.as_ref()) +} + +pub(crate) async fn patch_config( + State(state): State, + Json(patch): Json, +) -> ApiResult { + let patch = serde_json::from_value::(patch) + .map_err(|err| bad_request(&format!("invalid automation config patch: {err}")))?; + reject_unselectable_backend(&patch)?; + let global = UserConfig::load().automation; + let current = load_project_or_error(&state).await?; + let project = merge_project_config(current, patch); + let effective = effective_config(&global, Some(&project)).map_err(|err| bad_request(&err))?; + save_project_config(&state.dashboard_root, &project) + .await + .map_err(|err| internal_error(&err))?; + Ok(Json(config_payload_value( + &state, + &global, + Some(&project), + &effective, + ))) +} + +pub(crate) async fn reset_config(State(state): State) -> ApiResult { + let global = UserConfig::load().automation; + clear_project_config(&state.dashboard_root) + .await + .map_err(|err| internal_error(&err))?; + config_payload(&state, &global, None) +} + +async fn load_project_or_error( + state: &DashboardState, +) -> std::result::Result, JsonError> { + load_project_config(&state.dashboard_root) + .await + .map_err(|err| internal_error(&err)) +} + +fn reject_unselectable_backend( + patch: &AutomationConfigPatch, +) -> std::result::Result<(), JsonError> { + if patch.backend == Some(AutomationBackend::ExternalCommand) { + return Err(bad_request( + &"automation backend external_command is not selectable yet; use disabled or codex_app_server", + )); + } + Ok(()) +} + +fn config_payload( + state: &DashboardState, + global: &AutomationConfig, + project: Option<&AutomationConfigPatch>, +) -> ApiResult { + let effective = effective_config(global, project).map_err(|err| internal_error(&err))?; + Ok(Json(config_payload_value( + state, global, project, &effective, + ))) +} + +fn config_payload_value( + state: &DashboardState, + global: &AutomationConfig, + project: Option<&AutomationConfigPatch>, + effective: &AutomationConfig, +) -> Value { + json!({ + "global": global, + "project": project, + "effective": effective, + "backend_availability": backend::backend_availability(effective), + "project_config_path": crate::automation::config::project_config_path(&state.dashboard_root) + .display() + .to_string(), + }) +} + +fn bad_request(err: &impl ToString) -> JsonError { + let message = err.to_string(); + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "detail": message, + "validation_errors": [{ + "field": validation_field(&message), + "message": message, + }], + })), + ) +} + +fn internal_error(err: &impl ToString) -> JsonError { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&err.to_string())), + ) +} + +fn validation_field(message: &str) -> String { + if let Some(field) = unknown_field(message) { + return field; + } + + for field in [ + "auto_apply_memory_ops", + "auto_enable_skills", + "require_dashboard_approval", + "backend", + "host_mode", + "timeout_secs", + "scheduler_tick_secs", + "max_tokens", + "temperature", + ] { + if message.contains(field) { + return field.to_string(); + } + } + if message.contains("standalone") && message.contains("delegated_host") { + return "host_mode".to_string(); + } + + for task in ["memory_curator", "session_reflector", "skill_writer"] { + if !message.contains(task) { + continue; + } + for field in [ + "schedule", + "interval_secs", + "cooldown_secs", + "min_idle_secs", + "stale_lock_secs", + ] { + if message.contains(field) { + return format!("{task}.{field}"); + } + } + return task.to_string(); + } + + "config".to_string() +} + +fn unknown_field(message: &str) -> Option { + let start = message.find("unknown field `")? + "unknown field `".len(); + let rest = &message[start..]; + let end = rest.find('`')?; + Some(rest[..end].to_string()) +} diff --git a/src/dashboard/automation_fact_proposals_api.rs b/src/dashboard/automation_fact_proposals_api.rs new file mode 100644 index 00000000..961726ba --- /dev/null +++ b/src/dashboard/automation_fact_proposals_api.rs @@ -0,0 +1,127 @@ +use axum::extract::{Path as AxumPath, State}; +use axum::http::StatusCode; +use axum::response::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::util::{coerce_limit, http_detail, JsonQuery}; +use super::DashboardState; +use crate::automation::fact_proposals::{ + apply_fact_proposal, list_fact_proposals, load_fact_proposal, reject_fact_proposal, + FactProposalRecord, FactProposalState, +}; + +#[derive(Debug, Deserialize)] +pub(crate) struct ListParams { + state: Option, + limit: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub(crate) struct RejectBody { + reason: Option, +} + +pub(crate) async fn list( + State(state): State, + JsonQuery(params): JsonQuery, +) -> (StatusCode, Json) { + let proposal_state = match params.state.as_deref() { + Some(value) => match FactProposalState::parse(value) { + Ok(state) => Some(state), + Err(err) => return (StatusCode::BAD_REQUEST, Json(http_detail(&err.to_string()))), + }, + None => None, + }; + let limit = coerce_limit(params.limit, 50, 200) as usize; + match list_fact_proposals(&state.dashboard_root, proposal_state, limit).await { + Ok(proposals) => { + let count = proposals.len(); + ( + StatusCode::OK, + Json(json!({ + "proposals": proposals, + "count": count, + "limit": limit, + "error": "", + })), + ) + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&format!( + "Failed to load fact proposals: {err}" + ))), + ), + } +} + +pub(crate) async fn view( + State(state): State, + AxumPath(id): AxumPath, +) -> (StatusCode, Json) { + match load_fact_proposal(&state.dashboard_root, &id).await { + Ok(Some(proposal)) => (StatusCode::OK, Json(proposal_payload(&proposal))), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(http_detail(&format!("fact proposal not found: {id}"))), + ), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&format!("Failed to load fact proposal: {err}"))), + ), + } +} + +pub(crate) async fn apply( + State(state): State, + AxumPath(id): AxumPath, +) -> (StatusCode, Json) { + match apply_fact_proposal( + &state.dashboard_root, + &state.mem_conn, + &id, + Some("dashboard".to_string()), + ) + .await + { + Ok(proposal) => (StatusCode::OK, Json(proposal_payload(&proposal))), + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(http_detail(&format!( + "Failed to apply fact proposal: {err}" + ))), + ), + } +} + +pub(crate) async fn reject( + State(state): State, + AxumPath(id): AxumPath, + body: Option>, +) -> (StatusCode, Json) { + let reason = body.and_then(|body| body.0.reason); + match reject_fact_proposal( + &state.dashboard_root, + &id, + Some("dashboard".to_string()), + reason, + ) + .await + { + Ok(proposal) => (StatusCode::OK, Json(proposal_payload(&proposal))), + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(http_detail(&format!( + "Failed to reject fact proposal: {err}" + ))), + ), + } +} + +fn proposal_payload(proposal: &FactProposalRecord) -> Value { + json!({ + "proposal": proposal, + "error": "", + }) +} diff --git a/src/dashboard/automation_run_api.rs b/src/dashboard/automation_run_api.rs new file mode 100644 index 00000000..24f91823 --- /dev/null +++ b/src/dashboard/automation_run_api.rs @@ -0,0 +1,660 @@ +use axum::extract::{Path as AxumPath, State}; +use axum::http::StatusCode; +use axum::response::Json; +use std::future::Future; +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::automation_run_service::{ + self, MemoryCuratorRunRequest, SessionReflectionRunRequest, SkillWritingRunRequest, +}; +use super::memory_api::{ + default_agent_plan_max_clusters, default_agent_plan_min_confidence, default_dry_run, +}; +use super::memory_service::{push_curation_activity, push_curation_activity_with_level}; +use super::util::http_detail; +use super::DashboardState; +use crate::automation::backend::{ + agent_task_contract, classify_agent_task_error_message, prompt_version, task_key, AgentTaskKind, +}; +use crate::automation::config::{effective_config, load_project_config, AutomationConfig}; +use crate::automation::run_ledger::{ + append_run_record, find_run_record, load_run_records, read_run_artifact_payload, + AutomationRunArtifact, AutomationRunArtifactKind, AutomationRunLedgerRecord, + AutomationRunStatus, AutomationTrigger, +}; +use crate::sessions::lcm::{LcmGrepSort, LcmScope}; +use crate::tracedecay::current_timestamp; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct MemoryCuratorRunBody { + #[serde(default = "default_dry_run")] + dry_run: bool, + #[serde(default = "default_agent_plan_max_clusters")] + max_clusters: usize, + #[serde(default = "default_agent_plan_min_confidence")] + min_confidence: f64, +} + +impl Default for MemoryCuratorRunBody { + fn default() -> Self { + Self { + dry_run: default_dry_run(), + max_clusters: default_agent_plan_max_clusters(), + min_confidence: default_agent_plan_min_confidence(), + } + } +} + +impl From for MemoryCuratorRunRequest { + fn from(body: MemoryCuratorRunBody) -> Self { + Self { + max_clusters: body.max_clusters, + min_confidence: body.min_confidence, + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionReflectionRunBody { + #[serde(default = "default_dry_run")] + dry_run: bool, + provider: Option, + query: Option, + evidence_limit: Option, + storage_scope: Option, + hermes_home: Option, + scope: Option, + session_id: Option, + include_summaries: Option, + sort: Option, + source: Option, + role: Option, + start_time: Option, + end_time: Option, +} + +impl From for SessionReflectionRunRequest { + fn from(body: SessionReflectionRunBody) -> Self { + Self { + provider: body.provider, + query: body.query, + evidence_limit: body.evidence_limit, + storage_scope: body.storage_scope, + hermes_home: body.hermes_home, + scope: body.scope, + session_id: body.session_id, + include_summaries: body.include_summaries, + sort: body.sort, + source: body.source, + role: body.role, + start_time: body.start_time, + end_time: body.end_time, + } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SkillWritingRunBody { + #[serde(default = "default_dry_run")] + dry_run: bool, + provider: Option, + query: Option, + evidence_limit: Option, +} + +impl From for SkillWritingRunRequest { + fn from(body: SkillWritingRunBody) -> Self { + Self { + provider: body.provider, + query: body.query, + evidence_limit: body.evidence_limit, + } + } +} + +pub(crate) async fn memory_curator( + State(state): State, + body: Option>, +) -> (StatusCode, Json) { + let body = body.map(|body| body.0).unwrap_or_default(); + let dry_run = body.dry_run; + let request = MemoryCuratorRunRequest::from(body); + run_dashboard_task_endpoint( + state, + dry_run, + "memory-curator", + AgentTaskKind::MemoryCurator, + move |state, run_id| async move { + automation_run_service::curation_agent_plan_payload_with_run_id( + &state, + request, + Some(run_id), + ) + .await + }, + ) + .await +} + +pub(crate) async fn session_reflection( + State(state): State, + body: Option>, +) -> (StatusCode, Json) { + let body = body.map(|body| body.0).unwrap_or_default(); + let dry_run = body.dry_run; + let request = SessionReflectionRunRequest::from(body); + run_dashboard_task_endpoint( + state, + dry_run, + "session-reflection", + AgentTaskKind::SessionReflector, + move |state, run_id| async move { + automation_run_service::session_reflection_run_payload_with_run_id( + &state, + request, + Some(run_id), + ) + .await + }, + ) + .await +} + +pub(crate) async fn skill_writing( + State(state): State, + body: Option>, +) -> (StatusCode, Json) { + let body = body.map(|body| body.0).unwrap_or_default(); + let dry_run = body.dry_run; + let request = SkillWritingRunRequest::from(body); + run_dashboard_task_endpoint( + state, + dry_run, + "skill-writing", + AgentTaskKind::SkillWriter, + move |state, run_id| async move { + automation_run_service::skill_writing_run_payload_with_run_id( + &state, + request, + Some(run_id), + ) + .await + }, + ) + .await +} + +async fn run_dashboard_task_endpoint( + state: DashboardState, + dry_run: bool, + task_label: &'static str, + task: AgentTaskKind, + run_job: F, +) -> (StatusCode, Json) +where + F: FnOnce(DashboardState, String) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + if !dry_run { + return dry_run_only_response(task_label); + } + enqueue_dashboard_run(state, task, run_job).await +} + +pub(crate) async fn artifact_list( + State(state): State, + AxumPath(run_id): AxumPath, +) -> (StatusCode, Json) { + match find_run_record(&state.dashboard_root, &run_id).await { + Ok(Some(record)) => { + let count = record.artifacts.len(); + ( + StatusCode::OK, + Json(json!({ + "run_id": run_id, + "artifacts": record.artifacts, + "artifact_chain": artifact_chain_summary(&record.artifacts), + "count": count, + "error": "", + })), + ) + } + Ok(None) => not_found(&format!("automation run '{run_id}' not found")), + Err(err) => internal_error(&format!("Failed to load automation run artifacts: {err}")), + } +} + +pub(crate) async fn artifact_payload( + State(state): State, + AxumPath((run_id, kind)): AxumPath<(String, String)>, +) -> (StatusCode, Json) { + let record = match find_run_record(&state.dashboard_root, &run_id).await { + Ok(Some(record)) => record, + Ok(None) => { + return not_found(&format!("automation run '{run_id}' not found")); + } + Err(err) => { + return internal_error(&format!("Failed to load automation run artifact: {err}")); + } + }; + let Some(artifact) = find_artifact(&record.artifacts, &kind) else { + return not_found(&format!( + "automation run artifact '{kind}' not found for run '{run_id}'" + )); + }; + match read_run_artifact_payload(&state.dashboard_root, &run_id, artifact).await { + Ok(payload) => ( + StatusCode::OK, + Json(json!({ + "run_id": run_id, + "artifact": artifact, + "payload": payload, + "error": "", + })), + ), + Err(err) => internal_error(&format!("Failed to read automation run artifact: {err}")), + } +} + +fn dry_run_only_response(task: &str) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(http_detail(&format!( + "{task} currently supports dry_run=true only; approval controls apply accepted drafts separately" + ))), + ) +} + +fn not_found(message: &str) -> (StatusCode, Json) { + (StatusCode::NOT_FOUND, Json(http_detail(message))) +} + +fn internal_error(message: &str) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(message)), + ) +} + +fn find_artifact<'a>( + artifacts: &'a [AutomationRunArtifact], + kind: &str, +) -> Option<&'a AutomationRunArtifact> { + artifacts.iter().find(|artifact| artifact.kind == kind) +} + +fn artifact_chain_summary(artifacts: &[AutomationRunArtifact]) -> Value { + let expected_kinds = expected_artifact_chain_kinds(); + let present_kinds = artifacts + .iter() + .map(|artifact| artifact.kind.as_str()) + .collect::>(); + let complete = expected_kinds + .iter() + .all(|expected| present_kinds.iter().any(|present| present == expected)); + json!({ + "expected_kinds": expected_kinds, + "present_kinds": present_kinds, + "complete": complete, + }) +} + +fn expected_artifact_chain_kinds() -> Vec<&'static str> { + vec![ + AutomationRunArtifactKind::Traces.as_str(), + AutomationRunArtifactKind::Feedback.as_str(), + AutomationRunArtifactKind::GeneratedEvals.as_str(), + AutomationRunArtifactKind::ValidationGate.as_str(), + AutomationRunArtifactKind::OptimizerDiagnosis.as_str(), + AutomationRunArtifactKind::CodexHandoff.as_str(), + ] +} + +async fn enqueue_dashboard_run( + state: DashboardState, + task: AgentTaskKind, + run_job: F, +) -> (StatusCode, Json) +where + F: FnOnce(DashboardState, String) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let run_id = dashboard_run_id(task); + let queued = + match append_dashboard_job_record(&state, &run_id, task, AutomationRunStatus::Queued, None) + .await + { + Ok(record) => record, + Err(err) => return internal_error(&format!("Failed to queue automation run: {err}")), + }; + + match dashboard_job_skip_reason(&state, task).await { + Ok(Some(reason)) => { + if let Err(err) = append_immediate_skip_records(&state, &run_id, task, reason).await { + return internal_error(&format!("Failed to queue automation run: {err}")); + } + push_dashboard_task_skip_activity(&state, task, reason).await; + return ( + StatusCode::ACCEPTED, + Json(automation_job_payload(&run_id, &queued)), + ); + } + Ok(None) => {} + Err(err) => return internal_error(&format!("Failed to queue automation run: {err}")), + } + + let payload = automation_job_payload(&run_id, &queued); + tokio::spawn(async move { + Box::pin(run_dashboard_job(state, run_id, task, run_job)).await; + }); + (StatusCode::ACCEPTED, Json(payload)) +} + +async fn run_dashboard_job( + state: DashboardState, + run_id: String, + task: AgentTaskKind, + run_job: F, +) where + F: FnOnce(DashboardState, String) -> Fut, + Fut: Future>, +{ + if let Err(err) = append_running_record(&state, &run_id, task).await { + eprintln!("[tracedecay] failed to mark automation run running: {err}"); + } + + match dashboard_job_skip_reason(&state, task).await { + Ok(Some(reason)) => { + if let Err(err) = append_skipped_record(&state, &run_id, task, reason).await { + eprintln!("[tracedecay] failed to record automation run skip: {err}"); + } + push_dashboard_task_skip_activity(&state, task, reason).await; + return; + } + Ok(None) => {} + Err(err) => { + append_failed_if_missing(&state, &run_id, task, err).await; + return; + } + } + + if let Err(err) = run_job(state.clone(), run_id.clone()).await { + append_failed_if_missing(&state, &run_id, task, err).await; + } +} + +async fn dashboard_job_skip_reason( + state: &DashboardState, + task: AgentTaskKind, +) -> Result, String> { + use crate::automation::config::{AutomationBackend, AutomationHostMode}; + + let config = load_effective_dashboard_config(state).await?; + if !config.enabled { + return Ok(Some("automation_disabled")); + } + if config.host_mode == AutomationHostMode::DelegatedHost { + return Ok(Some("delegated_host_mode")); + } + if config.backend == AutomationBackend::Disabled { + return Ok(Some("backend_disabled")); + } + let task_config = match task { + AgentTaskKind::MemoryCurator => &config.tasks.memory_curator, + AgentTaskKind::SessionReflector => &config.tasks.session_reflector, + AgentTaskKind::SkillWriter => &config.tasks.skill_writer, + }; + if !task_config.enabled { + return Ok(Some(match task { + AgentTaskKind::MemoryCurator => "memory_curator_disabled", + AgentTaskKind::SessionReflector => "session_reflector_disabled", + AgentTaskKind::SkillWriter => "skill_writer_disabled", + })); + } + Ok(None) +} + +async fn append_immediate_skip_records( + state: &DashboardState, + run_id: &str, + task: AgentTaskKind, + reason: &'static str, +) -> Result<(), String> { + append_running_record(state, run_id, task).await?; + append_skipped_record(state, run_id, task, reason).await +} + +async fn append_running_record( + state: &DashboardState, + run_id: &str, + task: AgentTaskKind, +) -> Result<(), String> { + append_dashboard_job_record(state, run_id, task, AutomationRunStatus::Running, None) + .await + .map(|_| ()) + .map_err(|err| format!("failed to mark automation run running: {err}")) +} + +async fn append_skipped_record( + state: &DashboardState, + run_id: &str, + task: AgentTaskKind, + reason: &'static str, +) -> Result<(), String> { + append_dashboard_job_record( + state, + run_id, + task, + AutomationRunStatus::Skipped, + Some(reason.to_string()), + ) + .await + .map(|_| ()) + .map_err(|err| format!("failed to record automation run skip: {err}")) +} + +async fn append_failed_if_missing( + state: &DashboardState, + run_id: &str, + task: AgentTaskKind, + err: String, +) { + let terminal_exists = load_run_records(&state.dashboard_root, 200) + .await + .ok() + .into_iter() + .flatten() + .any(|record| record.run_id == run_id && record.status.is_terminal()); + if terminal_exists { + return; + } + if task == AgentTaskKind::MemoryCurator { + push_curation_activity_with_level( + state, + "failure", + format!("Dashboard memory-curator automation run failed: {err}"), + true, + "error", + ) + .await; + } + if let Err(err) = + append_dashboard_job_record(state, run_id, task, AutomationRunStatus::Failed, Some(err)) + .await + { + eprintln!("[tracedecay] failed to record automation run failure: {err}"); + } +} + +fn dashboard_task_label(task: AgentTaskKind) -> &'static str { + match task { + AgentTaskKind::MemoryCurator => "memory-curator", + AgentTaskKind::SessionReflector => "session-reflector", + AgentTaskKind::SkillWriter => "skill-writer", + } +} + +async fn push_dashboard_task_skip_activity( + state: &DashboardState, + task: AgentTaskKind, + reason: &str, +) { + let task_label = dashboard_task_label(task); + push_curation_activity( + state, + "queued", + format!("Queued dashboard {task_label} automation run"), + true, + ) + .await; + push_curation_activity( + state, + "evidence", + format!("Skipped evidence collection for dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "backend", + format!("Skipped backend call for dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "validation", + format!("Skipped dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "apply", + format!("No mutations applied for dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "report", + format!("Dashboard {task_label} automation run skipped: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "finish", + format!("Finished skipped dashboard {task_label} automation run: {reason}"), + true, + ) + .await; +} + +async fn append_dashboard_job_record( + state: &DashboardState, + run_id: &str, + task: AgentTaskKind, + status: AutomationRunStatus, + error: Option, +) -> Result { + let config = load_effective_dashboard_config(state).await?; + let record = dashboard_job_record(run_id, task, status, error, &config); + append_run_record(&state.dashboard_root, &record) + .await + .map_err(|err| err.to_string())?; + Ok(record) +} + +async fn load_effective_dashboard_config( + state: &DashboardState, +) -> Result { + let global = crate::user_config::UserConfig::load().automation; + let project = load_project_config(&state.dashboard_root) + .await + .map_err(|err| err.to_string())?; + effective_config(&global, project.as_ref()).map_err(|err| err.to_string()) +} + +fn automation_job_payload(run_id: &str, ledger_record: &AutomationRunLedgerRecord) -> Value { + json!({ + "run_id": run_id, + "dry_run": true, + "status": ledger_record.status, + "report": { + "status": ledger_record.status, + "task": task_key(ledger_record.task), + "queued": ledger_record.status == AutomationRunStatus::Queued, + }, + "ledger_record": ledger_record, + "backend_response": Value::Null, + }) +} + +fn dashboard_job_record( + run_id: &str, + task: AgentTaskKind, + status: AutomationRunStatus, + error: Option, + config: &AutomationConfig, +) -> AutomationRunLedgerRecord { + let now = current_timestamp().to_string(); + let fallback_status = error + .clone() + .filter(|_| status == AutomationRunStatus::Skipped); + let error_classification = (status == AutomationRunStatus::Failed) + .then(|| error.as_deref().map(classify_agent_task_error_message)) + .flatten(); + let contract = agent_task_contract(task); + AutomationRunLedgerRecord { + schema_version: 2, + run_id: run_id.to_string(), + trigger: AutomationTrigger::Dashboard, + task, + task_key: Some(task_key(task).to_string()), + backend: config.backend.as_str().to_string(), + host_mode: Some(config.host_mode.as_str().to_string()), + prompt_version: Some(prompt_version(task).to_string()), + response_schema: Some(contract.response_schema), + strict_json: Some(contract.strict_json), + model: config.model.clone(), + status, + evidence_hash: None, + input_hash: None, + output_hash: None, + proposed_ops: None, + applied_ops: None, + rejected_ops: None, + validation_report: None, + reviewed_count: 0, + accepted_count: 0, + rejected_count: 0, + skipped_count: usize::from(status == AutomationRunStatus::Skipped), + error, + error_classification, + error_retryable: error_classification + .map(crate::automation::backend::AgentTaskFailureClass::is_retryable), + fallback_status, + report_ref: Some(json!({ + "run_id": run_id, + "task": task_key(task), + })), + artifacts: Vec::new(), + started_at: now.clone(), + completed_at: now, + } +} + +fn dashboard_run_id(task: AgentTaskKind) -> String { + let micros = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros()) + .unwrap_or_default(); + format!("dashboard_{}_{}", task_key(task), micros) +} diff --git a/src/dashboard/automation_run_service.rs b/src/dashboard/automation_run_service.rs new file mode 100644 index 00000000..593f48e6 --- /dev/null +++ b/src/dashboard/automation_run_service.rs @@ -0,0 +1,599 @@ +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use super::memory_service::{push_curation_activity, push_curation_activity_with_level}; +use super::DashboardState; +use crate::sessions::lcm::{LcmGrepSort, LcmScope}; + +pub(crate) struct MemoryCuratorRunRequest { + pub max_clusters: usize, + pub min_confidence: f64, +} + +pub(crate) async fn curation_agent_plan_payload( + state: &DashboardState, + max_clusters: usize, + min_confidence: f64, +) -> Result { + Box::pin(curation_agent_plan_payload_with_run_id( + state, + MemoryCuratorRunRequest { + max_clusters, + min_confidence, + }, + None, + )) + .await +} + +pub(crate) async fn curation_agent_plan_payload_with_run_id( + state: &DashboardState, + request: MemoryCuratorRunRequest, + run_id: Option, +) -> Result { + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + run_memory_curator_with_backend, MemoryCuratorAutomationOptions, + }; + + push_curation_activity( + state, + "queued", + "Queued standalone memory-curator agent plan", + true, + ) + .await; + let run_context = match dashboard_automation_run_context(state).await { + Ok(context) => context, + Err(err) => { + push_curation_activity_with_level( + state, + "failure", + format!("Could not prepare memory-curator backend context: {err}"), + true, + "error", + ) + .await; + push_curation_activity( + state, + "finish", + "Finished standalone memory-curator agent plan with setup failure", + true, + ) + .await; + return Err(err); + } + }; + + push_curation_activity( + state, + "evidence", + format!( + "Collecting memory-curator evidence with up to {} cluster(s) at confidence floor {:.2}", + request.max_clusters, request.min_confidence + ), + true, + ) + .await; + push_curation_activity( + state, + "backend", + "Running standalone memory-curator backend review", + true, + ) + .await; + let run = match run_memory_curator_with_backend( + &run_context.cg, + &run_context.config, + &run_context.backend, + MemoryCuratorAutomationOptions { + trigger: AutomationTrigger::Dashboard, + run_id, + max_clusters: request.max_clusters, + min_confidence: request.min_confidence, + }, + ) + .await + { + Ok(run) => run, + Err(err) => { + push_curation_activity_with_level( + state, + "failure", + format!("Memory-curator backend review failed: {err}"), + true, + "error", + ) + .await; + push_curation_activity( + state, + "finish", + "Finished standalone memory-curator agent plan with backend failure", + true, + ) + .await; + return Err(err.to_string()); + } + }; + if run.ledger_record.fallback_status.as_deref() == Some("backend_failed_noop") { + push_curation_activity_with_level( + state, + "failure", + "Memory-curator backend was unavailable; recorded a no-op fallback run", + true, + "warning", + ) + .await; + push_curation_activity( + state, + "report", + format!( + "Agent plan {}: backend unavailable; no changes proposed", + run.ledger_record.status.as_str() + ), + true, + ) + .await; + push_curation_activity( + state, + "finish", + "Finished standalone memory-curator agent plan with no-op fallback", + true, + ) + .await; + return Ok(automation_run_payload( + &run.run_id, + &run.report, + &run.ledger_record, + run.backend_response.as_ref(), + )); + } + push_curation_activity( + state, + "validation", + format!( + "Validated backend proposal: {} accepted op(s), {} rejected op(s)", + run.ledger_record.accepted_count, run.ledger_record.rejected_count + ), + true, + ) + .await; + if run.ledger_record.rejected_count > 0 { + push_curation_activity_with_level( + state, + "rejection", + format!( + "Rejected {} backend-proposed op(s) during evidence validation", + run.ledger_record.rejected_count + ), + true, + "warning", + ) + .await; + } + let apply_policy = run + .report + .get("automation_apply_policy") + .cloned() + .unwrap_or(Value::Null); + let apply_decision = apply_policy + .get("decision") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let mutates_store = apply_policy + .get("mutates_store") + .and_then(Value::as_bool) + .unwrap_or(false); + push_curation_activity( + state, + "apply", + format!( + "Memory-curator apply policy: {apply_decision}; store mutation {}", + if mutates_store { + "performed" + } else { + "not performed" + } + ), + !mutates_store, + ) + .await; + push_curation_activity( + state, + "report", + format!( + "Agent plan {}: {} accepted op(s), {} rejected op(s)", + run.ledger_record.status.as_str(), + run.ledger_record.accepted_count, + run.ledger_record.rejected_count + ), + true, + ) + .await; + push_curation_activity( + state, + "finish", + format!( + "Finished standalone memory-curator agent plan: {}", + run.ledger_record.status.as_str() + ), + true, + ) + .await; + + Ok(automation_run_payload( + &run.run_id, + &run.report, + &run.ledger_record, + run.backend_response.as_ref(), + )) +} + +pub(crate) struct SessionReflectionRunRequest { + pub provider: Option, + pub query: Option, + pub evidence_limit: Option, + pub storage_scope: Option, + pub hermes_home: Option, + pub scope: Option, + pub session_id: Option, + pub include_summaries: Option, + pub sort: Option, + pub source: Option, + pub role: Option, + pub start_time: Option, + pub end_time: Option, +} + +pub(crate) struct SkillWritingRunRequest { + pub provider: Option, + pub query: Option, + pub evidence_limit: Option, +} + +pub(crate) async fn session_reflection_run_payload_with_run_id( + state: &DashboardState, + request: SessionReflectionRunRequest, + run_id: Option, +) -> Result { + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + run_session_reflector_with_backend, SessionReflectorAutomationOptions, + }; + + push_dashboard_automation_activity_start( + state, + "session-reflector", + "Collecting session-reflector evidence from LCM search", + "Preparing standalone session-reflector backend review", + ) + .await; + let run_context = match dashboard_automation_run_context(state).await { + Ok(context) => context, + Err(err) => { + push_dashboard_automation_activity_failure( + state, + "session-reflector", + format!("Could not prepare session-reflector backend context: {err}"), + "setup failure", + ) + .await; + return Err(err); + } + }; + let mut options = SessionReflectorAutomationOptions { + trigger: AutomationTrigger::Dashboard, + run_id, + ..SessionReflectorAutomationOptions::default() + }; + if let Some(provider) = request.provider { + options.provider = provider; + } + if let Some(query) = request.query { + options.query = query; + } + if let Some(evidence_limit) = request.evidence_limit { + options.evidence_limit = evidence_limit; + } + if let Some(storage_scope) = request.storage_scope { + options.storage_scope = storage_scope; + } + if let Some(hermes_home) = request.hermes_home { + options.hermes_home = Some(hermes_home); + } + if let Some(scope) = request.scope { + options.scope = scope; + } + if let Some(session_id) = request.session_id { + options.session_id = Some(session_id); + } + if let Some(include_summaries) = request.include_summaries { + options.include_summaries = include_summaries; + } + if let Some(sort) = request.sort { + options.sort = sort; + } + if let Some(source) = request.source { + options.source = Some(source); + } + if let Some(role) = request.role { + options.role = Some(role); + } + options.start_time = request.start_time; + options.end_time = request.end_time; + let run = match run_session_reflector_with_backend( + &run_context.cg, + &run_context.config, + &run_context.backend, + options, + ) + .await + { + Ok(run) => run, + Err(err) => { + push_dashboard_automation_activity_failure( + state, + "session-reflector", + format!("Session-reflector backend review failed: {err}"), + "backend failure", + ) + .await; + return Err(err.to_string()); + } + }; + push_dashboard_automation_activity_result(state, "session-reflector", &run.ledger_record).await; + + Ok(automation_run_payload( + &run.run_id, + &run.report, + &run.ledger_record, + run.backend_response.as_ref(), + )) +} + +pub(crate) async fn skill_writing_run_payload_with_run_id( + state: &DashboardState, + request: SkillWritingRunRequest, + run_id: Option, +) -> Result { + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{run_skill_writer_with_backend, SkillWriterAutomationOptions}; + + push_dashboard_automation_activity_start( + state, + "skill-writer", + "Collecting skill-writer evidence from LCM, managed skills, and usage telemetry", + "Preparing standalone skill-writer backend review", + ) + .await; + let run_context = match dashboard_automation_run_context(state).await { + Ok(context) => context, + Err(err) => { + push_dashboard_automation_activity_failure( + state, + "skill-writer", + format!("Could not prepare skill-writer backend context: {err}"), + "setup failure", + ) + .await; + return Err(err); + } + }; + let mut options = SkillWriterAutomationOptions { + trigger: AutomationTrigger::Dashboard, + run_id, + profile_root: None, + ..SkillWriterAutomationOptions::default() + }; + if let Some(provider) = request.provider { + options.provider = provider; + } + if let Some(query) = request.query { + options.query = query; + } + if let Some(evidence_limit) = request.evidence_limit { + options.evidence_limit = evidence_limit; + } + let run = match run_skill_writer_with_backend( + &run_context.cg, + &run_context.config, + &run_context.backend, + options, + ) + .await + { + Ok(run) => run, + Err(err) => { + push_dashboard_automation_activity_failure( + state, + "skill-writer", + format!("Skill-writer backend review failed: {err}"), + "backend failure", + ) + .await; + return Err(err.to_string()); + } + }; + push_dashboard_automation_activity_result(state, "skill-writer", &run.ledger_record).await; + + Ok(automation_run_payload( + &run.run_id, + &run.report, + &run.ledger_record, + run.backend_response.as_ref(), + )) +} + +async fn push_dashboard_automation_activity_start( + state: &DashboardState, + task_label: &str, + evidence_message: &'static str, + backend_message: &'static str, +) { + push_curation_activity( + state, + "queued", + format!("Queued dashboard {task_label} automation run"), + true, + ) + .await; + push_curation_activity( + state, + "evidence", + format!("{evidence_message} for dashboard {task_label} automation run"), + true, + ) + .await; + push_curation_activity( + state, + "backend", + format!("{backend_message} for dashboard {task_label} automation run"), + true, + ) + .await; +} + +async fn push_dashboard_automation_activity_failure( + state: &DashboardState, + task_label: &str, + message: impl Into, + finish_reason: &str, +) { + push_curation_activity_with_level(state, "failure", message, true, "error").await; + push_curation_activity( + state, + "finish", + format!("Finished dashboard {task_label} automation run with {finish_reason}"), + true, + ) + .await; +} + +async fn push_dashboard_automation_activity_result( + state: &DashboardState, + task_label: &str, + record: &crate::automation::run_ledger::AutomationRunLedgerRecord, +) { + if record.status == crate::automation::run_ledger::AutomationRunStatus::Skipped { + let reason = record.error.as_deref().unwrap_or("skipped"); + push_curation_activity( + state, + "validation", + format!("Skipped dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "apply", + format!("No mutations applied for dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "report", + format!("Dashboard {task_label} automation run skipped: {reason}"), + true, + ) + .await; + push_curation_activity( + state, + "finish", + format!("Finished skipped dashboard {task_label} automation run: {reason}"), + true, + ) + .await; + return; + } + + push_curation_activity( + state, + "validation", + format!( + "Validated dashboard {task_label} proposal: {} accepted item(s), {} rejected item(s)", + record.accepted_count, record.rejected_count + ), + true, + ) + .await; + push_curation_activity( + state, + "apply", + format!("Dashboard {task_label} run kept mutations gated behind approval controls"), + true, + ) + .await; + push_curation_activity( + state, + "report", + format!( + "Dashboard {task_label} automation run {}: {} accepted item(s), {} rejected item(s)", + record.status.as_str(), + record.accepted_count, + record.rejected_count + ), + true, + ) + .await; + push_curation_activity( + state, + "finish", + format!( + "Finished dashboard {task_label} automation run: {}", + record.status.as_str() + ), + true, + ) + .await; +} + +struct DashboardAutomationRunContext { + cg: crate::tracedecay::TraceDecay, + config: crate::automation::config::AutomationConfig, + backend: crate::automation::backend::CodexAppServerBackend, +} + +async fn dashboard_automation_run_context( + state: &DashboardState, +) -> Result { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::config::{effective_config, load_project_config, AutomationBackend}; + use crate::tracedecay::TraceDecay; + + let cg = TraceDecay::open(&state.project_root) + .await + .map_err(|e| e.to_string())?; + let global = crate::user_config::UserConfig::load().automation; + let project = load_project_config(&state.dashboard_root) + .await + .map_err(|e| e.to_string())?; + let config = effective_config(&global, project.as_ref()).map_err(|e| e.to_string())?; + if config.enabled && config.backend == AutomationBackend::ExternalCommand { + return Err("automation backend external_command is not implemented yet".to_string()); + } + let backend = CodexAppServerBackend::from_automation_config(&config); + + Ok(DashboardAutomationRunContext { + cg, + config, + backend, + }) +} + +fn automation_run_payload( + run_id: &str, + report: &Value, + ledger_record: &crate::automation::run_ledger::AutomationRunLedgerRecord, + backend_response: Option<&crate::automation::backend::AgentTaskResponse>, +) -> Value { + json!({ + "run_id": run_id, + "dry_run": true, + "status": ledger_record.status, + "report": report, + "ledger_record": ledger_record, + "backend_response": backend_response, + }) +} diff --git a/src/dashboard/automation_scheduler_api.rs b/src/dashboard/automation_scheduler_api.rs new file mode 100644 index 00000000..4a58f4c6 --- /dev/null +++ b/src/dashboard/automation_scheduler_api.rs @@ -0,0 +1,130 @@ +//! Dashboard endpoints for automation scheduler state and coarse controls. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use serde_json::{json, Value}; + +use super::util::{http_detail, JsonError}; +use super::DashboardState; +use crate::automation::backend::{task_key, AgentTaskKind}; +use crate::automation::config::{effective_config, load_project_config, AutomationConfig}; +use crate::automation::run_ledger::{load_run_records, AutomationRunLedgerRecord}; +use crate::automation::scheduler::{ + load_scheduler_control, save_scheduler_control, schedule_decision, scheduler_control_path, + AutomationSchedulerControl, +}; +use crate::tracedecay::current_timestamp; +use crate::user_config::UserConfig; + +type ApiResult = std::result::Result, JsonError>; + +pub(crate) async fn status(State(state): State) -> ApiResult { + scheduler_status_payload(&state).await +} + +pub(crate) async fn pause(State(state): State) -> ApiResult { + set_scheduler_paused(&state, true).await?; + scheduler_status_payload(&state).await +} + +pub(crate) async fn resume(State(state): State) -> ApiResult { + set_scheduler_paused(&state, false).await?; + scheduler_status_payload(&state).await +} + +async fn set_scheduler_paused( + state: &DashboardState, + paused: bool, +) -> std::result::Result<(), JsonError> { + save_scheduler_control( + &state.dashboard_root, + &AutomationSchedulerControl { paused }, + ) + .await + .map_err(|err| internal_error(&err)) +} + +async fn scheduler_status_payload(state: &DashboardState) -> ApiResult { + let global = UserConfig::load().automation; + let project = load_project_config(&state.dashboard_root) + .await + .map_err(|err| internal_error(&err))?; + let effective = + effective_config(&global, project.as_ref()).map_err(|err| internal_error(&err))?; + let control = load_scheduler_control(&state.dashboard_root) + .await + .map_err(|err| internal_error(&err))?; + let records = load_run_records(&state.dashboard_root, 200) + .await + .map_err(|err| internal_error(&err))?; + let now = current_timestamp(); + Ok(Json(json!({ + "status": scheduler_status_label(&effective, control.paused), + "paused": control.paused, + "enabled": effective.enabled, + "scheduler_tick_secs": effective.scheduler_tick_secs, + "now": now, + "project_config_path": crate::automation::config::project_config_path(&state.dashboard_root) + .display() + .to_string(), + "control_path": scheduler_control_path(&state.dashboard_root) + .display() + .to_string(), + "tasks": [ + task_status(&effective, control.paused, &records, now, AgentTaskKind::MemoryCurator), + task_status(&effective, control.paused, &records, now, AgentTaskKind::SessionReflector), + task_status(&effective, control.paused, &records, now, AgentTaskKind::SkillWriter), + ], + }))) +} + +fn task_status( + config: &AutomationConfig, + paused: bool, + records: &[AutomationRunLedgerRecord], + now: i64, + task: AgentTaskKind, +) -> Value { + let decision = if paused { + crate::automation::scheduler::AutomationScheduleDecision::skipped("scheduler_paused") + } else { + schedule_decision(config, task, records, now) + }; + let latest_scheduler = records + .iter() + .filter(|record| { + record.task == task + && record.trigger == crate::automation::run_ledger::AutomationTrigger::Scheduler + }) + .max_by_key(|record| record.completed_at.parse::().ok().unwrap_or(0)); + json!({ + "task": task_key(task), + "due": decision.is_due(), + "skip_reason": decision.skip_reason(), + "last_scheduler_run": latest_scheduler, + }) +} + +fn scheduler_status_label(config: &AutomationConfig, paused: bool) -> &'static str { + if paused { + return "paused"; + } + if !config.enabled { + return "automation_disabled"; + } + if config.host_mode == crate::automation::config::AutomationHostMode::DelegatedHost { + return "delegated_host"; + } + if config.backend == crate::automation::config::AutomationBackend::Disabled { + return "backend_disabled"; + } + "configured" +} + +fn internal_error(err: &impl ToString) -> JsonError { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&err.to_string())), + ) +} diff --git a/src/dashboard/automation_skills_api.rs b/src/dashboard/automation_skills_api.rs new file mode 100644 index 00000000..f061e646 --- /dev/null +++ b/src/dashboard/automation_skills_api.rs @@ -0,0 +1,347 @@ +//! Dashboard endpoints for profile-owned managed automation skills. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::util::{http_detail, JsonError}; +use super::DashboardState; +use crate::automation::managed_skills::{ + approve_managed_skill, create_managed_skill_draft, discard_pending_managed_skill_update, + list_managed_skills, load_managed_skill, managed_skill_dir, managed_skill_root, + save_managed_skill, set_managed_skill_state, stage_managed_skill_update, update_managed_skill, + ManagedSkill, ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, ManagedSkillState, + ManagedSkillUpdate, ManagedSupportFile, SkillInstallTarget, +}; +use crate::automation::skill_usage::{ + ingest_project_analytics_events, record_skill_usage, skill_improvement_recommendations, + stale_skill_recommendations, summarize_skill_usage, summarize_skill_usage_for, + SkillUsageAction, +}; +use crate::tracedecay::current_timestamp; + +type ApiResult = std::result::Result, JsonError>; +const SKILL_ANALYTICS_IMPORT_LIMIT: usize = 10_000; + +#[derive(Debug, Deserialize)] +pub(crate) struct ManagedSkillDraftRequest { + id: String, + title: String, + summary: String, + category: String, + #[serde(default = "crate::automation::managed_skills::default_managed_skill_targets")] + targets: Vec, + body_markdown: String, + #[serde(default)] + support_files: Vec, + #[serde(default)] + provenance: Option, + #[serde(default)] + pinned: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ManagedSkillUpdateRequest { + #[serde(default)] + base_checksum: Option, + #[serde(flatten)] + update: ManagedSkillUpdate, +} + +pub(crate) async fn list(State(state): State) -> ApiResult { + let profile_root = profile_root_or_error()?; + sync_project_skill_analytics(&profile_root, &state).await?; + let skills = list_managed_skills(&profile_root) + .await + .map_err(|err| internal_error(&err))?; + let skill_metadata = skills + .iter() + .map(|skill| skill.metadata.clone()) + .collect::>(); + let usage_summaries = summarize_skill_usage(&profile_root, &skills) + .await + .map_err(|err| internal_error(&err))?; + let stale_recommendations = + stale_skill_recommendations(&usage_summaries, current_timestamp(), 60 * 60 * 24 * 90); + let improvement_recommendations = skill_improvement_recommendations(&usage_summaries); + Ok(Json(json!({ + "profile_root": profile_root.display().to_string(), + "skills_root": managed_skill_root(&profile_root).display().to_string(), + "count": skills.len(), + "skills": skills, + "skill_metadata": skill_metadata, + "usage_summaries": usage_summaries, + "stale_recommendations": stale_recommendations, + "improvement_recommendations": improvement_recommendations, + }))) +} + +pub(crate) async fn view(State(state): State, Path(id): Path) -> ApiResult { + let profile_root = profile_root_or_error()?; + let skill = load_managed_skill(&profile_root, &id) + .await + .map_err(|err| not_found_or_internal(&err))?; + record_skill_usage( + &profile_root, + &skill, + SkillUsageAction::View, + "dashboard", + vec!["dashboard".to_string()], + Some("dashboard".to_string()), + None, + ) + .await + .map_err(|err| internal_error(&err))?; + sync_project_skill_analytics(&profile_root, &state).await?; + skill_payload(&profile_root, skill).await +} + +pub(crate) async fn draft( + State(_state): State, + Json(request): Json, +) -> ApiResult { + let profile_root = profile_root_or_error()?; + reject_existing_managed_skill(&profile_root, &request.id).await?; + let pinned = request.pinned; + let mut skill = create_managed_skill_draft(&profile_root, request.into_draft()) + .await + .map_err(|err| bad_request_or_internal(&err))?; + if let Some(pinned) = pinned { + skill.set_pinned(pinned); + save_managed_skill(&profile_root, &skill) + .await + .map_err(|err| internal_error(&err))?; + } + skill_payload(&profile_root, skill).await +} + +async fn reject_existing_managed_skill( + profile_root: &std::path::Path, + id: &str, +) -> std::result::Result<(), JsonError> { + match load_managed_skill(profile_root, id).await { + Ok(_) => Err(conflict(&format!( + "managed skill '{id}' already exists; use PATCH to update it" + ))), + Err(err) => { + let message = err.to_string(); + if is_not_found(&message) { + Ok(()) + } else { + Err(not_found_bad_request_or_internal(&message)) + } + } + } +} + +pub(crate) async fn update( + State(_state): State, + Path(id): Path, + Json(request): Json, +) -> ApiResult { + let profile_root = profile_root_or_error()?; + let current = load_managed_skill(&profile_root, &id) + .await + .map_err(|err| not_found_or_internal(&err))?; + let skill = (if current.metadata.state == ManagedSkillState::PendingApproval + && current.pending_update.is_none() + { + update_managed_skill(&profile_root, &id, request.update).await + } else { + let base_checksum = request.base_checksum.as_deref().ok_or_else(|| { + bad_request(&format!( + "base_checksum is required to stage managed skill update for '{id}'" + )) + })?; + match stage_managed_skill_update(&profile_root, &id, base_checksum, request.update).await { + Ok(_) => load_managed_skill(&profile_root, &id).await, + Err(err) => Err(err), + } + }) + .map_err(|err| not_found_bad_request_or_internal(&err))?; + skill_payload(&profile_root, skill).await +} + +pub(crate) async fn approve( + State(_state): State, + Path(id): Path, +) -> ApiResult { + let profile_root = profile_root_or_error()?; + let skill = approve_managed_skill(&profile_root, &id) + .await + .map_err(|err| not_found_or_internal(&err))?; + skill_payload(&profile_root, skill).await +} + +pub(crate) async fn discard_update( + State(_state): State, + Path(id): Path, +) -> ApiResult { + let profile_root = profile_root_or_error()?; + let skill = discard_pending_managed_skill_update(&profile_root, &id) + .await + .map_err(|err| not_found_or_internal(&err))?; + skill_payload(&profile_root, skill).await +} + +pub(crate) async fn disable( + State(_state): State, + Path(id): Path, +) -> ApiResult { + set_state(&id, ManagedSkillState::Disabled).await +} + +pub(crate) async fn archive( + State(_state): State, + Path(id): Path, +) -> ApiResult { + set_state(&id, ManagedSkillState::Archived).await +} + +pub(crate) async fn restore( + State(_state): State, + Path(id): Path, +) -> ApiResult { + set_state(&id, ManagedSkillState::PendingApproval).await +} + +async fn set_state(id: &str, state: ManagedSkillState) -> ApiResult { + let profile_root = profile_root_or_error()?; + let skill = set_managed_skill_state(&profile_root, id, state) + .await + .map_err(|err| not_found_or_internal(&err))?; + skill_payload(&profile_root, skill).await +} + +impl ManagedSkillDraftRequest { + fn into_draft(self) -> ManagedSkillDraft { + ManagedSkillDraft { + id: self.id, + title: self.title, + summary: self.summary, + category: self.category, + targets: self.targets, + body_markdown: self.body_markdown, + support_files: self.support_files, + provenance: self.provenance.unwrap_or(ManagedSkillProvenance { + source: ManagedSkillSource::UserDraft, + actor: "dashboard".to_string(), + run_id: None, + }), + } + } +} + +async fn skill_payload(profile_root: &std::path::Path, skill: ManagedSkill) -> ApiResult { + let skill_dir = managed_skill_dir(profile_root, &skill.metadata.id) + .map_err(|err| bad_request_or_internal(&err))?; + let usage_summary = summarize_skill_usage_for(profile_root, &skill) + .await + .map_err(|err| internal_error(&err))?; + let stale_recommendation = stale_skill_recommendations( + std::slice::from_ref(&usage_summary), + current_timestamp(), + 60 * 60 * 24 * 90, + ) + .into_iter() + .next(); + let improvement_recommendation = + skill_improvement_recommendations(std::slice::from_ref(&usage_summary)) + .into_iter() + .next(); + Ok(Json(json!({ + "profile_root": profile_root.display().to_string(), + "skills_root": managed_skill_root(profile_root).display().to_string(), + "skill_dir": skill_dir.display().to_string(), + "skill": skill, + "usage_summary": usage_summary, + "stale_recommendation": stale_recommendation, + "improvement_recommendation": improvement_recommendation, + }))) +} + +async fn sync_project_skill_analytics( + profile_root: &std::path::Path, + state: &DashboardState, +) -> std::result::Result<(), JsonError> { + ingest_project_analytics_events( + profile_root, + &state.project_root, + state.savings_db.as_deref(), + SKILL_ANALYTICS_IMPORT_LIMIT, + ) + .await + .map(|_| ()) + .map_err(|err| internal_error(&err)) +} + +fn profile_root_or_error() -> std::result::Result { + crate::storage::default_profile_root().map_err(|err| internal_error(&err)) +} + +fn bad_request(err: &impl ToString) -> JsonError { + (StatusCode::BAD_REQUEST, Json(http_detail(&err.to_string()))) +} + +fn bad_request_or_internal(err: &impl ToString) -> JsonError { + client_error_or_internal(err, false, true) +} + +fn not_found_or_internal(err: &impl ToString) -> JsonError { + client_error_or_internal(err, true, false) +} + +fn not_found_bad_request_or_internal(err: &impl ToString) -> JsonError { + client_error_or_internal(err, true, true) +} + +fn client_error_or_internal( + err: &impl ToString, + allow_not_found: bool, + allow_bad_request: bool, +) -> JsonError { + let message = err.to_string(); + if allow_not_found && is_not_found(&message) { + not_found(&message) + } else if allow_bad_request && is_bad_request(&message) { + bad_request(&message) + } else { + internal_error(&message) + } +} + +fn is_not_found(message: &str) -> bool { + message.contains("No such file") || message.contains("not found") +} + +fn not_found(message: &str) -> JsonError { + (StatusCode::NOT_FOUND, Json(http_detail(message))) +} + +fn conflict(message: &str) -> JsonError { + (StatusCode::CONFLICT, Json(http_detail(message))) +} + +fn is_bad_request(message: &str) -> bool { + message.contains("unsafe") + || message.contains("cannot be empty") + || message.contains("duplicate") + || message.contains("conflicts with") + || message.contains("exceeds") + || message.contains("must be under") + || message.contains("must name a file") + || message.contains("failed to parse") + || message.contains("base_checksum") + || message.contains("stale") + || message.contains("pending update") + || message.contains("does not change") +} + +fn internal_error(err: &impl ToString) -> JsonError { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&err.to_string())), + ) +} diff --git a/src/dashboard/memory_analysis.rs b/src/dashboard/memory_analysis.rs index 061c688a..51502612 100644 --- a/src/dashboard/memory_analysis.rs +++ b/src/dashboard/memory_analysis.rs @@ -359,6 +359,13 @@ pub(crate) const ACCESS_RELUCTANCE_EXTREME_SIMILARITY: f64 = 0.98; /// negation/state-change cue only signals supersession when the two facts are /// substantially similar (mirrors the write-time conflict threshold). pub(crate) const SUPERSESSION_SIMILARITY_THRESHOLD: f64 = 0.7; +const SEMANTIC_FRESHNESS_FIELDS: [&str; 5] = [ + "asserted_at", + "effective_at", + "observed_at", + "occurred_at", + "created_at", +]; fn pair_has_supersession_cue(facts: &[Value], a: usize, b: usize) -> bool { let a_content = facts[a] @@ -471,6 +478,39 @@ fn fact_i64(fact: &Value, key: &str) -> i64 { fact.get(key).and_then(Value::as_i64).unwrap_or(0) } +fn metadata_value(fact: &Value) -> Option { + let metadata = fact.get("metadata")?; + if metadata.is_object() { + return Some(metadata.clone()); + } + metadata + .as_str() + .and_then(|raw| serde_json::from_str::(raw).ok()) +} + +fn timestamp_i64(value: Option<&Value>) -> Option { + value.and_then(|value| { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok())) + .or_else(|| value.as_str().and_then(|raw| raw.parse::().ok())) + }) +} + +fn semantic_freshness(fact: &Value) -> (i64, &'static str) { + let metadata = metadata_value(fact); + for field in SEMANTIC_FRESHNESS_FIELDS { + if let Some(value) = metadata + .as_ref() + .and_then(|metadata| timestamp_i64(metadata.get(field))) + .or_else(|| timestamp_i64(fact.get(field))) + { + return (value, field); + } + } + (0, "created_at") +} + fn candidate_confidence(base: f64, fact: &Value) -> f64 { let trust = fact .get("trust_score") @@ -555,18 +595,21 @@ pub(crate) fn propose_hygiene_candidates( { continue; } - // Older = smaller created_at; on a tie, the smaller fact_id. - let (older, newer) = match fact_i64(a, "created_at").cmp(&fact_i64(b, "created_at")) { - std::cmp::Ordering::Less => (a, b), - std::cmp::Ordering::Greater => (b, a), - std::cmp::Ordering::Equal => { - if fact_i64(a, "fact_id") <= fact_i64(b, "fact_id") { - (a, b) - } else { - (b, a) + // Older = smaller semantic freshness timestamp; on a tie, the smaller fact_id. + let (a_freshness, a_freshness_field) = semantic_freshness(a); + let (b_freshness, b_freshness_field) = semantic_freshness(b); + let (older, newer, freshness_field, newer_freshness_field) = + match a_freshness.cmp(&b_freshness) { + std::cmp::Ordering::Less => (a, b, a_freshness_field, b_freshness_field), + std::cmp::Ordering::Greater => (b, a, b_freshness_field, a_freshness_field), + std::cmp::Ordering::Equal => { + if fact_i64(a, "fact_id") <= fact_i64(b, "fact_id") { + (a, b, a_freshness_field, b_freshness_field) + } else { + (b, a, b_freshness_field, a_freshness_field) + } } - } - }; + }; let older_id = fact_i64(older, "fact_id"); let newer_id = fact_i64(newer, "fact_id"); if flagged.contains(&older_id) || !proposed.insert(older_id) { @@ -586,6 +629,8 @@ pub(crate) fn propose_hygiene_candidates( "review_required": true, "status": "candidate", "access_count": fact_i64(older, "access_count"), + "freshness_field": freshness_field, + "superseded_by_freshness_field": newer_freshness_field, "tier": "supersession", })); } @@ -779,6 +824,40 @@ mod tests { assert_eq!(supersession[0]["tier"], "supersession"); } + #[test] + fn supersession_prefers_semantic_timestamps_over_created_at_and_updated_at() { + let facts = vec![ + json!({ + "fact_id": 1, + "content": "The ingestion pipeline uses Redis for queueing", + "trust_score": 0.8, + "created_at": 100, + "updated_at": 500, + "metadata": {"asserted_at": 10} + }), + json!({ + "fact_id": 2, + "content": "The ingestion pipeline no longer uses Redis for queueing", + "trust_score": 0.8, + "created_at": 50, + "updated_at": 100, + "metadata": {"asserted_at": 200} + }), + ]; + let pairs = vec![ScoredPair::analyze(&facts, 0.91, 0, 1)]; + + let hygiene_candidates = + propose_hygiene_candidates(&facts, &facts, &pairs, &std::collections::HashSet::new()); + let supersession = hygiene_candidates["supersession"] + .as_array() + .unwrap_or_else(|| panic!("expected supersession array")); + + assert_eq!(supersession.len(), 1); + assert_eq!(supersession[0]["fact_id"], 1); + assert_eq!(supersession[0]["superseded_by"], 2); + assert_eq!(supersession[0]["freshness_field"], "asserted_at"); + } + #[test] fn propose_hygiene_candidates_respects_exclusions_and_thresholds() { let facts = vec![ diff --git a/src/dashboard/memory_api.rs b/src/dashboard/memory_api.rs index f90a7794..0ac16bf3 100644 --- a/src/dashboard/memory_api.rs +++ b/src/dashboard/memory_api.rs @@ -16,12 +16,13 @@ //! `holographic_plus` soft-archived facts; tracedecay does not). //! - Banks are named after their category directly (no `cat:` prefix). -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::Json; use serde::Deserialize; use serde_json::{json, Map, Value}; +use super::automation_run_service; use super::memory_analysis::{SIMILARITY_DEFAULT_THRESHOLD, SIMILARITY_PAIR_CAP}; use super::memory_service; use super::util::{coerce_limit, http_detail, query_i64, JsonPath, JsonQuery}; @@ -57,13 +58,30 @@ pub(crate) struct LimitParams { limit: Option, } +#[derive(Deserialize)] +pub(crate) struct FactProposalParams { + state: Option, + limit: Option, +} + +#[derive(Deserialize, Default)] +pub(crate) struct FactProposalApplyBody { + reviewer: Option, +} + +#[derive(Deserialize, Default)] +pub(crate) struct FactProposalRejectBody { + reviewer: Option, + reason: Option, +} + #[derive(Deserialize, Default)] pub(crate) struct CurateBody { #[serde(default = "default_dry_run")] dry_run: bool, } -fn default_dry_run() -> bool { +pub(crate) fn default_dry_run() -> bool { true } @@ -72,6 +90,24 @@ pub(crate) struct CurateApplyBody { ops: Vec, } +#[derive(Deserialize)] +pub(crate) struct AgentPlanBody { + #[serde(default = "default_dry_run")] + dry_run: bool, + #[serde(default = "default_agent_plan_max_clusters")] + max_clusters: usize, + #[serde(default = "default_agent_plan_min_confidence")] + min_confidence: f64, +} + +pub(crate) fn default_agent_plan_max_clusters() -> usize { + crate::dashboard::memory_curate::CURATION_DEFAULT_MAX_CLUSTERS +} + +pub(crate) fn default_agent_plan_min_confidence() -> f64 { + crate::dashboard::memory_curate::CURATION_DEFAULT_MIN_CONFIDENCE +} + async fn largest_bank_fact_count(state: &DashboardState) -> Result { let mut rows = state .mem_conn @@ -358,12 +394,186 @@ pub(crate) async fn curation_activity( Json(memory_service::curation_activity_payload(&state, limit).await) } +/// `GET /api/plugins/holographic/curation/runs` — recent standalone +/// automation backend runs, loaded from the append-only project sidecar ledger. +pub(crate) async fn curation_runs( + State(state): State, + JsonQuery(params): JsonQuery, +) -> Json { + let limit = coerce_limit(params.limit, 50, 200) as usize; + match crate::automation::run_ledger::load_run_records(&state.dashboard_root, limit).await { + Ok(records) => { + let count = records.len(); + Json(json!({ + "records": records, + "count": count, + "limit": limit, + "error": "", + })) + } + Err(err) => Json(json!({ + "records": [], + "count": 0, + "limit": limit, + "error": err.to_string(), + })), + } +} + +/// `GET /api/plugins/holographic/fact-proposals` — session-reflector fact +/// proposals awaiting approval, plus historical applied/rejected decisions. +pub(crate) async fn fact_proposals( + State(state): State, + JsonQuery(params): JsonQuery, +) -> (StatusCode, Json) { + let proposal_state = match parse_fact_proposal_state(params.state.as_deref()) { + Ok(state) => state, + Err(message) => return (StatusCode::BAD_REQUEST, Json(http_detail(&message))), + }; + let limit = coerce_limit(params.limit, 50, 200) as usize; + match crate::automation::fact_proposals::list_fact_proposals( + &state.dashboard_root, + proposal_state, + limit, + ) + .await + { + Ok(proposals) => ( + StatusCode::OK, + Json(json!({ + "proposals": proposals, + "count": proposals.len(), + "limit": limit, + "error": "", + })), + ), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&err.to_string())), + ), + } +} + +/// `POST /api/plugins/holographic/fact-proposals/{proposal_id}/apply` — +/// approval-gated session-reflector fact write. +pub(crate) async fn fact_proposal_apply( + State(state): State, + Path(proposal_id): Path, + body: Option>, +) -> (StatusCode, Json) { + let reviewer = body.and_then(|body| body.0.reviewer); + match crate::automation::fact_proposals::apply_fact_proposal( + &state.dashboard_root, + &state.mem_conn, + &proposal_id, + reviewer, + ) + .await + { + Ok(proposal) => ( + StatusCode::OK, + Json(json!({ + "proposal": proposal, + "error": "", + })), + ), + Err(err) => fact_proposal_error(&err), + } +} + +/// `POST /api/plugins/holographic/fact-proposals/{proposal_id}/reject` — +/// explicit rejection for a pending session-reflector proposal. +pub(crate) async fn fact_proposal_reject( + State(state): State, + Path(proposal_id): Path, + body: Option>, +) -> (StatusCode, Json) { + let body = body.map(|body| body.0).unwrap_or_default(); + match crate::automation::fact_proposals::reject_fact_proposal( + &state.dashboard_root, + &proposal_id, + body.reviewer, + body.reason, + ) + .await + { + Ok(proposal) => ( + StatusCode::OK, + Json(json!({ + "proposal": proposal, + "error": "", + })), + ), + Err(err) => fact_proposal_error(&err), + } +} + +fn parse_fact_proposal_state( + state: Option<&str>, +) -> Result, String> { + use crate::automation::fact_proposals::FactProposalState; + + let Some(state) = state else { + return Ok(None); + }; + match state.trim().to_ascii_lowercase().as_str() { + "" => Ok(None), + "pending" | "pending_approval" => Ok(Some(FactProposalState::PendingApproval)), + "applied" => Ok(Some(FactProposalState::Applied)), + "rejected" => Ok(Some(FactProposalState::Rejected)), + _ => Err(format!( + "unknown fact proposal state '{state}' (expected pending_approval, applied, rejected)" + )), + } +} + +fn fact_proposal_error(err: &crate::errors::TraceDecayError) -> (StatusCode, Json) { + let message = err.to_string(); + let status = if message.contains("not found") { + StatusCode::NOT_FOUND + } else if message.contains("not pending") || message.contains("no add_fact_request") { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, Json(http_detail(&message))) +} + /// `GET /api/plugins/holographic/curation/preview` — returns the last saved /// dry-run preview, or null if none has been run this server session. pub(crate) async fn curation_preview(State(state): State) -> Json { Json(memory_service::curation_preview_payload(&state).await) } +/// `POST /api/plugins/holographic/curation/agent-plan` — standalone backend +/// curation planner. Delegated-host mode skips TraceDecay-owned backend calls. +pub(crate) async fn curation_agent_plan( + State(state): State, + axum::Json(body): axum::Json, +) -> (StatusCode, Json) { + if !body.dry_run { + return ( + StatusCode::BAD_REQUEST, + Json(http_detail( + "agent-plan currently supports dry_run=true only; apply validated ops separately", + )), + ); + } + match Box::pin(automation_run_service::curation_agent_plan_payload( + &state, + body.max_clusters, + body.min_confidence, + )) + .await + { + Ok(payload) => (StatusCode::OK, Json(payload)), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&format!("Agent curation plan failed: {e}"))), + ), + } +} + /// `POST /api/plugins/holographic/curate` — similarity-based deduplication /// curation. `dry_run=true` (default) returns the proposed plan without /// mutating; `dry_run=false` applies the plan by hard-DELETING duplicate diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index dcd2e61c..0ba32eca 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use serde_json::{json, Map, Value}; use tokio::sync::RwLock; +use super::memory_queries::normalize_fact_metadata; use super::memory_service::{ apply_delete_op, apply_merge_op, build_delete_plan, delete_fact, similarity_computation, }; @@ -47,8 +48,10 @@ and facts that merely share an entity should remain separate (use \ \"keep\").\n\n\ Conflict policy: when two facts about the SAME subject conflict, keep \ the higher-trust one and delete the stale one. Only use age/recency \ -after the same-subject / same-claim conflict is established (created_at \ -is the freshness signal; updated_at is maintenance metadata). If the \ +after the same-subject / same-claim conflict is established. Freshness \ +signals, in order, are asserted_at, effective_at, observed_at, occurred_at, \ +then created_at; these may appear in metadata. updated_at is maintenance \ +metadata, never truth freshness. If the \ facts describe an EVOLUTION over time (a preference pivot, not a true \ contradiction, e.g. 'used React' then 'switched to Vue'), emit a merge \ whose merged_content is ONE time-aware fact built strictly from the \ @@ -410,7 +413,7 @@ async fn fact_details( } let ids: Vec = fact_ids.iter().copied().collect(); let sql = format!( - "SELECT fact_id, content, category, tags, trust_score, created_at, updated_at, + "SELECT fact_id, content, category, tags, trust_score, metadata, created_at, updated_at, access_count, last_recalled_at FROM memory_facts WHERE fact_id IN ({})", qmarks(ids.len()) @@ -422,6 +425,7 @@ async fn fact_details( message: format!("fact detail query failed: {message}"), })?; for row in rows { + let row = normalize_fact_metadata(row); if let Some(fact_id) = row.get("fact_id").and_then(Value::as_i64) { details.insert(fact_id, row); } diff --git a/src/dashboard/memory_queries.rs b/src/dashboard/memory_queries.rs index ab7dd52a..414e58ac 100644 --- a/src/dashboard/memory_queries.rs +++ b/src/dashboard/memory_queries.rs @@ -8,18 +8,28 @@ use crate::memory::encoding::HolographicEncoder; pub(crate) type VectorStateFingerprint = (i64, i64, i64, u64); +pub(crate) fn normalize_fact_metadata(mut row: Value) -> Value { + if let Some(obj) = row.as_object_mut() { + if let Some(raw) = obj.get("metadata").and_then(Value::as_str) { + let parsed = serde_json::from_str::(raw).unwrap_or(Value::Null); + obj.insert("metadata".to_string(), parsed); + } + } + row +} + pub(crate) async fn fact_rows( state: &DashboardState, query: &str, limit: i64, ) -> Result, String> { let q = query.trim(); - if q.is_empty() { + let rows = if q.is_empty() { query_rows( &state.mem_conn, "SELECT fact_id, content, category, tags, trust_score, retrieval_count, access_count, last_recalled_at, - helpful_count, created_at, updated_at, + helpful_count, metadata, created_at, updated_at, hrr_vector IS NOT NULL AS has_hrr FROM memory_facts ORDER BY trust_score DESC, updated_at DESC @@ -33,7 +43,7 @@ pub(crate) async fn fact_rows( &state.mem_conn, "SELECT fact_id, content, category, tags, trust_score, retrieval_count, access_count, last_recalled_at, - helpful_count, created_at, updated_at, + helpful_count, metadata, created_at, updated_at, hrr_vector IS NOT NULL AS has_hrr FROM memory_facts WHERE content LIKE ?1 ESCAPE '\\' OR tags LIKE ?1 ESCAPE '\\' @@ -42,7 +52,8 @@ pub(crate) async fn fact_rows( libsql::params![like, limit], ) .await - } + }?; + Ok(rows.into_iter().map(normalize_fact_metadata).collect()) } pub(crate) async fn entity_rows(state: &DashboardState, limit: i64) -> Result, String> { @@ -215,7 +226,7 @@ pub(crate) async fn fact_detail_row( &state.mem_conn, "SELECT fact_id, content, category, tags, trust_score, retrieval_count, access_count, last_recalled_at, - helpful_count, created_at, updated_at, + helpful_count, metadata, created_at, updated_at, hrr_vector IS NOT NULL AS has_hrr FROM memory_facts WHERE fact_id = ?1 @@ -223,7 +234,7 @@ pub(crate) async fn fact_detail_row( libsql::params![fact_id], ) .await?; - Ok(rows.into_iter().next()) + Ok(rows.into_iter().next().map(normalize_fact_metadata)) } pub(crate) async fn fact_entities( @@ -252,7 +263,7 @@ pub(crate) async fn vector_facts( ( "SELECT f.fact_id, f.content, f.category, f.trust_score, f.retrieval_count, f.hrr_vector, b.bank_id, b.bank_name, COUNT(fe.entity_id) AS entity_count, - f.access_count, f.last_recalled_at, f.created_at + f.access_count, f.last_recalled_at, f.created_at, f.updated_at, f.metadata FROM memory_facts f LEFT JOIN memory_banks b ON b.bank_name = f.category LEFT JOIN memory_fact_entities fe ON fe.fact_id = f.fact_id @@ -267,7 +278,7 @@ pub(crate) async fn vector_facts( ( "SELECT f.fact_id, f.content, f.category, f.trust_score, f.retrieval_count, f.hrr_vector, b.bank_id, b.bank_name, COUNT(fe.entity_id) AS entity_count, - f.access_count, f.last_recalled_at, f.created_at + f.access_count, f.last_recalled_at, f.created_at, f.updated_at, f.metadata FROM memory_facts f LEFT JOIN memory_banks b ON b.bank_name = f.category LEFT JOIN memory_fact_entities fe ON fe.fact_id = f.fact_id @@ -317,6 +328,12 @@ pub(crate) async fn vector_facts( let access_count: i64 = row.get(9).unwrap_or(0); let last_recalled_at: Option = row.get(10).unwrap_or(None); let created_at: i64 = row.get(11).unwrap_or(0); + let updated_at: i64 = row.get(12).unwrap_or(0); + let metadata = row + .get::(13) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or(Value::Null); out.push(( serde_json::json!({ "fact_id": fact_id, @@ -327,6 +344,8 @@ pub(crate) async fn vector_facts( "access_count": access_count, "last_recalled_at": last_recalled_at, "created_at": created_at, + "updated_at": updated_at, + "metadata": metadata, "bank_id": bank_id, "bank_name": bank_name, "entity_count": entity_count, diff --git a/src/dashboard/memory_service.rs b/src/dashboard/memory_service.rs index 91aff6e0..2f5b2511 100644 --- a/src/dashboard/memory_service.rs +++ b/src/dashboard/memory_service.rs @@ -338,6 +338,9 @@ fn projection_point(meta: &Value, x: f64, y: f64) -> Value { "content": meta.get("content").and_then(Value::as_str).map(|s| s.chars().take(200).collect::()).unwrap_or_default(), "trust_score": meta.get("trust_score").cloned().unwrap_or(json!(0.0)), "retrieval_count": meta.get("retrieval_count").cloned().unwrap_or(json!(0)), + "created_at": meta.get("created_at").cloned().unwrap_or(json!(0)), + "updated_at": meta.get("updated_at").cloned().unwrap_or(json!(0)), + "metadata": meta.get("metadata").cloned().unwrap_or(Value::Null), "bank_id": meta.get("bank_id").cloned().unwrap_or(Value::Null), "bank_name": meta.get("bank_name").cloned().unwrap_or(Value::Null), "entity_count": meta.get("entity_count").cloned().unwrap_or(json!(0)), @@ -641,18 +644,28 @@ pub(crate) async fn curation_status_payload(state: &DashboardState) -> Value { }) } -async fn push_curation_activity( +pub(crate) async fn push_curation_activity( state: &DashboardState, phase: &str, message: impl Into, dry_run: bool, +) { + push_curation_activity_with_level(state, phase, message, dry_run, "info").await; +} + +pub(crate) async fn push_curation_activity_with_level( + state: &DashboardState, + phase: &str, + message: impl Into, + dry_run: bool, + level: &str, ) { let mut events = state.curation_activity.write().await; events.push(json!({ "ts": crate::timeutil::now_iso_utc(), "phase": phase, "message": message.into(), - "level": "info", + "level": level, "dry_run": dry_run, })); if events.len() > 300 { @@ -748,6 +761,17 @@ pub(crate) async fn delete_fact(state: &DashboardState, fact_id: i64) -> Result< } pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Result { + push_curation_activity( + state, + "queued", + if dry_run { + "Queued similarity-dedup curation preview" + } else { + "Queued similarity-dedup curation apply" + }, + dry_run, + ) + .await; push_curation_activity( state, "start", @@ -759,7 +783,45 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res dry_run, ) .await; - let (actions, hygiene_candidates, counts, total) = build_delete_plan(state).await?; + push_curation_activity( + state, + "evidence", + "Collecting similarity and hygiene evidence", + dry_run, + ) + .await; + push_curation_activity( + state, + "backend", + "Running deterministic similarity-dedup planner", + dry_run, + ) + .await; + let (actions, hygiene_candidates, counts, total) = match build_delete_plan(state).await { + Ok(plan) => plan, + Err(err) => { + push_curation_activity_with_level( + state, + "failure", + format!("Curation evidence collection failed: {err}"), + dry_run, + "error", + ) + .await; + return Err(err); + } + }; + push_curation_activity( + state, + "validation", + format!( + "Validated deterministic curation plan: {} delete action(s), {} hygiene candidate(s)", + actions.len(), + hygiene_candidates.as_array().map_or(0, Vec::len) + ), + dry_run, + ) + .await; let report = json!({ "ran": true, @@ -791,6 +853,17 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res }; super::curate_preview_store::save(&state.dashboard_root, &entry).await; *state.curate_preview.write().await = Some(entry); + push_curation_activity( + state, + "report", + format!( + "Preview report ready: {} delete action(s), {} active fact(s) scanned", + actions.len(), + total + ), + true, + ) + .await; push_curation_activity( state, "finish", @@ -807,6 +880,27 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res let mut applied = 0i64; let mut skipped = 0i64; + push_curation_activity( + state, + "report", + format!( + "Apply report ready: {} delete action(s), {} active fact(s) scanned", + actions.len(), + total + ), + false, + ) + .await; + push_curation_activity( + state, + "apply", + format!( + "Applying {} deterministic curation action(s)", + actions.len() + ), + false, + ) + .await; if let Some(action_list) = report.get("actions").and_then(Value::as_array) { for action in action_list { let Some(fact_id) = action.get("fact_id").and_then(Value::as_i64) else { @@ -835,6 +929,16 @@ pub(crate) async fn curate_payload(state: &DashboardState, dry_run: bool) -> Res if applied > 0 { applied_counts.insert("delete".to_string(), json!(applied)); } + if skipped > 0 { + push_curation_activity_with_level( + state, + "rejection", + format!("{skipped} deterministic curation action(s) were skipped during apply"), + false, + "warning", + ) + .await; + } push_curation_activity( state, "finish", @@ -963,6 +1067,20 @@ pub(crate) async fn apply_merge_op(state: &DashboardState, op: &Value) -> (Value } pub(crate) async fn curate_apply_payload(state: &DashboardState, ops: &[Value]) -> Value { + push_curation_activity( + state, + "queued", + format!("Queued explicit apply for {} curation op(s)", ops.len()), + false, + ) + .await; + push_curation_activity( + state, + "apply", + format!("Applying {} explicit curation op(s)", ops.len()), + false, + ) + .await; let mut results: Vec = Vec::with_capacity(ops.len()); let mut deleted = 0i64; let mut merged = 0i64; @@ -994,6 +1112,25 @@ pub(crate) async fn curate_apply_payload(state: &DashboardState, ops: &[Value]) results.push(result); } + push_curation_activity( + state, + "validation", + format!( + "Validated explicit apply results: {deleted} delete op(s), {merged} merge op(s), {errors} error(s)" + ), + false, + ) + .await; + if errors > 0 { + push_curation_activity_with_level( + state, + "rejection", + format!("{errors} explicit curation op(s) were rejected or failed"), + false, + "warning", + ) + .await; + } if deleted > 0 || merged > 0 { *state.curate_preview.write().await = None; super::curate_preview_store::clear(&state.dashboard_root).await; @@ -1004,16 +1141,35 @@ pub(crate) async fn curate_apply_payload(state: &DashboardState, ops: &[Value]) &json!({ "mode": "ops", "deleted": deleted, "merged": merged, "errors": errors }), ) .await; - push_curation_activity( + } + push_curation_activity( + state, + "report", + format!( + "Explicit apply report ready: {deleted} delete op(s), {merged} merge op(s), {errors} error(s)" + ), + false, + ) + .await; + if errors > 0 && deleted == 0 && merged == 0 { + push_curation_activity_with_level( state, - "finish", - format!( - "Explicit apply completed: {deleted} delete op(s), {merged} merge op(s), {errors} op(s) errored" - ), + "failure", + format!("All {errors} explicit curation op(s) failed validation or apply"), false, + "error", ) .await; } + push_curation_activity( + state, + "finish", + format!( + "Explicit apply completed: {deleted} delete op(s), {merged} merge op(s), {errors} op(s) errored" + ), + false, + ) + .await; json!({ "results": results, diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 9c031bc6..507d08fb 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -23,6 +23,12 @@ mod analytics_api; pub(crate) mod assets; +mod automation_config_api; +mod automation_fact_proposals_api; +mod automation_run_api; +mod automation_run_service; +mod automation_scheduler_api; +mod automation_skills_api; mod curate_preview_store; mod graph_api; mod graph_queries; @@ -50,6 +56,8 @@ use axum::Router; use serde_json::{json, Value}; use tokio::sync::RwLock; +use crate::automation::backend; +use crate::automation::config::{self, AutomationBackend, AutomationHostMode}; use crate::db::Database; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; @@ -376,10 +384,116 @@ pub(crate) fn router(state: DashboardState) -> Router { "/api/plugins/holographic/curation/activity", get(memory_api::curation_activity), ) + .route( + "/api/plugins/holographic/curation/runs", + get(memory_api::curation_runs), + ) + .route( + "/api/plugins/holographic/fact-proposals", + get(memory_api::fact_proposals), + ) + .route( + "/api/plugins/holographic/fact-proposals/{proposal_id}/apply", + post(memory_api::fact_proposal_apply), + ) + .route( + "/api/plugins/holographic/fact-proposals/{proposal_id}/reject", + post(memory_api::fact_proposal_reject), + ) .route( "/api/plugins/holographic/curation/preview", get(memory_api::curation_preview), ) + .route( + "/api/plugins/holographic/curation/config", + get(automation_config_api::get_config) + .patch(automation_config_api::patch_config) + .delete(automation_config_api::reset_config), + ) + .route( + "/api/plugins/holographic/curation/agent-plan", + post(memory_api::curation_agent_plan), + ) + .route( + "/api/automation/skills", + get(automation_skills_api::list).post(automation_skills_api::draft), + ) + .route( + "/api/automation/skills/draft", + post(automation_skills_api::draft), + ) + .route( + "/api/automation/skills/{id}", + get(automation_skills_api::view).patch(automation_skills_api::update), + ) + .route( + "/api/automation/skills/{id}/approve", + post(automation_skills_api::approve), + ) + .route( + "/api/automation/skills/{id}/discard-update", + post(automation_skills_api::discard_update), + ) + .route( + "/api/automation/skills/{id}/disable", + post(automation_skills_api::disable), + ) + .route( + "/api/automation/skills/{id}/archive", + post(automation_skills_api::archive), + ) + .route( + "/api/automation/skills/{id}/restore", + post(automation_skills_api::restore), + ) + .route( + "/api/automation/fact-proposals", + get(automation_fact_proposals_api::list), + ) + .route( + "/api/automation/fact-proposals/{id}", + get(automation_fact_proposals_api::view), + ) + .route( + "/api/automation/fact-proposals/{id}/apply", + post(automation_fact_proposals_api::apply), + ) + .route( + "/api/automation/fact-proposals/{id}/reject", + post(automation_fact_proposals_api::reject), + ) + .route( + "/api/automation/run/memory-curator", + post(automation_run_api::memory_curator), + ) + .route( + "/api/automation/run/session-reflection", + post(automation_run_api::session_reflection), + ) + .route( + "/api/automation/run/skill-writing", + post(automation_run_api::skill_writing), + ) + .route( + "/api/automation/scheduler/status", + get(automation_scheduler_api::status), + ) + .route( + "/api/automation/scheduler/pause", + post(automation_scheduler_api::pause), + ) + .route( + "/api/automation/scheduler/resume", + post(automation_scheduler_api::resume), + ) + .route( + "/api/automation/runs/{run_id}/artifacts", + get(automation_run_api::artifact_list), + ) + .route( + "/api/automation/runs/{run_id}/artifacts/{kind}", + get(automation_run_api::artifact_payload), + ) .route("/api/plugins/holographic/curate", post(memory_api::curate)) .route( "/api/plugins/holographic/curate/apply", @@ -424,6 +538,10 @@ pub(crate) fn router(state: DashboardState) -> Router { ) .route("/api/plugins/analytics/hints", get(analytics_api::hints)) .route("/api/plugins/analytics/usage", get(analytics_api::usage)) + .route( + "/api/plugins/analytics/diagnostics", + get(analytics_api::diagnostics), + ) .route( "/api/plugins/analytics/underused", get(analytics_api::underused), @@ -437,10 +555,31 @@ pub(crate) fn router(state: DashboardState) -> Router { .with_state(state) } -/// Capability discovery for hosts and future Hermes-side extensions. The UI +/// Capability discovery for hosts and future delegated-host extensions. The UI /// (or a wrapper) can probe this to decide which panels/actions to enable. async fn capabilities(State(state): State) -> Json { let has_lcm = state.lcm_conn.is_some(); + let global_automation = crate::user_config::UserConfig::load().automation; + let project_automation = config::load_project_config(&state.dashboard_root) + .await + .ok() + .flatten(); + let automation = config::effective_config(&global_automation, project_automation.as_ref()) + .unwrap_or(global_automation); + let automation_backend = automation.backend; + let automation_host_mode = automation.host_mode; + let backend_availability = backend::backend_availability(&automation); + let automation_backend_supported = + matches!(automation_backend, AutomationBackend::CodexAppServer); + let automation_configured = automation.enabled && automation_backend_supported; + let automation_mode = if !automation_configured { + "disabled" + } else if automation_host_mode == AutomationHostMode::DelegatedHost { + "delegated_host" + } else { + "standalone_backend" + }; + let standalone_automation = automation_mode == "standalone_backend"; Json(json!({ "name": "tracedecay-dashboard", "version": env!("CARGO_PKG_VERSION"), @@ -461,15 +600,23 @@ async fn capabilities(State(state): State) -> Json { "graph": true, "analytics": true, // Similarity-based dedup curation (delete/merge ops via /curate - // and /curate/apply). LLM-proposed curation is a host-side - // extension (the Hermes wrapper flips llm_curation when it adds - // an LLM planner that calls /curate/apply). + // and /curate/apply). LLM-proposed curation is served by the + // configured standalone automation backend when enabled. "curation": true, - "llm_curation": false, + "automation": automation_configured, + "llm_curation": standalone_automation, + "managed_skills": true, // Savings & Cost tab: savings-ledger analytics + per-session // cost accounting with OpenRouter-backed pricing. "savings": true, }, + "automation": { + "enabled": automation.enabled, + "mode": automation_mode, + "backend": automation_backend, + "host_mode": automation_host_mode, + "availability": backend_availability, + }, "dashboards": ["holographic", "hermes-lcm", "graph", "savings"], })) } diff --git a/tests/dashboard_analytics_api_test.rs b/tests/dashboard_analytics_api_test.rs index 8b18cd8e..38e4aa26 100644 --- a/tests/dashboard_analytics_api_test.rs +++ b/tests/dashboard_analytics_api_test.rs @@ -17,6 +17,7 @@ use tracedecay::dashboard; use tracedecay::global_db::{AnalyticsEventInsert, GlobalDb}; use tracedecay::sessions::cursor::project_session_db_path; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -183,6 +184,41 @@ async fn seed_durable_analytics(db_path: &Path, project_root: &Path) { } } +fn seed_hook_analytics(project_root: &Path) { + let layout = resolve_layout_for_current_profile(project_root).expect("resolve store layout"); + std::fs::create_dir_all(&layout.data_root).expect("create store root"); + let rows = [ + serde_json::json!({ + "event": "hook_invoked", + "ts_unix_ms": 1_760_000_300_000u64, + "agent": "codex", + "hook_name": "UserPromptSubmit", + "session_id": "analytics-session", + "tool_name": null, + "prompt_category": "dashboard_or_ui", + }), + serde_json::json!({ + "event": "hook_invoked", + "ts_unix_ms": 1_760_000_301_000u64, + "agent": "cursor", + "hook_name": "postToolUse", + "session_id": "analytics-session", + "tool_name": "Grep", + "prompt_category": "code_research", + }), + ]; + let content = rows + .iter() + .map(serde_json::Value::to_string) + .collect::>() + .join("\n"); + std::fs::write( + layout.data_root.join("hook_analytics.jsonl"), + format!("{content}\n"), + ) + .expect("write hook analytics"); +} + async fn seed_durable_recent_window(db_path: &Path, project_root: &Path) { let gdb = GlobalDb::open_at(db_path).await.expect("open global db"); let project_id = GlobalDb::canonical_project_key(project_root); @@ -362,6 +398,54 @@ fn analytics_api_prefers_durable_events_when_available() { }); } +#[test] +fn analytics_diagnostics_reports_tool_hook_and_prompt_rollups() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_fixture(true).await; + seed_hook_analytics(&fixture.project_root); + let agent = http_agent(); + + let (status, diagnostics) = get_json( + &agent, + &format!("{}/api/plugins/analytics/diagnostics", fixture.base_url), + ); + assert_eq!(status, 200); + assert_eq!(diagnostics["source"], "analytics_events"); + assert_eq!(diagnostics["message_count"], 4); + assert_eq!(diagnostics["event_count"], 3); + assert_eq!(diagnostics["mcp_tool_call_count"], 1); + assert_eq!(diagnostics["tracedecay_call_count"], 1); + assert_eq!(diagnostics["hook_call_count"], 2); + assert_eq!(diagnostics["ratios"]["mcp_tool_calls_per_message"], 0.25); + assert_eq!(diagnostics["ratios"]["hook_calls_per_message"], 0.5); + + assert_eq!( + find_row(&diagnostics["by_tool_category"], "tool_category", "mcp")["count"], + 1 + ); + assert_eq!( + find_row(&diagnostics["by_hook"], "hook_name", "UserPromptSubmit")["count"], + 1 + ); + assert_eq!( + find_row( + &diagnostics["by_prompt_category"], + "prompt_category", + "dashboard_or_ui" + )["count"], + 1 + ); + assert_eq!( + diagnostics["recent_hooks"][0]["hook_name"], "postToolUse", + "recent hook rows should be newest-first" + ); + }); +} + #[test] fn analytics_api_filters_fallback_events_to_current_project() { let _lock = ENV_LOCK diff --git a/tests/dashboard_api_support/mod.rs b/tests/dashboard_api_support/mod.rs new file mode 100644 index 00000000..04e2583b --- /dev/null +++ b/tests/dashboard_api_support/mod.rs @@ -0,0 +1,721 @@ +#![allow(dead_code, unused_imports)] + +pub(crate) use std::fs; +pub(crate) use std::path::{Path, PathBuf}; +pub(crate) use std::process::Command; +pub(crate) use std::thread; + +pub(crate) use crate::common::{ + create_runtime, fake_codex_bin, get_json, http_agent, install_fake_codex_launcher, + pick_free_port, response_to_json, tempdir_or_panic, wait_for_dashboard, EnvVarGuard, + GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK, +}; +pub(crate) use serde_json::Value; +pub(crate) use tempfile::TempDir; +pub(crate) use tracedecay::config::USER_DATA_DIR_ENV; +pub(crate) use tracedecay::dashboard; +pub(crate) use tracedecay::errors::TraceDecayError; +pub(crate) use tracedecay::global_db::GlobalDb; +pub(crate) use tracedecay::memory::encoding::HolographicEncoder; +pub(crate) use tracedecay::sessions::lcm::{LcmSourceRef, LcmSummaryNodeDraft}; +pub(crate) use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; +pub(crate) use tracedecay::storage::{write_enrollment_marker, EnrollmentMarker, StorageMode}; +pub(crate) use tracedecay::tracedecay::TraceDecay; + +/// Longer than 200 chars on purpose: list/projection payloads truncate +/// `content` at 200, so this fact proves the `/fact/{id}` detail endpoint +/// returns the full text. +pub(crate) const LONG_FACT_CONTENT: &str = "LCM dashboard empty states need explicit copy. \ +The drawer, search results, charts, and overview panels must each explain why \ +they are empty and what action will populate them, because first-run users \ +otherwise assume the integration is broken when the store simply has no rows yet."; + +pub(crate) struct DashboardFixture { + pub(crate) _tmp: TempDir, + pub(crate) _env_guard: EnvVarGuard, + pub(crate) _data_dir_guard: EnvVarGuard, + pub(crate) base_url: String, + pub(crate) project_root: std::path::PathBuf, + pub(crate) project_db_path: std::path::PathBuf, + pub(crate) server: DashboardServer, +} + +impl Drop for DashboardFixture { + fn drop(&mut self) { + self.server.stop(); + } +} + +pub(crate) struct DashboardServer { + pub(crate) shutdown: Option>, + pub(crate) thread: Option>, +} + +impl DashboardServer { + pub(crate) fn stop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Drop for DashboardServer { + fn drop(&mut self) { + self.stop(); + } +} + +pub(crate) fn spawn_dashboard_server(cg: TraceDecay, port: u16) -> DashboardServer { + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let thread = thread::spawn(move || { + let runtime = create_runtime(); + runtime.block_on(async move { + let result = dashboard::run_until_shutdown(&cg, "127.0.0.1", port, false, async move { + let _ = shutdown_rx.await; + }) + .await; + let _ = cg.checkpoint().await; + cg.close(); + let _ = result; + }); + }); + DashboardServer { + shutdown: Some(shutdown), + thread: Some(thread), + } +} + +pub(crate) fn write_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + panic!("failed to create {}: {err}", parent.display()); + } + } + if let Err(err) = fs::write(path, content) { + panic!("failed to write {}: {err}", path.display()); + } +} + +pub(crate) async fn setup_project(project_root: &Path) -> TraceDecay { + write_file( + &project_root.join("src/lib.rs"), + "pub fn seed_fixture() -> &'static str { \"dashboard\" }\n", + ); + match TraceDecay::init(project_root).await { + Ok(cg) => cg, + Err(err) => panic!("failed to initialize tracedecay fixture project: {err}"), + } +} + +pub(crate) fn blob_param(bytes: Vec) -> libsql::Value { + libsql::Value::Blob(bytes) +} + +pub(crate) async fn seed_memory_fixture(cg: &TraceDecay) { + let conn = cg.db().conn(); + let vec_a = match HolographicEncoder::serialize(&[0.20, 0.35, 0.50]) { + Ok(value) => value, + Err(err) => panic!("failed to serialize vec_a: {err}"), + }; + let vec_b = match HolographicEncoder::serialize(&[0.21, 0.34, 0.49]) { + Ok(value) => value, + Err(err) => panic!("failed to serialize vec_b: {err}"), + }; + let vec_c = match HolographicEncoder::serialize(&[2.1, -1.2, 0.9]) { + Ok(value) => value, + Err(err) => panic!("failed to serialize vec_c: {err}"), + }; + let bank_a = match HolographicEncoder::serialize(&[0.1, 0.2, 0.3]) { + Ok(value) => value, + Err(err) => panic!("failed to serialize bank_a: {err}"), + }; + let bank_b = match HolographicEncoder::serialize(&[0.4, 0.5, 0.6]) { + Ok(value) => value, + Err(err) => panic!("failed to serialize bank_b: {err}"), + }; + + let inserts = [ + ( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + libsql::params![ + 101_i64, + "Cache invalidation policy must be explicit", + "project", + "[\"cache\",\"policy\"]", + 0.97_f64, + 8_i64, + 5_i64, + 1_700_000_000_i64, + 1_700_000_100_i64, + blob_param(vec_a.clone()), + "amari_fhrr", + HolographicEncoder::DIMENSIONS as i64 + ], + ), + ( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + libsql::params![ + 102_i64, + "Cache invalidation policy must stay explicit", + "project", + "[\"cache\",\"policy\"]", + 0.95_f64, + 6_i64, + 4_i64, + 1_700_000_010_i64, + 1_700_000_110_i64, + blob_param(vec_b.clone()), + "amari_fhrr", + HolographicEncoder::DIMENSIONS as i64 + ], + ), + ( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + libsql::params![ + 103_i64, + LONG_FACT_CONTENT, + "tool", + "[\"lcm\",\"ux\"]", + 0.76_f64, + 3_i64, + 2_i64, + 1_700_000_020_i64, + 1_700_000_120_i64, + blob_param(vec_c.clone()), + "amari_fhrr", + HolographicEncoder::DIMENSIONS as i64 + ], + ), + ]; + for (sql, params) in inserts { + if let Err(err) = conn.execute(sql, params).await { + panic!("failed to insert memory fact: {err}"); + } + } + + let entity_rows = [ + ( + 201_i64, + "CachePolicy", + "cachepolicy", + "concept", + "[\"cache policy\"]", + ), + (202_i64, "LCMTab", "lcmtab", "feature", "[\"lcm tab\"]"), + (203_i64, "SimilarityView", "similarityview", "feature", "[]"), + ]; + for (entity_id, name, normalized_name, entity_type, aliases) in entity_rows { + if let Err(err) = conn + .execute( + "INSERT INTO memory_entities + (entity_id, name, normalized_name, entity_type, aliases, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + libsql::params![ + entity_id, + name, + normalized_name, + entity_type, + aliases, + 1_700_000_050_i64 + ], + ) + .await + { + panic!("failed to insert memory entity: {err}"); + } + } + + let joins = [ + (101_i64, 201_i64), + (102_i64, 201_i64), + (103_i64, 202_i64), + (103_i64, 203_i64), + ]; + for (fact_id, entity_id) in joins { + if let Err(err) = conn + .execute( + "INSERT INTO memory_fact_entities (fact_id, entity_id) VALUES (?1, ?2)", + libsql::params![fact_id, entity_id], + ) + .await + { + panic!("failed to insert memory_fact_entities row: {err}"); + } + } + + // The "project" bank's stored fact_count is deliberately stale (5 vs the + // 2 live project facts): bank counts are denormalized snapshots from the + // last bundle rebuild, and the overview API must report live membership. + let bank_rows = [("project", bank_a, 5_i64), ("tool", bank_b, 1_i64)]; + for (name, vector, fact_count) in bank_rows { + if let Err(err) = conn + .execute( + "INSERT INTO memory_banks + (bank_name, vector, hrr_dim, fact_count, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + libsql::params![ + name, + blob_param(vector), + 3_i64, + fact_count, + 1_700_000_130_i64 + ], + ) + .await + { + panic!("failed to insert memory bank: {err}"); + } + } +} + +pub(crate) async fn seed_lcm_fixture(global_db: &GlobalDb, project_path: &Path) { + let session = SessionRecord { + provider: "cursor".to_string(), + session_id: "sess-dashboard-1".to_string(), + project_key: "tracedecay-fixture".to_string(), + project_path: project_path.display().to_string(), + title: Some("Dashboard fixture session".to_string()), + started_at: Some(1_700_001_000), + ended_at: None, + transcript_path: None, + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }; + if !global_db.upsert_session(&session).await { + panic!("failed to upsert session fixture"); + } + + let messages = [ + SessionMessageRecord { + provider: "cursor".to_string(), + message_id: "msg-1".to_string(), + session_id: "sess-dashboard-1".to_string(), + role: "user".to_string(), + timestamp: Some(1_700_001_010), + ordinal: 1, + text: "Need a vector projection for memory similarity.".to_string(), + kind: Some("chat".to_string()), + model: Some("gpt".to_string()), + tool_names: None, + source_path: None, + source_offset: None, + metadata_json: None, + }, + SessionMessageRecord { + provider: "cursor".to_string(), + message_id: "msg-2".to_string(), + session_id: "sess-dashboard-1".to_string(), + role: "assistant".to_string(), + timestamp: Some(1_700_001_020), + ordinal: 2, + text: "Similarity pair detected for cache policy facts.".to_string(), + kind: Some("chat".to_string()), + model: Some("gpt".to_string()), + tool_names: Some("tracedecay_search".to_string()), + source_path: None, + source_offset: None, + metadata_json: None, + }, + SessionMessageRecord { + provider: "cursor".to_string(), + message_id: "msg-3".to_string(), + session_id: "sess-dashboard-1".to_string(), + role: "assistant".to_string(), + timestamp: Some(1_700_001_030), + ordinal: 3, + text: "LCM tab should render non-empty overview cards.".to_string(), + kind: Some("chat".to_string()), + model: Some("gpt".to_string()), + tool_names: Some("tracedecay_lcm_status".to_string()), + source_path: None, + source_offset: None, + metadata_json: None, + }, + ]; + + for message in messages { + if !global_db.upsert_session_message(&message).await { + panic!( + "failed to upsert LCM message fixture {}", + message.message_id + ); + } + } + + let msg_1 = match global_db.lcm_load_raw_message("cursor", "msg-1").await { + Some(record) => record.store_id, + None => panic!("missing seeded message msg-1"), + }; + let msg_2 = match global_db.lcm_load_raw_message("cursor", "msg-2").await { + Some(record) => record.store_id, + None => panic!("missing seeded message msg-2"), + }; + + let draft = LcmSummaryNodeDraft { + provider: "cursor".to_string(), + conversation_id: "conv-dashboard".to_string(), + session_id: "sess-dashboard-1".to_string(), + depth: 1, + summary_text: "Vector projection summary for cache policy similarities.".to_string(), + source_refs: vec![ + LcmSourceRef::RawMessage { store_id: msg_1 }, + LcmSourceRef::RawMessage { store_id: msg_2 }, + ], + source_token_count: 180, + summary_token_count: 72, + source_time_start: Some(1_700_001_010), + source_time_end: Some(1_700_001_030), + expand_hint: Some("Use summary detail drawer".to_string()), + metadata_json: Some( + "{\"category\":\"analysis\",\"tags\":[\"vector\"],\"entities\":[\"cache\"]}" + .to_string(), + ), + }; + if let Err(err) = global_db.lcm_insert_summary_node(draft).await { + panic!("failed to insert summary node fixture: {err}"); + } +} + +pub(crate) fn post_json(agent: &ureq::Agent, url: &str) -> (u16, Value) { + let response = match agent.post(url).send_empty() { + Ok(response) => response, + Err(err) => panic!("POST {url} failed: {err}"), + }; + response_to_json(response) +} + +pub(crate) fn post_json_body(agent: &ureq::Agent, url: &str, body: &Value) -> (u16, Value) { + let response = match agent.post(url).send_json(body) { + Ok(response) => response, + Err(err) => panic!("POST {url} (with body) failed: {err}"), + }; + response_to_json(response) +} + +pub(crate) fn patch_json_body(agent: &ureq::Agent, url: &str, body: &Value) -> (u16, Value) { + let response = match agent.patch(url).send_json(body) { + Ok(response) => response, + Err(err) => panic!("PATCH {url} (with body) failed: {err}"), + }; + response_to_json(response) +} + +pub(crate) fn delete_json(agent: &ureq::Agent, url: &str) -> (u16, Value) { + let response = match agent.delete(url).call() { + Ok(response) => response, + Err(err) => panic!("DELETE {url} failed: {err}"), + }; + response_to_json(response) +} + +pub(crate) struct FakeCodexAppServer { + pub(crate) _temp: TempDir, + pub(crate) bin: PathBuf, +} + +impl FakeCodexAppServer { + pub(crate) fn new_memory_curator() -> Self { + let temp = tempdir_or_panic(); + let script_path = temp.path().join("codex.py"); + let bin = fake_codex_bin(temp.path()); + let script = r#"#!/usr/bin/env python3 +import json +import os +import sys + +if len(sys.argv) != 2 or sys.argv[1] != "app-server": + sys.exit(42) +if os.environ.get("TRACEDECAY_CODEX_SUMMARY_CHILD") != "1": + sys.exit(43) + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"id": msg.get("id"), "result": {}}), flush=True) + elif method == "thread/start": + print(json.dumps({ + "id": msg.get("id"), + "result": {"thread": {"id": "thread-dashboard", "model": "dashboard-fake-model"}} + }), flush=True) + elif method == "turn/start": + payload = { + "ops": [{ + "cluster_id": "cluster-0000", + "op": "delete", + "fact_id": 102, + "confidence": 0.98, + "reason": "near duplicate of fact 101" + }] + } + print(json.dumps({ + "method": "item/agentMessage/delta", + "params": {"delta": json.dumps(payload), "model": "dashboard-fake-model"} + }), flush=True) + print(json.dumps({"method": "turn/completed"}), flush=True) + break +"#; + write_file(&script_path, script); + install_fake_codex_launcher(&script_path, &bin); + Self { _temp: temp, bin } + } +} + +pub(crate) async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + if let Err(err) = write_enrollment_marker( + &project_root, + &EnrollmentMarker { + project_id: "dashboard_fixture".to_string(), + storage_mode: StorageMode::ProfileSharded, + }, + ) { + panic!("failed to enroll dashboard fixture in profile storage: {err}"); + } + + let cg = setup_project(&project_root).await; + seed_memory_fixture(&cg).await; + + let global_db = match GlobalDb::open_at(&global_db_path).await { + Some(db) => db, + None => panic!( + "failed to open temporary global DB at {}", + global_db_path.display() + ), + }; + drop(global_db); + if seed_lcm { + let session_store = open_project_session_store(&project_root).await; + seed_lcm_fixture(&session_store, &project_root).await; + drop(session_store); + } + + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let project_db_path = cg.store_layout().graph_db_path.clone(); + let server = spawn_dashboard_server(cg, port); + + let agent = http_agent(); + wait_for_dashboard(&agent, &base_url).await; + + DashboardFixture { + _tmp: tmp, + _env_guard: env_guard, + _data_dir_guard: data_dir_guard, + base_url, + project_root, + project_db_path, + server, + } +} + +/// Counts rows in the fixture's project DB matching `sql` (a SELECT COUNT query +/// with one `?1` bind), via a fresh read connection. Used to prove hard deletes +/// actually removed rows (and their entity links) from the store that +/// `tracedecay_fact_store` recall reads. +pub(crate) async fn count_in_project_db( + fixture: &DashboardFixture, + sql: &str, + fact_id: i64, +) -> i64 { + let db = match libsql::Builder::new_local(&fixture.project_db_path) + .build() + .await + { + Ok(db) => db, + Err(err) => panic!("failed to open project DB for verification: {err}"), + }; + let conn = match db.connect() { + Ok(conn) => conn, + Err(err) => panic!("failed to connect to project DB: {err}"), + }; + let mut rows = match conn.query(sql, libsql::params![fact_id]).await { + Ok(rows) => rows, + Err(err) => panic!("verification query failed: {err}"), + }; + match rows.next().await { + Ok(Some(row)) => row.get::(0).unwrap_or(-1), + Ok(None) => -1, + Err(err) => panic!("verification row read failed: {err}"), + } +} + +pub(crate) async fn string_in_project_db( + fixture: &DashboardFixture, + sql: &str, + fact_id: i64, +) -> Option { + let conn = project_db_conn(fixture).await; + let mut rows = match conn.query(sql, libsql::params![fact_id]).await { + Ok(rows) => rows, + Err(err) => panic!("verification query failed: {err}"), + }; + match rows.next().await { + Ok(Some(row)) => row.get::(0).ok(), + Ok(None) => None, + Err(err) => panic!("verification row read failed: {err}"), + } +} + +pub(crate) async fn project_db_conn(fixture: &DashboardFixture) -> libsql::Connection { + let db = match libsql::Builder::new_local(&fixture.project_db_path) + .build() + .await + { + Ok(db) => db, + Err(err) => panic!("failed to open project DB directly: {err}"), + }; + let conn = match db.connect() { + Ok(conn) => conn, + Err(err) => panic!("failed to connect to project DB directly: {err}"), + }; + // The running dashboard can write to this store concurrently; wait out + // transient write locks instead of failing the fixture mutation. + if let Err(err) = conn.execute_batch("PRAGMA busy_timeout = 5000;").await { + panic!("failed to set busy_timeout on project DB connection: {err}"); + } + conn +} + +/// Swaps a fact's vector the way every production re-encode does: alongside +/// an `updated_at` bump (`update_fact` / `update_fact_vector` always bump it; +/// the startup repair only fills NULL vectors, which changes the vectored +/// count instead). The similarity cache fingerprint is metadata-only and +/// relies on exactly that contract. +pub(crate) async fn set_fact_vector_and_bump_updated_at( + fixture: &DashboardFixture, + fact_id: i64, + phases: &[f64], +) { + let conn = project_db_conn(fixture).await; + let vector = match HolographicEncoder::serialize(phases) { + Ok(vector) => vector, + Err(err) => panic!("failed to serialize replacement vector: {err}"), + }; + if let Err(err) = conn + .execute( + "UPDATE memory_facts + SET hrr_vector = ?1, hrr_algebra = 'amari_fhrr', hrr_dim = ?2, + updated_at = updated_at + 1 + WHERE fact_id = ?3", + libsql::params![blob_param(vector), phases.len() as i64, fact_id], + ) + .await + { + panic!("failed to update fact vector fixture: {err}"); + } +} + +pub(crate) async fn clear_fact_vector_without_touching_updated_at( + fixture: &DashboardFixture, + fact_id: i64, +) { + let conn = project_db_conn(fixture).await; + if let Err(err) = conn + .execute( + "UPDATE memory_facts + SET hrr_vector = NULL + WHERE fact_id = ?1", + libsql::params![fact_id], + ) + .await + { + panic!("failed to clear fact vector fixture: {err}"); + } +} + +pub(crate) async fn set_fact_access_without_touching_updated_at( + fixture: &DashboardFixture, + fact_id: i64, + access_count: i64, + last_recalled_at: i64, +) { + let conn = project_db_conn(fixture).await; + if let Err(err) = conn + .execute( + "UPDATE memory_facts + SET access_count = ?1, last_recalled_at = ?2 + WHERE fact_id = ?3", + libsql::params![access_count, last_recalled_at, fact_id], + ) + .await + { + panic!("failed to update fact access fixture: {err}"); + } +} + +pub(crate) fn git(project: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(project) + .output() + .unwrap_or_else(|err| panic!("failed to run git {args:?}: {err}")); + assert!( + output.status.success(), + "git {args:?} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +pub(crate) fn commit_all(project: &Path, message: &str) { + git(project, &["add", "."]); + git( + project, + &[ + "-c", + "user.name=TraceDecay Test", + "-c", + "user.email=tracedecay-test@example.com", + "commit", + "-m", + message, + ], + ); +} + +pub(crate) async fn index_all_retrying_sync_lock(cg: &TraceDecay, context: &str) { + for attempt in 0..20 { + match cg.index_all().await { + Ok(_) => return, + Err(TraceDecayError::SyncLock { .. }) if attempt < 19 => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(err) => panic!("{context}: {err}"), + } + } +} + +/// Opens (creating if needed) the resolved project session store — profile +/// sharded by default, project-local only for explicit or legacy projects. +pub(crate) async fn open_project_session_store(project_root: &Path) -> GlobalDb { + let db_path = tracedecay::sessions::cursor::project_session_db_path(project_root); + match GlobalDb::open_at(&db_path).await { + Some(db) => db, + None => panic!( + "failed to open project session store at {}", + db_path.display() + ), + } +} diff --git a/tests/dashboard_api_test.rs b/tests/dashboard_api_test.rs index 7df02ea4..bfee2628 100644 --- a/tests/dashboard_api_test.rs +++ b/tests/dashboard_api_test.rs @@ -1,637 +1,10 @@ mod common; +mod dashboard_api_support; -use std::fs; -use std::path::Path; -use std::process::Command; -use std::thread; - -use common::{ - create_runtime, get_json, http_agent, pick_free_port, response_to_json, tempdir_or_panic, - wait_for_dashboard, EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK, -}; -use serde_json::Value; -use tempfile::TempDir; -use tracedecay::config::USER_DATA_DIR_ENV; -use tracedecay::dashboard; -use tracedecay::errors::TraceDecayError; -use tracedecay::global_db::GlobalDb; -use tracedecay::memory::encoding::HolographicEncoder; -use tracedecay::sessions::lcm::{LcmSourceRef, LcmSummaryNodeDraft}; -use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; -use tracedecay::storage::{write_enrollment_marker, EnrollmentMarker, StorageMode}; -use tracedecay::tracedecay::TraceDecay; - -/// Longer than 200 chars on purpose: list/projection payloads truncate -/// `content` at 200, so this fact proves the `/fact/{id}` detail endpoint -/// returns the full text. -const LONG_FACT_CONTENT: &str = "LCM dashboard empty states need explicit copy. \ -The drawer, search results, charts, and overview panels must each explain why \ -they are empty and what action will populate them, because first-run users \ -otherwise assume the integration is broken when the store simply has no rows yet."; - -struct DashboardFixture { - _tmp: TempDir, - _env_guard: EnvVarGuard, - _data_dir_guard: EnvVarGuard, - base_url: String, - project_root: std::path::PathBuf, - project_db_path: std::path::PathBuf, - server: DashboardServer, -} - -impl Drop for DashboardFixture { - fn drop(&mut self) { - self.server.stop(); - } -} - -struct DashboardServer { - shutdown: Option>, - thread: Option>, -} - -impl DashboardServer { - fn stop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -impl Drop for DashboardServer { - fn drop(&mut self) { - self.stop(); - } -} - -fn spawn_dashboard_server(cg: TraceDecay, port: u16) -> DashboardServer { - let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let thread = thread::spawn(move || { - let runtime = create_runtime(); - runtime.block_on(async move { - let result = dashboard::run_until_shutdown(&cg, "127.0.0.1", port, false, async move { - let _ = shutdown_rx.await; - }) - .await; - let _ = cg.checkpoint().await; - cg.close(); - let _ = result; - }); - }); - DashboardServer { - shutdown: Some(shutdown), - thread: Some(thread), - } -} - -fn write_file(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - if let Err(err) = fs::create_dir_all(parent) { - panic!("failed to create {}: {err}", parent.display()); - } - } - if let Err(err) = fs::write(path, content) { - panic!("failed to write {}: {err}", path.display()); - } -} - -async fn setup_project(project_root: &Path) -> TraceDecay { - write_file( - &project_root.join("src/lib.rs"), - "pub fn seed_fixture() -> &'static str { \"dashboard\" }\n", - ); - match TraceDecay::init(project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to initialize tracedecay fixture project: {err}"), - } -} - -fn blob_param(bytes: Vec) -> libsql::Value { - libsql::Value::Blob(bytes) -} - -async fn seed_memory_fixture(cg: &TraceDecay) { - let conn = cg.db().conn(); - let vec_a = match HolographicEncoder::serialize(&[0.20, 0.35, 0.50]) { - Ok(value) => value, - Err(err) => panic!("failed to serialize vec_a: {err}"), - }; - let vec_b = match HolographicEncoder::serialize(&[0.21, 0.34, 0.49]) { - Ok(value) => value, - Err(err) => panic!("failed to serialize vec_b: {err}"), - }; - let vec_c = match HolographicEncoder::serialize(&[2.1, -1.2, 0.9]) { - Ok(value) => value, - Err(err) => panic!("failed to serialize vec_c: {err}"), - }; - let bank_a = match HolographicEncoder::serialize(&[0.1, 0.2, 0.3]) { - Ok(value) => value, - Err(err) => panic!("failed to serialize bank_a: {err}"), - }; - let bank_b = match HolographicEncoder::serialize(&[0.4, 0.5, 0.6]) { - Ok(value) => value, - Err(err) => panic!("failed to serialize bank_b: {err}"), - }; - - let inserts = [ - ( - "INSERT INTO memory_facts - (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - libsql::params![ - 101_i64, - "Cache invalidation policy must be explicit", - "project", - "[\"cache\",\"policy\"]", - 0.97_f64, - 8_i64, - 5_i64, - 1_700_000_000_i64, - 1_700_000_100_i64, - blob_param(vec_a.clone()), - "amari_fhrr", - HolographicEncoder::DIMENSIONS as i64 - ], - ), - ( - "INSERT INTO memory_facts - (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - libsql::params![ - 102_i64, - "Cache invalidation policy must stay explicit", - "project", - "[\"cache\",\"policy\"]", - 0.95_f64, - 6_i64, - 4_i64, - 1_700_000_010_i64, - 1_700_000_110_i64, - blob_param(vec_b.clone()), - "amari_fhrr", - HolographicEncoder::DIMENSIONS as i64 - ], - ), - ( - "INSERT INTO memory_facts - (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - libsql::params![ - 103_i64, - LONG_FACT_CONTENT, - "tool", - "[\"lcm\",\"ux\"]", - 0.76_f64, - 3_i64, - 2_i64, - 1_700_000_020_i64, - 1_700_000_120_i64, - blob_param(vec_c.clone()), - "amari_fhrr", - HolographicEncoder::DIMENSIONS as i64 - ], - ), - ]; - for (sql, params) in inserts { - if let Err(err) = conn.execute(sql, params).await { - panic!("failed to insert memory fact: {err}"); - } - } - - let entity_rows = [ - ( - 201_i64, - "CachePolicy", - "cachepolicy", - "concept", - "[\"cache policy\"]", - ), - (202_i64, "LCMTab", "lcmtab", "feature", "[\"lcm tab\"]"), - (203_i64, "SimilarityView", "similarityview", "feature", "[]"), - ]; - for (entity_id, name, normalized_name, entity_type, aliases) in entity_rows { - if let Err(err) = conn - .execute( - "INSERT INTO memory_entities - (entity_id, name, normalized_name, entity_type, aliases, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - libsql::params![ - entity_id, - name, - normalized_name, - entity_type, - aliases, - 1_700_000_050_i64 - ], - ) - .await - { - panic!("failed to insert memory entity: {err}"); - } - } - - let joins = [ - (101_i64, 201_i64), - (102_i64, 201_i64), - (103_i64, 202_i64), - (103_i64, 203_i64), - ]; - for (fact_id, entity_id) in joins { - if let Err(err) = conn - .execute( - "INSERT INTO memory_fact_entities (fact_id, entity_id) VALUES (?1, ?2)", - libsql::params![fact_id, entity_id], - ) - .await - { - panic!("failed to insert memory_fact_entities row: {err}"); - } - } - - // The "project" bank's stored fact_count is deliberately stale (5 vs the - // 2 live project facts): bank counts are denormalized snapshots from the - // last bundle rebuild, and the overview API must report live membership. - let bank_rows = [("project", bank_a, 5_i64), ("tool", bank_b, 1_i64)]; - for (name, vector, fact_count) in bank_rows { - if let Err(err) = conn - .execute( - "INSERT INTO memory_banks - (bank_name, vector, hrr_dim, fact_count, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - libsql::params![ - name, - blob_param(vector), - 3_i64, - fact_count, - 1_700_000_130_i64 - ], - ) - .await - { - panic!("failed to insert memory bank: {err}"); - } - } -} - -async fn seed_lcm_fixture(global_db: &GlobalDb, project_path: &Path) { - let session = SessionRecord { - provider: "cursor".to_string(), - session_id: "sess-dashboard-1".to_string(), - project_key: "tracedecay-fixture".to_string(), - project_path: project_path.display().to_string(), - title: Some("Dashboard fixture session".to_string()), - started_at: Some(1_700_001_000), - ended_at: None, - transcript_path: None, - metadata_json: None, - parent_session_id: None, - is_subagent: false, - agent_id: None, - parent_tool_use_id: None, - }; - if !global_db.upsert_session(&session).await { - panic!("failed to upsert session fixture"); - } - - let messages = [ - SessionMessageRecord { - provider: "cursor".to_string(), - message_id: "msg-1".to_string(), - session_id: "sess-dashboard-1".to_string(), - role: "user".to_string(), - timestamp: Some(1_700_001_010), - ordinal: 1, - text: "Need a vector projection for memory similarity.".to_string(), - kind: Some("chat".to_string()), - model: Some("gpt".to_string()), - tool_names: None, - source_path: None, - source_offset: None, - metadata_json: None, - }, - SessionMessageRecord { - provider: "cursor".to_string(), - message_id: "msg-2".to_string(), - session_id: "sess-dashboard-1".to_string(), - role: "assistant".to_string(), - timestamp: Some(1_700_001_020), - ordinal: 2, - text: "Similarity pair detected for cache policy facts.".to_string(), - kind: Some("chat".to_string()), - model: Some("gpt".to_string()), - tool_names: Some("tracedecay_search".to_string()), - source_path: None, - source_offset: None, - metadata_json: None, - }, - SessionMessageRecord { - provider: "cursor".to_string(), - message_id: "msg-3".to_string(), - session_id: "sess-dashboard-1".to_string(), - role: "assistant".to_string(), - timestamp: Some(1_700_001_030), - ordinal: 3, - text: "LCM tab should render non-empty overview cards.".to_string(), - kind: Some("chat".to_string()), - model: Some("gpt".to_string()), - tool_names: Some("tracedecay_lcm_status".to_string()), - source_path: None, - source_offset: None, - metadata_json: None, - }, - ]; - - for message in messages { - if !global_db.upsert_session_message(&message).await { - panic!( - "failed to upsert LCM message fixture {}", - message.message_id - ); - } - } - - let msg_1 = match global_db.lcm_load_raw_message("cursor", "msg-1").await { - Some(record) => record.store_id, - None => panic!("missing seeded message msg-1"), - }; - let msg_2 = match global_db.lcm_load_raw_message("cursor", "msg-2").await { - Some(record) => record.store_id, - None => panic!("missing seeded message msg-2"), - }; - - let draft = LcmSummaryNodeDraft { - provider: "cursor".to_string(), - conversation_id: "conv-dashboard".to_string(), - session_id: "sess-dashboard-1".to_string(), - depth: 1, - summary_text: "Vector projection summary for cache policy similarities.".to_string(), - source_refs: vec![ - LcmSourceRef::RawMessage { store_id: msg_1 }, - LcmSourceRef::RawMessage { store_id: msg_2 }, - ], - source_token_count: 180, - summary_token_count: 72, - source_time_start: Some(1_700_001_010), - source_time_end: Some(1_700_001_030), - expand_hint: Some("Use summary detail drawer".to_string()), - metadata_json: Some( - "{\"category\":\"analysis\",\"tags\":[\"vector\"],\"entities\":[\"cache\"]}" - .to_string(), - ), - }; - if let Err(err) = global_db.lcm_insert_summary_node(draft).await { - panic!("failed to insert summary node fixture: {err}"); - } -} - -fn post_json(agent: &ureq::Agent, url: &str) -> (u16, Value) { - let response = match agent.post(url).send_empty() { - Ok(response) => response, - Err(err) => panic!("POST {url} failed: {err}"), - }; - response_to_json(response) -} - -fn post_json_body(agent: &ureq::Agent, url: &str, body: &Value) -> (u16, Value) { - let response = match agent.post(url).send_json(body) { - Ok(response) => response, - Err(err) => panic!("POST {url} (with body) failed: {err}"), - }; - response_to_json(response) -} - -async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { - let tmp = tempdir_or_panic(); - let tmp_root = tmp - .path() - .canonicalize() - .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); - let project_root = tmp_root.join("project"); - let global_db_path = tmp_root.join("global").join("global.db"); - let profile_root = tmp_root.join("profile").join(".tracedecay"); - let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); - let data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); - if let Err(err) = write_enrollment_marker( - &project_root, - &EnrollmentMarker { - project_id: "dashboard_fixture".to_string(), - storage_mode: StorageMode::ProfileSharded, - }, - ) { - panic!("failed to enroll dashboard fixture in profile storage: {err}"); - } - - let cg = setup_project(&project_root).await; - seed_memory_fixture(&cg).await; - - let global_db = match GlobalDb::open_at(&global_db_path).await { - Some(db) => db, - None => panic!( - "failed to open temporary global DB at {}", - global_db_path.display() - ), - }; - drop(global_db); - if seed_lcm { - let session_store = open_project_session_store(&project_root).await; - seed_lcm_fixture(&session_store, &project_root).await; - drop(session_store); - } - - let port = pick_free_port(); - let base_url = format!("http://127.0.0.1:{port}"); - let project_db_path = cg.store_layout().graph_db_path.clone(); - let server = spawn_dashboard_server(cg, port); - - let agent = http_agent(); - wait_for_dashboard(&agent, &base_url).await; - - DashboardFixture { - _tmp: tmp, - _env_guard: env_guard, - _data_dir_guard: data_dir_guard, - base_url, - project_root, - project_db_path, - server, - } -} - -/// Counts rows in the fixture's project DB matching `sql` (a SELECT COUNT query -/// with one `?1` bind), via a fresh read connection. Used to prove hard deletes -/// actually removed rows (and their entity links) from the store that -/// `tracedecay_fact_store` recall reads. -async fn count_in_project_db(fixture: &DashboardFixture, sql: &str, fact_id: i64) -> i64 { - let db = match libsql::Builder::new_local(&fixture.project_db_path) - .build() - .await - { - Ok(db) => db, - Err(err) => panic!("failed to open project DB for verification: {err}"), - }; - let conn = match db.connect() { - Ok(conn) => conn, - Err(err) => panic!("failed to connect to project DB: {err}"), - }; - let mut rows = match conn.query(sql, libsql::params![fact_id]).await { - Ok(rows) => rows, - Err(err) => panic!("verification query failed: {err}"), - }; - match rows.next().await { - Ok(Some(row)) => row.get::(0).unwrap_or(-1), - Ok(None) => -1, - Err(err) => panic!("verification row read failed: {err}"), - } -} - -async fn string_in_project_db( - fixture: &DashboardFixture, - sql: &str, - fact_id: i64, -) -> Option { - let conn = project_db_conn(fixture).await; - let mut rows = match conn.query(sql, libsql::params![fact_id]).await { - Ok(rows) => rows, - Err(err) => panic!("verification query failed: {err}"), - }; - match rows.next().await { - Ok(Some(row)) => row.get::(0).ok(), - Ok(None) => None, - Err(err) => panic!("verification row read failed: {err}"), - } -} - -async fn project_db_conn(fixture: &DashboardFixture) -> libsql::Connection { - let db = match libsql::Builder::new_local(&fixture.project_db_path) - .build() - .await - { - Ok(db) => db, - Err(err) => panic!("failed to open project DB directly: {err}"), - }; - let conn = match db.connect() { - Ok(conn) => conn, - Err(err) => panic!("failed to connect to project DB directly: {err}"), - }; - // The running dashboard can write to this store concurrently; wait out - // transient write locks instead of failing the fixture mutation. - if let Err(err) = conn.execute_batch("PRAGMA busy_timeout = 5000;").await { - panic!("failed to set busy_timeout on project DB connection: {err}"); - } - conn -} - -/// Swaps a fact's vector the way every production re-encode does: alongside -/// an `updated_at` bump (`update_fact` / `update_fact_vector` always bump it; -/// the startup repair only fills NULL vectors, which changes the vectored -/// count instead). The similarity cache fingerprint is metadata-only and -/// relies on exactly that contract. -async fn set_fact_vector_and_bump_updated_at( - fixture: &DashboardFixture, - fact_id: i64, - phases: &[f64], -) { - let conn = project_db_conn(fixture).await; - let vector = match HolographicEncoder::serialize(phases) { - Ok(vector) => vector, - Err(err) => panic!("failed to serialize replacement vector: {err}"), - }; - if let Err(err) = conn - .execute( - "UPDATE memory_facts - SET hrr_vector = ?1, hrr_algebra = 'amari_fhrr', hrr_dim = ?2, - updated_at = updated_at + 1 - WHERE fact_id = ?3", - libsql::params![blob_param(vector), phases.len() as i64, fact_id], - ) - .await - { - panic!("failed to update fact vector fixture: {err}"); - } -} - -async fn clear_fact_vector_without_touching_updated_at(fixture: &DashboardFixture, fact_id: i64) { - let conn = project_db_conn(fixture).await; - if let Err(err) = conn - .execute( - "UPDATE memory_facts - SET hrr_vector = NULL - WHERE fact_id = ?1", - libsql::params![fact_id], - ) - .await - { - panic!("failed to clear fact vector fixture: {err}"); - } -} - -async fn set_fact_access_without_touching_updated_at( - fixture: &DashboardFixture, - fact_id: i64, - access_count: i64, - last_recalled_at: i64, -) { - let conn = project_db_conn(fixture).await; - if let Err(err) = conn - .execute( - "UPDATE memory_facts - SET access_count = ?1, last_recalled_at = ?2 - WHERE fact_id = ?3", - libsql::params![access_count, last_recalled_at, fact_id], - ) - .await - { - panic!("failed to update fact access fixture: {err}"); - } -} - -fn git(project: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(project) - .output() - .unwrap_or_else(|err| panic!("failed to run git {args:?}: {err}")); - assert!( - output.status.success(), - "git {args:?} failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn commit_all(project: &Path, message: &str) { - git(project, &["add", "."]); - git( - project, - &[ - "-c", - "user.name=TraceDecay Test", - "-c", - "user.email=tracedecay-test@example.com", - "commit", - "-m", - message, - ], - ); -} - -async fn index_all_retrying_sync_lock(cg: &TraceDecay, context: &str) { - for attempt in 0..20 { - match cg.index_all().await { - Ok(_) => return, - Err(TraceDecayError::SyncLock { .. }) if attempt < 19 => { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - Err(err) => panic!("{context}: {err}"), - } - } -} +use dashboard_api_support::*; #[test] -fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { +fn dashboard_plugin_manifest_assets_are_served() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -640,1217 +13,283 @@ fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { let fixture = start_dashboard_fixture(false).await; let agent = http_agent(); - let (status, initial) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/similarity?min_similarity=0.99&limit=20", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!( - initial["pairs"].as_array().map(Vec::len), - Some(3), - "dashboard startup should repair stale seeded vectors before similarity reads" - ); - - set_fact_access_without_touching_updated_at(&fixture, 102, 7, 1_700_000_500).await; - let (status, curate_after_access) = post_json_body( + let (status, plugins) = get_json( &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), + &format!("{}/api/dashboard/plugins", fixture.base_url), ); assert_eq!(status, 200); - let access_action = curate_after_access["actions"] + for plugin in plugins .as_array() - .and_then(|actions| { - actions - .iter() - .find(|action| action["fact_id"].as_i64() == Some(102)) - }) - .unwrap_or_else(|| { - panic!("expected dry-run delete action for fact 102: {curate_after_access}") - }); - assert_eq!( - access_action["access_count"], 7, - "access-only updates must invalidate cached curation metadata" - ); - - set_fact_vector_and_bump_updated_at(&fixture, 103, &[0.20, 0.35, 0.50]).await; - let (status, repaired_cache) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/similarity?min_similarity=0.99&limit=20", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!( - repaired_cache["pairs"].as_array().map(Vec::len), - Some(1), - "updated_at-only vector rewrites must invalidate the similarity cache even when the rewritten fact no longer participates in the repaired 2048-dim pair set; got {repaired_cache}" - ); - - clear_fact_vector_without_touching_updated_at(&fixture, 103).await; - let port = pick_free_port(); - let base_url = format!("http://127.0.0.1:{port}"); - let project_root = fixture.project_root.clone(); - let cg = match TraceDecay::open(&project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to reopen fixture project: {err}"), - }; - let mut server = spawn_dashboard_server(cg, port); - wait_for_dashboard(&agent, &base_url).await; - let (status, _capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); - server.stop(); - assert_eq!(status, 200); - let repaired = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1 AND hrr_vector IS NOT NULL", - 103, - ) - .await; - assert_eq!( - repaired, 1, - "dashboard startup should repair NULL HRR vectors before memory reads" - ); - }); -} - -#[test] -fn dashboard_reports_resolved_branch_db_path() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); - let global_db_path = tmp.path().join("global").join("global.db"); - let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); - - fs::create_dir_all(project_root.join("src")) - .unwrap_or_else(|err| panic!("failed to create src dir: {err}")); - git(&project_root, &["init", "-b", "main"]); - fs::write( - project_root.join("src/lib.rs"), - "pub fn main_branch_symbol() {}\n", - ) - .unwrap_or_else(|err| panic!("failed to write fixture lib.rs: {err}")); - commit_all(&project_root, "initial commit"); - - let main = match TraceDecay::init(&project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to initialize fixture project: {err}"), - }; - index_all_retrying_sync_lock(&main, "failed to index main branch fixture").await; - drop(main); - - git(&project_root, &["checkout", "-b", "feature/dashboard-path"]); - fs::write( - project_root.join("src/feature.rs"), - "pub fn feature_branch_symbol() {}\n", - ) - .unwrap_or_else(|err| panic!("failed to write feature fixture: {err}")); - if let Err(err) = - TraceDecay::add_branch_tracking(&project_root, "feature/dashboard-path").await + .unwrap_or_else(|| panic!("expected plugin manifest array")) { - panic!("failed to track feature branch: {err}"); + let name = plugin["name"] + .as_str() + .unwrap_or_else(|| panic!("plugin name should be a string: {plugin}")); + for key in ["entry", "css"] { + let Some(asset) = plugin[key].as_str() else { + continue; + }; + let url = format!("{}/dashboard-plugins/{name}/{asset}", fixture.base_url); + let response = agent + .get(&url) + .call() + .unwrap_or_else(|err| panic!("GET {url} failed: {err}")); + assert_eq!( + response.status().as_u16(), + 200, + "advertised plugin asset should be served: {name} {asset}" + ); + } } - let cg = match TraceDecay::open(&project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to open feature branch fixture: {err}"), - }; - let expected = cg.db_path().display().to_string(); - assert!( - expected.replace('\\', "/").contains("/branches/"), - "fixture should serve a branch DB path, got {expected}" - ); - - let port = pick_free_port(); - let base_url = format!("http://127.0.0.1:{port}"); - let mut server = spawn_dashboard_server(cg, port); - let agent = http_agent(); - wait_for_dashboard(&agent, &base_url).await; - - let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); - server.stop(); - assert_eq!(status, 200); - assert_eq!(capabilities["graph_db"], expected); }); } #[test] -fn dashboard_uses_project_memory_db_and_branch_graph_db() { +fn holographic_dashboard_endpoints_return_seeded_payloads() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let runtime = create_runtime(); runtime.block_on(async { - let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); - let global_db_path = tmp.path().join("global").join("global.db"); - let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); - - fs::create_dir_all(project_root.join("src")) - .unwrap_or_else(|err| panic!("failed to create src dir: {err}")); - git(&project_root, &["init", "-b", "main"]); - fs::write( - project_root.join("src/lib.rs"), - "pub fn main_branch_symbol() {}\n", - ) - .unwrap_or_else(|err| panic!("failed to write fixture lib.rs: {err}")); - commit_all(&project_root, "initial commit"); - - let main = match TraceDecay::init(&project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to initialize fixture project: {err}"), - }; - index_all_retrying_sync_lock(&main, "failed to index main branch fixture").await; - drop(main); - - git(&project_root, &["checkout", "-b", "feature/dashboard-storage"]); - fs::write( - project_root.join("src/feature.rs"), - "pub fn feature_branch_symbol() {}\n", - ) - .unwrap_or_else(|err| panic!("failed to write feature fixture: {err}")); - if let Err(err) = - TraceDecay::add_branch_tracking(&project_root, "feature/dashboard-storage").await - { - panic!("failed to track feature branch: {err}"); - } - - let cg = match TraceDecay::open(&project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to open feature branch fixture: {err}"), - }; - let project_db_path = cg.store_layout().graph_db_path.clone(); - let project_db = libsql::Builder::new_local(&project_db_path) - .build() - .await - .unwrap_or_else(|err| panic!("failed to open project DB: {err}")); - let project_conn = project_db - .connect() - .unwrap_or_else(|err| panic!("failed to connect to project DB: {err}")); - project_conn - .execute( - "INSERT INTO memory_facts - (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - libsql::params![ - 9001_i64, - "Project memory must survive branch dashboard routing", - "project", - "[\"dashboard\",\"storage\"]", - 0.99_f64, - 1_i64, - 1_i64, - 1_700_100_000_i64, - 1_700_100_000_i64, - ], - ) - .await - .unwrap_or_else(|err| panic!("failed to seed project memory fact: {err}")); - - let branch_db_path = cg.db_path(); - assert!( - branch_db_path - .display() - .to_string() - .replace('\\', "/") - .contains("/branches/"), - "fixture should serve a branch graph DB path, got {}", - branch_db_path.display() - ); - - let port = pick_free_port(); - let base_url = format!("http://127.0.0.1:{port}"); - let mut server = spawn_dashboard_server(cg, port); - let agent = http_agent(); - wait_for_dashboard(&agent, &base_url).await; - - let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); - assert_eq!(status, 200); - assert_eq!(capabilities["memory_db"], project_db_path.display().to_string()); - assert_eq!(capabilities["graph_db"], branch_db_path.display().to_string()); - - let (status, memory) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/?limit=5&graph_limit=5"), - ); - assert_eq!(status, 200); - assert_eq!(memory["holographic"]["overview"]["facts"], 1); - - let (status, memory_status) = - get_json(&agent, &format!("{base_url}/api/plugins/holographic/status")); - assert_eq!(status, 200); - assert_eq!(memory_status["path"], project_db_path.display().to_string()); - assert_eq!(memory_status["memory"]["fact_count"], 1); - - let (status, graph_search) = get_json( - &agent, - &format!("{base_url}/api/plugins/graph/search?q=feature_branch_symbol"), - ); - server.stop(); - assert_eq!(status, 200); - assert!( - graph_search["total"].as_i64().unwrap_or_default() > 0, - "graph search should read the branch graph DB" - ); - }); -} - -#[test] -fn graph_bad_params_and_missing_neighbors_return_json_errors() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - let (status, bad_query) = get_json( - &agent, - &format!( - "{}/api/plugins/graph/search?limit=not-a-number", - fixture.base_url - ), - ); - assert_eq!(status, 400); - assert!( - bad_query["detail"] - .as_str() - .unwrap_or_default() - .contains("limit"), - "bad graph query rejection must be JSON with detail, got {bad_query}" - ); - - let (status, missing_neighbors) = get_json( - &agent, - &format!( - "{}/api/plugins/graph/node/missing-node/neighbors", - fixture.base_url - ), - ); - assert_eq!(status, 404); - assert!( - missing_neighbors["detail"] - .as_str() - .unwrap_or_default() - .contains("missing-node"), - "missing-neighbor body should carry the requested id" - ); - }); -} - -#[test] -fn dashboard_plugin_manifest_assets_are_served() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - let (status, plugins) = get_json( - &agent, - &format!("{}/api/dashboard/plugins", fixture.base_url), - ); - assert_eq!(status, 200); - for plugin in plugins - .as_array() - .unwrap_or_else(|| panic!("expected plugin manifest array")) - { - let name = plugin["name"] - .as_str() - .unwrap_or_else(|| panic!("plugin name should be a string: {plugin}")); - for key in ["entry", "css"] { - let Some(asset) = plugin[key].as_str() else { - continue; - }; - let url = format!("{}/dashboard-plugins/{name}/{asset}", fixture.base_url); - let response = agent - .get(&url) - .call() - .unwrap_or_else(|err| panic!("GET {url} failed: {err}")); - assert_eq!( - response.status().as_u16(), - 200, - "advertised plugin asset should be served: {name} {asset}" - ); - } - } - }); -} - -#[test] -fn holographic_dashboard_endpoints_return_seeded_payloads() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - let (status, overview) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/?q=cache&limit=5&graph_limit=10", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(overview["providers"]["memory_provider"], "tracedecay"); - assert_eq!(overview["holographic"]["overview"]["facts"], 3); - assert_eq!(overview["holographic"]["overview"]["banks"], 3); - assert_eq!(overview["holographic"]["overview"]["entities"], 3); - // Bank list counts must be live (consistent with the header fact - // count). The stored bundle snapshot still stays exposed as - // bundled_fact_count, but startup backfill rebuilds now refresh the - // seeded project bank to the live membership count. - let memory_banks = overview["holographic"]["overview"]["memory_banks"] - .as_array() - .unwrap_or_else(|| panic!("expected memory_banks array")); - let project_bank = memory_banks - .iter() - .find(|bank| bank["bank_name"] == "project") - .unwrap_or_else(|| panic!("expected project bank in memory_banks")); - assert_eq!( - project_bank["fact_count"], 2, - "bank list must report live membership counts" - ); - assert_eq!( - project_bank["bundled_fact_count"], 2, - "startup bank rebuild should refresh the bundled project snapshot to the live membership count" - ); - let facts = overview["holographic"]["facts"] - .as_array() - .unwrap_or_else(|| panic!("expected facts array in overview payload")); - assert_eq!(facts.len(), 2, "query should filter to cache facts only"); - // Access tracking is part of every fact payload (seeded rows carry - // the column defaults). - assert!( - facts - .iter() - .all(|fact| fact["access_count"].is_number() - && fact.get("last_recalled_at").is_some()), - "fact list rows must surface access_count and last_recalled_at" - ); - let graph_nodes = overview["holographic"]["graph"]["nodes"] - .as_array() - .unwrap_or_else(|| panic!("expected graph nodes array")); - assert!( - graph_nodes.iter().any(|node| node["kind"] == "entity"), - "graph should include entity nodes" - ); - let growth = overview["holographic"]["overview"]["growth"] - .as_array() - .unwrap_or_else(|| panic!("expected growth series array")); - assert!( - !growth.is_empty(), - "growth should cover seeded historical facts" - ); - assert!( - growth.iter().all(|day| day["cumulative_facts"].is_number()), - "growth points should include cumulative fact counts" - ); - assert_eq!( - growth - .last() - .and_then(|day| day["cumulative_facts"].as_i64()), - Some(3), - "last cumulative growth point should include all seeded facts" - ); - - let (status, projection) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/projection?limit=5000", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(projection["limit"], 2000); - assert_eq!(projection["method"], "pca"); - assert_eq!(projection["dim"], 2048); - let projection_points = projection["points"] - .as_array() - .unwrap_or_else(|| panic!("expected projection points array")); - assert!( - projection_points.len() >= 2, - "projection should include at least two PCA points" - ); - assert!( - projection_points[0]["x"].is_number() && projection_points[0]["y"].is_number(), - "projection points should include numeric x/y coordinates" - ); - let project_point = projection_points - .iter() - .find(|point| point["fact_id"].as_i64() == Some(101)) - .unwrap_or_else(|| panic!("expected projection point for fact 101")); - assert_eq!(project_point["bank_name"], "project"); - assert!( - project_point["bank_id"].is_number(), - "projection point should include numeric bank_id" - ); - assert_eq!(project_point["entity_count"], 1); - assert_eq!(project_point["connection_count"], 1); - let tool_point = projection_points - .iter() - .find(|point| point["fact_id"].as_i64() == Some(103)) - .unwrap_or_else(|| panic!("expected projection point for fact 103")); - assert_eq!(tool_point["entity_count"], 2); - assert_eq!(tool_point["connection_count"], 2); - - let (status, similarity) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/similarity?min_similarity=0.0&limit=5000", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(similarity["limit"], 2000); - assert_eq!(similarity["min_similarity"], 0.0); - assert_eq!(similarity["dim"], 2048); - assert_eq!(similarity["count"], 3); - assert_eq!(similarity["total_pairs"], 3); - let pairs = similarity["pairs"] - .as_array() - .unwrap_or_else(|| panic!("expected similarity pairs array")); - assert_eq!( - pairs.len(), - 3, - "min_similarity=0 should return pairs below the previous 0.5 floor" - ); - let duplicate_pair = pairs - .iter() - .find(|pair| pair["classification"] == "likely_duplicate") - .unwrap_or_else(|| panic!("expected likely_duplicate similarity pair")); - let duplicate_similarity = duplicate_pair["similarity"] - .as_f64() - .unwrap_or_else(|| panic!("expected numeric similarity")); - assert!( - duplicate_similarity < 1.0 && duplicate_similarity > 0.9999, - "similarity should retain full precision instead of rounding to four decimals" - ); - let distribution = &similarity["score_distribution"]; - let bins = distribution["bins"] - .as_array() - .unwrap_or_else(|| panic!("expected score distribution bins")); - assert!(!bins.is_empty(), "score distribution should include bins"); - let binned_pairs: i64 = bins - .iter() - .map(|bin| bin["count"].as_i64().unwrap_or(0)) - .sum(); - assert_eq!(distribution["total_pairs"], 3); - assert_eq!( - binned_pairs, 3, - "distribution bins should cover every computed pair" - ); - assert_eq!( - distribution["min"], distribution["min_score"], - "bins should adapt to the observed score range" - ); - assert_eq!( - distribution["max"], distribution["max_score"], - "bins should adapt to the observed score range" - ); - let occupied_bins = bins - .iter() - .filter(|bin| bin["count"].as_i64().unwrap_or(0) > 0) - .count(); - assert!( - occupied_bins >= 2, - "adaptive binning should spread near-duplicate and unrelated pairs across bins" - ); - assert!( - pairs - .iter() - .any(|pair| pair["classification"] == "likely_duplicate"), - "fixture vectors should produce a likely_duplicate pair" - ); - - let (status, curation_status) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/curation/status", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(curation_status["config"]["enabled"], true); - - let (status, curation_activity) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/curation/activity?limit=75", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(curation_activity["count"], 0); - assert_eq!(curation_activity["events"], Value::Array(Vec::new())); - - let (status, curation_preview) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/curation/preview", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert!(curation_preview["report"].is_null()); - assert_eq!(curation_preview["stale"], false); - - // Curation dry-run should return a valid plan (the fixture has a likely-duplicate pair). - let (status, curate) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), - ); - assert_eq!(status, 200); - assert_eq!(curate["ran"], true); - assert_eq!(curate["dry_run"], true); - assert!( - curate["actions"].as_array().is_some(), - "curate dry-run should return an actions array" - ); - // The deterministic hygiene candidate section is always present. - for key in ["secret_like", "transient", "supersession"] { - assert!( - curate["hygiene_candidates"][key].as_array().is_some(), - "curate dry-run should include hygiene_candidates.{key} proposals" - ); - } - }); -} - -#[test] -fn holographic_fact_detail_returns_full_content_and_entities() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - assert!( - LONG_FACT_CONTENT.chars().count() > 200, - "fixture must exceed the 200-char list/projection truncation" - ); - - // The projection payload truncates content at 200 chars by design. - let (status, projection) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/projection?limit=2000", - fixture.base_url - ), - ); - assert_eq!(status, 200); - let truncated_point = projection["points"] - .as_array() - .and_then(|points| { - points - .iter() - .find(|point| point["fact_id"].as_i64() == Some(103)) - }) - .unwrap_or_else(|| panic!("expected projection point for fact 103")); - assert_eq!( - truncated_point["content"] - .as_str() - .unwrap_or_default() - .chars() - .count(), - 200, - "projection content stays truncated at 200 chars" - ); - - // The detail endpoint returns the complete row plus linked entities. - let (status, detail) = get_json( - &agent, - &format!("{}/api/plugins/holographic/fact/103", fixture.base_url), - ); - assert_eq!(status, 200); - assert_eq!(detail["error"], ""); - assert_eq!(detail["fact"]["fact_id"], 103); - assert_eq!(detail["fact"]["category"], "tool"); - assert_eq!(detail["fact"]["content"], LONG_FACT_CONTENT); - assert_eq!(detail["fact"]["has_hrr"], 1); - assert_eq!(detail["fact"]["trust_score"], 0.76); - assert!( - detail["fact"]["access_count"].is_number(), - "fact detail must surface access_count" - ); - assert!( - detail["fact"].get("last_recalled_at").is_some(), - "fact detail must surface last_recalled_at" - ); - let entities = detail["fact"]["entities"] - .as_array() - .unwrap_or_else(|| panic!("expected entities array in fact detail")); - let entity_names: Vec<&str> = entities - .iter() - .filter_map(|entity| entity["name"].as_str()) - .collect(); - assert_eq!( - entity_names, - vec!["LCMTab", "SimilarityView"], - "fact detail must list linked entities sorted by name" - ); - - // Unknown ids are a 404 with the FastAPI-style detail body. - let (status, missing) = get_json( - &agent, - &format!("{}/api/plugins/holographic/fact/99999", fixture.base_url), - ); - assert_eq!(status, 404); - assert!( - missing["detail"] - .as_str() - .unwrap_or_default() - .contains("99999"), - "404 body should carry the requested fact id" - ); - }); -} - -#[test] -fn holographic_fact_trust_history_returns_feedback_trail_and_empty_for_unreviewed_facts() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let conn = project_db_conn(&fixture).await; - conn.execute( - "INSERT INTO memory_feedback_events - (fact_id, action, trust_delta, old_trust, new_trust, created_at, source, note) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - libsql::params![ - 103_i64, - "helpful", - 0.05_f64, - 0.71_f64, - 0.76_f64, - 1_700_000_450_i64, - "dashboard-test", - "confirmed durable" - ], - ) - .await - .unwrap_or_else(|err| panic!("failed to insert helpful feedback row: {err}")); - conn.execute( - "INSERT INTO memory_feedback_events - (fact_id, action, trust_delta, old_trust, new_trust, created_at, source, note) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - libsql::params![ - 103_i64, - "unhelpful", - -0.10_f64, - 0.76_f64, - 0.66_f64, - 1_700_000_460_i64, - "dashboard-test", - libsql::Value::Null - ], - ) - .await - .unwrap_or_else(|err| panic!("failed to insert unhelpful feedback row: {err}")); - - let agent = http_agent(); - let (status, history) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/fact/103/trust-history", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(history["error"], ""); - assert_eq!(history["fact_id"], 103); - let trail = history["trust_history"] - .as_array() - .unwrap_or_else(|| panic!("expected trust_history array: {history}")); - assert_eq!(trail.len(), 2); - assert_eq!(trail[0]["timestamp"], 1_700_000_450_i64); - assert_eq!(trail[0]["action"], "helpful"); - assert_eq!(trail[0]["old_trust"], 0.71); - assert_eq!(trail[0]["new_trust"], 0.76); - assert_eq!(trail[0]["delta"], 0.05); - assert_eq!(trail[0]["source"], "dashboard-test"); - assert_eq!(trail[0]["note"], "confirmed durable"); - assert_eq!(trail[1]["action"], "unhelpful"); - assert!(trail[1]["note"].is_null()); - - let (status, empty_history) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/fact/101/trust-history", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(empty_history["fact_id"], 101); - assert_eq!( - empty_history["trust_history"] - .as_array() - .map(|rows| rows.len()), - Some(0) - ); - - let (status, missing) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/fact/99999/trust-history", - fixture.base_url - ), - ); - assert_eq!(status, 404); - assert!( - missing["detail"] - .as_str() - .unwrap_or_default() - .contains("99999"), - "404 body should carry the requested fact id" - ); - }); -} - -#[test] -fn curate_hygiene_scans_unvectored_facts() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let conn = project_db_conn(&fixture).await; - conn.execute( - "INSERT INTO memory_facts - (fact_id, content, category, tags, trust_score, created_at, updated_at, source, metadata) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - libsql::params![ - 901_i64, - "api_key=Zx9mQ4tR7wLp2NvK8sBd1FgH", - "project", - "[]", - 0.5_f64, - 1_700_000_200_i64, - 1_700_000_200_i64, - "test", - "{}" - ], - ) - .await - .unwrap_or_else(|err| panic!("failed to insert unvectored hygiene fact: {err}")); - - let agent = http_agent(); - let (status, curate) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), - ); - - assert_eq!(status, 200); - let secret_like = curate["hygiene_candidates"]["secret_like"] - .as_array() - .unwrap_or_else(|| panic!("expected hygiene_candidates.secret_like array")); - let secret_candidate = secret_like - .iter() - .find(|action| action["fact_id"].as_i64() == Some(901)) - .unwrap_or_else(|| { - panic!("hygiene scan must include secret-like facts without HRR vectors: {curate}") - }); - assert_eq!(secret_candidate["status"], "candidate"); - assert_eq!(secret_candidate["review_required"], true); - assert_eq!(secret_candidate["recommended_op"], "delete"); - - let (status, applied) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": false }), - ); - assert_eq!(status, 200); - assert!(applied["hygiene_candidates"]["secret_like"] - .as_array() - .is_some_and(|candidates| candidates - .iter() - .any(|candidate| candidate["fact_id"].as_i64() == Some(901)))); - assert_eq!( - count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", - 901, - ) - .await, - 1, - "deterministic curate apply must not delete hygiene candidates without explicit review" - ); - }); -} - -#[test] -fn curation_delete_lifecycle() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - // --- Dry-run curation: expect a delete plan for the likely-duplicate pair --- - let (status, dry) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), - ); - assert_eq!(status, 200); - assert_eq!(dry["ran"], true); - assert_eq!(dry["dry_run"], true); - assert_eq!(dry["llm_calls"], 0); - let actions = dry["actions"] - .as_array() - .unwrap_or_else(|| panic!("expected actions array")); - assert!( - !actions.is_empty(), - "fixture with likely-duplicate vectors should produce at least one delete action" - ); - assert_eq!(actions[0]["op"], "delete"); - assert!( - actions[0]["fact_id"].is_number(), - "action must have fact_id" - ); - assert!( - actions[0]["duplicate_of"].is_number(), - "action must reference the surviving duplicate" - ); - let planned_delete_id = actions[0]["fact_id"] - .as_i64() - .unwrap_or_else(|| panic!("fact_id must be an integer")); - assert_eq!(dry["counts"]["delete"], actions.len() as i64); - assert_eq!(dry["coverage"]["active_total"], 3); - - // Preview should now be available and fresh. - let (status, preview) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/curation/preview", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert!( - !preview["report"].is_null(), - "preview should be non-null after a dry-run" - ); - assert_eq!(preview["stale"], false); - - // Curation status should reflect the preview timestamp. - let (status, curation_status) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/curation/status", - fixture.base_url - ), - ); - assert_eq!(status, 200); - assert_eq!(curation_status["config"]["enabled"], true); - assert!( - !curation_status["state"]["last_preview_at"].is_null(), - "last_preview_at should be set after dry-run" - ); + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); - let (status, dry_activity) = get_json( + let (status, overview) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/activity?limit=75", + "{}/api/plugins/holographic/?q=cache&limit=5&graph_limit=10", fixture.base_url ), ); assert_eq!(status, 200); - assert_eq!(dry_activity["error"], ""); - assert_eq!(dry_activity["limit"], 75); - let dry_events = dry_activity["events"] + assert_eq!(overview["providers"]["memory_provider"], "tracedecay"); + assert_eq!(overview["holographic"]["overview"]["facts"], 3); + assert_eq!(overview["holographic"]["overview"]["banks"], 3); + assert_eq!(overview["holographic"]["overview"]["entities"], 3); + // Bank list counts must be live (consistent with the header fact + // count). The stored bundle snapshot still stays exposed as + // bundled_fact_count, but startup backfill rebuilds now refresh the + // seeded project bank to the live membership count. + let memory_banks = overview["holographic"]["overview"]["memory_banks"] .as_array() - .unwrap_or_else(|| panic!("expected dry-run activity events array")); + .unwrap_or_else(|| panic!("expected memory_banks array")); + let project_bank = memory_banks + .iter() + .find(|bank| bank["bank_name"] == "project") + .unwrap_or_else(|| panic!("expected project bank in memory_banks")); + assert_eq!( + project_bank["fact_count"], 2, + "bank list must report live membership counts" + ); assert_eq!( - dry_activity["count"].as_u64(), - Some(dry_events.len() as u64) + project_bank["bundled_fact_count"], 2, + "startup bank rebuild should refresh the bundled project snapshot to the live membership count" ); + let facts = overview["holographic"]["facts"] + .as_array() + .unwrap_or_else(|| panic!("expected facts array in overview payload")); + assert_eq!(facts.len(), 2, "query should filter to cache facts only"); + // Access tracking is part of every fact payload (seeded rows carry + // the column defaults). assert!( - !dry_events.is_empty(), - "dry-run curation should emit activity events" + facts + .iter() + .all(|fact| fact["access_count"].is_number() + && fact.get("last_recalled_at").is_some()), + "fact list rows must surface access_count and last_recalled_at" ); + let graph_nodes = overview["holographic"]["graph"]["nodes"] + .as_array() + .unwrap_or_else(|| panic!("expected graph nodes array")); assert!( - dry_events.iter().any(|event| { - event["phase"] == "finish" - && event["dry_run"] == true - && event["message"] - .as_str() - .is_some_and(|message| !message.is_empty()) - && event["ts"].as_str().is_some_and(|ts| !ts.is_empty()) - }), - "dry-run curation should emit a finish activity event" + graph_nodes.iter().any(|node| node["kind"] == "entity"), + "graph should include entity nodes" ); - - // --- Apply curation: hard-delete the duplicate --- - let (status, applied) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": false }), + let growth = overview["holographic"]["overview"]["growth"] + .as_array() + .unwrap_or_else(|| panic!("expected growth series array")); + assert!( + !growth.is_empty(), + "growth should cover seeded historical facts" ); - assert_eq!(status, 200); - assert_eq!(applied["ran"], true); - assert_eq!(applied["dry_run"], false); assert!( - applied["applied_counts"]["delete"].as_i64().unwrap_or(0) > 0, - "apply should report at least one deleted fact" + growth.iter().all(|day| day["cumulative_facts"].is_number()), + "growth points should include cumulative fact counts" + ); + assert_eq!( + growth + .last() + .and_then(|day| day["cumulative_facts"].as_i64()), + Some(3), + "last cumulative growth point should include all seeded facts" ); - let (status, apply_activity) = get_json( + let (status, projection) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/activity?limit=75", + "{}/api/plugins/holographic/projection?limit=5000", fixture.base_url ), ); assert_eq!(status, 200); - let apply_events = apply_activity["events"] + assert_eq!(projection["limit"], 2000); + assert_eq!(projection["method"], "pca"); + assert_eq!(projection["dim"], 2048); + let projection_points = projection["points"] .as_array() - .unwrap_or_else(|| panic!("expected apply activity events array")); - assert_eq!( - apply_activity["count"].as_u64(), - Some(apply_events.len() as u64) + .unwrap_or_else(|| panic!("expected projection points array")); + assert!( + projection_points.len() >= 2, + "projection should include at least two PCA points" ); assert!( - apply_events.len() > dry_events.len(), - "apply should append activity events after dry-run events" + projection_points[0]["x"].is_number() && projection_points[0]["y"].is_number(), + "projection points should include numeric x/y coordinates" ); + let project_point = projection_points + .iter() + .find(|point| point["fact_id"].as_i64() == Some(101)) + .unwrap_or_else(|| panic!("expected projection point for fact 101")); + assert_eq!(project_point["bank_name"], "project"); assert!( - apply_events - .iter() - .rev() - .any(|event| event["phase"] == "finish" && event["dry_run"] == false), - "apply curation should emit a finish activity event" + project_point["bank_id"].is_number(), + "projection point should include numeric bank_id" ); + assert_eq!(project_point["entity_count"], 1); + assert_eq!(project_point["connection_count"], 1); + let tool_point = projection_points + .iter() + .find(|point| point["fact_id"].as_i64() == Some(103)) + .unwrap_or_else(|| panic!("expected projection point for fact 103")); + assert_eq!(tool_point["entity_count"], 2); + assert_eq!(tool_point["connection_count"], 2); - let (status, status_after_apply) = get_json( + let (status, similarity) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/status", + "{}/api/plugins/holographic/similarity?min_similarity=0.0&limit=5000", fixture.base_url ), ); assert_eq!(status, 200); - assert_eq!(status_after_apply["state"]["run_count"], 1); - assert!( - status_after_apply["state"]["last_run_at"] - .as_str() - .is_some_and(|ts| !ts.is_empty()), - "last_run_at should be set after apply" + assert_eq!(similarity["limit"], 2000); + assert_eq!(similarity["min_similarity"], 0.0); + assert_eq!(similarity["dim"], 2048); + assert_eq!(similarity["count"], 3); + assert_eq!(similarity["total_pairs"], 3); + let pairs = similarity["pairs"] + .as_array() + .unwrap_or_else(|| panic!("expected similarity pairs array")); + assert_eq!( + pairs.len(), + 3, + "min_similarity=0 should return pairs below the previous 0.5 floor" ); + let duplicate_pair = pairs + .iter() + .find(|pair| pair["classification"] == "likely_duplicate") + .unwrap_or_else(|| panic!("expected likely_duplicate similarity pair")); + let duplicate_similarity = duplicate_pair["similarity"] + .as_f64() + .unwrap_or_else(|| panic!("expected numeric similarity")); assert!( - status_after_apply["state"]["last_run_summary"] - .as_str() - .is_some_and(|summary| summary.contains("deleted")), - "last_run_summary should describe the apply result" + duplicate_similarity < 1.0 && duplicate_similarity > 0.9999, + "similarity should retain full precision instead of rounding to four decimals" ); - assert!( - status_after_apply["snapshots"] - .as_array() - .is_some_and(|snapshots| !snapshots.is_empty()), - "status snapshots should include recent apply history" + let distribution = &similarity["score_distribution"]; + let bins = distribution["bins"] + .as_array() + .unwrap_or_else(|| panic!("expected score distribution bins")); + assert!(!bins.is_empty(), "score distribution should include bins"); + let binned_pairs: i64 = bins + .iter() + .map(|bin| bin["count"].as_i64().unwrap_or(0)) + .sum(); + assert_eq!(distribution["total_pairs"], 3); + assert_eq!( + binned_pairs, 3, + "distribution bins should cover every computed pair" ); - - // --- Overview should show fewer facts and not contain the deleted one --- - let (status, overview) = get_json( - &agent, - &format!("{}/api/plugins/holographic/", fixture.base_url), + assert_eq!( + distribution["min"], distribution["min_score"], + "bins should adapt to the observed score range" ); - assert_eq!(status, 200); - let fact_count = overview["holographic"]["overview"]["facts"] - .as_i64() - .unwrap_or(3); + assert_eq!( + distribution["max"], distribution["max_score"], + "bins should adapt to the observed score range" + ); + let occupied_bins = bins + .iter() + .filter(|bin| bin["count"].as_i64().unwrap_or(0) > 0) + .count(); assert!( - fact_count < 3, - "overview fact count should decrease after deletion" + occupied_bins >= 2, + "adaptive binning should spread near-duplicate and unrelated pairs across bins" ); - let facts = overview["holographic"]["facts"] - .as_array() - .unwrap_or_else(|| panic!("expected facts array")); assert!( - facts + pairs .iter() - .all(|fact| fact["fact_id"].as_i64() != Some(planned_delete_id)), - "deleted fact must not appear in the overview fact list" - ); - - // --- The row and its entity links must be gone from the store that - // tracedecay_fact_store recall reads (hard delete, not soft). --- - let remaining = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", - planned_delete_id, - ) - .await; - assert_eq!( - remaining, 0, - "deleted fact row must be gone from memory_facts" - ); - let remaining_links = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_fact_entities WHERE fact_id = ?1", - planned_delete_id, - ) - .await; - assert_eq!( - remaining_links, 0, - "entity links of a deleted fact must be cleaned up" + .any(|pair| pair["classification"] == "likely_duplicate"), + "fixture vectors should produce a likely_duplicate pair" ); - // Apply invalidates the saved preview. - let (status, preview_after) = get_json( + let (status, curation_status) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/preview", + "{}/api/plugins/holographic/curation/status", fixture.base_url ), ); assert_eq!(status, 200); - assert!(preview_after["report"].is_null()); - }); -} - -#[test] -fn curation_preview_marks_same_count_updates_stale() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - - let (status, dry) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), - ); - assert_eq!(status, 200); - assert_eq!(dry["dry_run"], true); - - let conn = project_db_conn(&fixture).await; - conn.execute( - "UPDATE memory_facts - SET content = content || ' after preview', updated_at = updated_at + 1 - WHERE fact_id = 101", - (), - ) - .await - .unwrap(); + assert_eq!(curation_status["config"]["enabled"], true); - let (status, preview) = get_json( + let (status, curation_activity) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/preview", + "{}/api/plugins/holographic/curation/activity?limit=75", fixture.base_url ), ); assert_eq!(status, 200); - assert_eq!( - preview["stale"], true, - "same-count edits must stale previews" - ); - assert!( - preview["stale_reason"] - .as_str() - .unwrap_or_default() - .contains("changed"), - "stale response should explain the memory store changed: {preview}" - ); - }); -} - -#[test] -fn memory_oplog_endpoint_lists_recent_operations() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); + assert_eq!(curation_activity["count"], 0); + assert_eq!(curation_activity["events"], Value::Array(Vec::new())); - // Fresh fixture: no operations recorded yet. - let (status, empty) = get_json( + let (status, curation_preview) = get_json( &agent, &format!( - "{}/api/plugins/holographic/oplog?limit=10", + "{}/api/plugins/holographic/curation/preview", fixture.base_url ), ); assert_eq!(status, 200); - assert_eq!(empty["count"], 0); - assert_eq!(empty["error"], ""); - - // An explicit-ops delete writes a per-fact "remove" row plus a - // "curate_apply" summary row. - let (status, applied) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate/apply", fixture.base_url), - &serde_json::json!({ - "ops": [{ "op": "delete", "fact_id": 103, "reason": "oplog fixture" }] - }), - ); - assert_eq!(status, 200); - assert_eq!(applied["counts"]["deleted"], 1); + assert!(curation_preview["report"].is_null()); + assert_eq!(curation_preview["stale"], false); - let (status, oplog) = get_json( + // Curation dry-run should return a valid plan (the fixture has a likely-duplicate pair). + let (status, curate) = post_json_body( &agent, - &format!( - "{}/api/plugins/holographic/oplog?limit=10", - fixture.base_url - ), + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": true }), ); assert_eq!(status, 200); - assert_eq!(oplog["error"], ""); - let events = oplog["events"] - .as_array() - .unwrap_or_else(|| panic!("expected oplog events array")); - assert_eq!(events.len(), 2, "expected remove + curate_apply rows"); - - // Newest first: the curate_apply summary follows the per-fact remove. - assert_eq!(events[0]["op"], "curate_apply"); - assert_eq!(events[0]["detail"]["deleted"], 1); - assert_eq!(events[1]["op"], "remove"); - assert_eq!(events[1]["fact_id"], 103); - let remove_detail = events[1]["detail"].to_string(); - assert!( - remove_detail.contains("content_hash"), - "remove rows must carry a content hash: {remove_detail}" - ); - assert!( - !remove_detail.contains("empty states"), - "remove rows must not leak deleted fact content: {remove_detail}" - ); + assert_eq!(curate["ran"], true); + assert_eq!(curate["dry_run"], true); assert!( - events.iter().all(|event| event["ts"].is_number()), - "every oplog row carries a timestamp" + curate["actions"].as_array().is_some(), + "curate dry-run should return an actions array" ); + // The deterministic hygiene candidate section is always present. + for key in ["secret_like", "transient", "supersession"] { + assert!( + curate["hygiene_candidates"][key].as_array().is_some(), + "curate dry-run should include hygiene_candidates.{key} proposals" + ); + } }); } #[test] -fn curate_apply_ops_contract() { +fn holographic_fact_detail_returns_full_content_and_entities() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -1858,313 +297,187 @@ fn curate_apply_ops_contract() { runtime.block_on(async { let fixture = start_dashboard_fixture(false).await; let agent = http_agent(); - let apply_url = format!("{}/api/plugins/holographic/curate/apply", fixture.base_url); - - // Merge: fact 102 into 101 with rewritten content, plus an explicit - // delete of 103, plus an invalid delete — partial failure stays per-op. - let (status, response) = post_json_body( - &agent, - &apply_url, - &serde_json::json!({ - "ops": [ - { - "op": "merge", - "winner_id": 101, - "loser_ids": [102], - "merged_content": "Cache invalidation policy must be explicit (merged)" - }, - { "op": "delete", "fact_id": 103, "reason": "manual cleanup" }, - { "op": "delete", "fact_id": 99999 }, - { "op": "frobnicate" } - ] - }), - ); - assert_eq!(status, 200, "partial failures must not fail the request"); - let results = response["results"] - .as_array() - .unwrap_or_else(|| panic!("expected results array")); - assert_eq!(results.len(), 4); - - assert_eq!(results[0]["op"], "merge"); - assert_eq!( - results[0]["status"], "merged", - "merge op failed: {response}" - ); - assert_eq!(results[0]["content_updated"], true); - assert_eq!(results[0]["deleted_loser_ids"], serde_json::json!([102])); - - assert_eq!(results[1]["op"], "delete"); - assert_eq!(results[1]["status"], "deleted"); - assert_eq!(results[1]["fact_id"], 103); - - assert_eq!(results[2]["status"], "error"); - assert!( - results[2]["error"] - .as_str() - .unwrap_or_default() - .contains("not found"), - "invalid fact_id must produce a per-op not-found error" - ); - assert_eq!(results[3]["status"], "error"); assert!( - results[3]["error"] - .as_str() - .unwrap_or_default() - .contains("unsupported op"), - "unknown op kinds must produce a per-op error" + LONG_FACT_CONTENT.chars().count() > 200, + "fixture must exceed the 200-char list/projection truncation" ); - assert_eq!(response["counts"]["deleted"], 1); - assert_eq!(response["counts"]["merged"], 1); - assert_eq!(response["counts"]["errors"], 2); - - let (status, apply_activity) = get_json( + // The projection payload truncates content at 200 chars by design. + let (status, projection) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/activity?limit=25", + "{}/api/plugins/holographic/projection?limit=2000", fixture.base_url ), ); assert_eq!(status, 200); - let apply_events = apply_activity["events"] + let truncated_point = projection["points"] .as_array() - .unwrap_or_else(|| panic!("expected generic apply activity events array")); - assert!( - apply_events.iter().any(|event| { - event["phase"] == "finish" - && event["dry_run"] == false - && event["message"].as_str().is_some_and(|message| { - message.contains("Explicit apply completed") - && message.contains("1 delete") - && message.contains("1 merge") - && message.contains("2 op(s) errored") - }) - && event["ts"].as_str().is_some_and(|ts| !ts.is_empty()) - }), - "/curate/apply should emit a finish activity event: {apply_activity}" + .and_then(|points| { + points + .iter() + .find(|point| point["fact_id"].as_i64() == Some(103)) + }) + .unwrap_or_else(|| panic!("expected projection point for fact 103")); + assert_eq!( + truncated_point["content"] + .as_str() + .unwrap_or_default() + .chars() + .count(), + 200, + "projection content stays truncated at 200 chars" ); - let (status, apply_status) = get_json( + // The detail endpoint returns the complete row plus linked entities. + let (status, detail) = get_json( &agent, - &format!( - "{}/api/plugins/holographic/curation/status", - fixture.base_url - ), + &format!("{}/api/plugins/holographic/fact/103", fixture.base_url), ); assert_eq!(status, 200); - assert_eq!(apply_status["state"]["run_count"], 1); - assert!( - apply_status["state"]["last_run_at"] - .as_str() - .is_some_and(|ts| !ts.is_empty()), - "last_run_at should be set after /curate/apply" - ); - let summary = apply_status["state"]["last_run_summary"] - .as_str() - .unwrap_or_default(); + assert_eq!(detail["error"], ""); + assert_eq!(detail["fact"]["fact_id"], 103); + assert_eq!(detail["fact"]["category"], "tool"); + assert_eq!(detail["fact"]["content"], LONG_FACT_CONTENT); + assert_eq!(detail["fact"]["has_hrr"], 1); + assert_eq!(detail["fact"]["trust_score"], 0.76); assert!( - summary.contains("Explicit apply completed") - && summary.contains("1 delete") - && summary.contains("1 merge") - && summary.contains("2 op(s) errored"), - "/curate/apply should drive the status summary: {apply_status}" + detail["fact"]["access_count"].is_number(), + "fact detail must surface access_count" ); assert!( - apply_status["snapshots"] - .as_array() - .is_some_and(|snapshots| { - snapshots.iter().any(|snapshot| { - snapshot["summary"] - .as_str() - .is_some_and(|summary| summary.contains("Explicit apply completed")) - }) - }), - "/curate/apply should appear in status snapshots: {apply_status}" - ); - - // Hard deletes: rows + entity links gone from the project DB. - for gone_id in [102_i64, 103] { - let remaining = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", - gone_id, - ) - .await; - assert_eq!(remaining, 0, "fact {gone_id} must be hard-deleted"); - let links = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_fact_entities WHERE fact_id = ?1", - gone_id, - ) - .await; - assert_eq!(links, 0, "entity links of fact {gone_id} must be gone"); - } - - // Winner survived with merged content. - let (status, overview) = get_json( - &agent, - &format!( - "{}/api/plugins/holographic/?q=merged&limit=10", - fixture.base_url - ), + detail["fact"].get("last_recalled_at").is_some(), + "fact detail must surface last_recalled_at" ); - assert_eq!(status, 200); - let facts = overview["holographic"]["facts"] + let entities = detail["fact"]["entities"] .as_array() - .unwrap_or_else(|| panic!("expected facts array")); - assert!( - facts.iter().any(|fact| { - fact["fact_id"].as_i64() == Some(101) - && fact["content"] - .as_str() - .unwrap_or_default() - .contains("(merged)") - }), - "winner fact must survive with the merged content" + .unwrap_or_else(|| panic!("expected entities array in fact detail")); + let entity_names: Vec<&str> = entities + .iter() + .filter_map(|entity| entity["name"].as_str()) + .collect(); + assert_eq!( + entity_names, + vec!["LCMTab", "SimilarityView"], + "fact detail must list linked entities sorted by name" ); - // Merge with a missing winner: per-op error, losers untouched. - let (status, response) = post_json_body( + // Unknown ids are a 404 with the FastAPI-style detail body. + let (status, missing) = get_json( &agent, - &apply_url, - &serde_json::json!({ - "ops": [{ "op": "merge", "winner_id": 4242, "loser_ids": [101] }] - }), - ); - assert_eq!(status, 200); - assert_eq!(response["results"][0]["status"], "error"); - assert_eq!(response["counts"]["errors"], 1); - let survivor = count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", - 101, - ) - .await; - assert_eq!( - survivor, 1, - "loser must be untouched when the winner is missing" + &format!("{}/api/plugins/holographic/fact/99999", fixture.base_url), ); - - // Malformed body (no ops field) is the only whole-request failure mode. - let (status, _) = post_json(&agent, &apply_url); + assert_eq!(status, 404); assert!( - status == 400 || status == 415 || status == 422, - "missing/malformed body should be rejected, got {status}" + missing["detail"] + .as_str() + .unwrap_or_default() + .contains("99999"), + "404 body should carry the requested fact id" ); }); } #[test] -fn curate_apply_merge_with_missing_loser_is_atomic() { +fn holographic_fact_trust_history_returns_feedback_trail_and_empty_for_unreviewed_facts() { let _env_lock = GLOBAL_DB_ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let runtime = create_runtime(); runtime.block_on(async { let fixture = start_dashboard_fixture(false).await; - let agent = http_agent(); - let apply_url = format!("{}/api/plugins/holographic/curate/apply", fixture.base_url); - - let (status, dry) = post_json_body( - &agent, - &format!("{}/api/plugins/holographic/curate", fixture.base_url), - &serde_json::json!({ "dry_run": true }), - ); - assert_eq!(status, 200); - assert_eq!(dry["dry_run"], true); - - let original_winner = string_in_project_db( - &fixture, - "SELECT content FROM memory_facts WHERE fact_id = ?1", - 101, + let conn = project_db_conn(&fixture).await; + conn.execute( + "INSERT INTO memory_feedback_events + (fact_id, action, trust_delta, old_trust, new_trust, created_at, source, note) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + libsql::params![ + 103_i64, + "helpful", + 0.05_f64, + 0.71_f64, + 0.76_f64, + 1_700_000_450_i64, + "dashboard-test", + "confirmed durable" + ], + ) + .await + .unwrap_or_else(|err| panic!("failed to insert helpful feedback row: {err}")); + conn.execute( + "INSERT INTO memory_feedback_events + (fact_id, action, trust_delta, old_trust, new_trust, created_at, source, note) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + libsql::params![ + 103_i64, + "unhelpful", + -0.10_f64, + 0.76_f64, + 0.66_f64, + 1_700_000_460_i64, + "dashboard-test", + libsql::Value::Null + ], ) .await - .expect("winner content"); + .unwrap_or_else(|err| panic!("failed to insert unhelpful feedback row: {err}")); - let (status, response) = post_json_body( + let agent = http_agent(); + let (status, history) = get_json( &agent, - &apply_url, - &serde_json::json!({ - "ops": [{ - "op": "merge", - "winner_id": 101, - "loser_ids": [102, 99999], - "merged_content": "Cache invalidation policy should not partially merge" - }] - }), - ); - assert_eq!(status, 200, "per-op failures stay in-band"); - assert_eq!(response["counts"]["deleted"], 0); - assert_eq!(response["counts"]["merged"], 0); - assert_eq!(response["counts"]["errors"], 1); - assert_eq!(response["results"][0]["op"], "merge"); - assert_eq!(response["results"][0]["status"], "error"); - assert!( - response["results"][0]["error"] - .as_str() - .unwrap_or_default() - .contains("loser fact 99999 not found"), - "missing loser should be reported before mutation: {response}" + &format!( + "{}/api/plugins/holographic/fact/103/trust-history", + fixture.base_url + ), ); + assert_eq!(status, 200); + assert_eq!(history["error"], ""); + assert_eq!(history["fact_id"], 103); + let trail = history["trust_history"] + .as_array() + .unwrap_or_else(|| panic!("expected trust_history array: {history}")); + assert_eq!(trail.len(), 2); + assert_eq!(trail[0]["timestamp"], 1_700_000_450_i64); + assert_eq!(trail[0]["action"], "helpful"); + assert_eq!(trail[0]["old_trust"], 0.71); + assert_eq!(trail[0]["new_trust"], 0.76); + assert_eq!(trail[0]["delta"], 0.05); + assert_eq!(trail[0]["source"], "dashboard-test"); + assert_eq!(trail[0]["note"], "confirmed durable"); + assert_eq!(trail[1]["action"], "unhelpful"); + assert!(trail[1]["note"].is_null()); - let winner_after = string_in_project_db( - &fixture, - "SELECT content FROM memory_facts WHERE fact_id = ?1", - 101, - ) - .await - .expect("winner content after failed merge"); - assert_eq!( - winner_after, original_winner, - "failed merge must not update winner content" - ); - assert_eq!( - count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", - 102, - ) - .await, - 1, - "failed merge must not delete valid losers" - ); - assert_eq!( - count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_oplog WHERE fact_id = ?1", - 101, - ) - .await, - 0, - "failed merge must not write a winner update oplog" + let (status, empty_history) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/fact/101/trust-history", + fixture.base_url + ), ); + assert_eq!(status, 200); + assert_eq!(empty_history["fact_id"], 101); assert_eq!( - count_in_project_db( - &fixture, - "SELECT COUNT(*) FROM memory_oplog WHERE fact_id = ?1", - 102, - ) - .await, - 0, - "failed merge must not write loser delete oplogs" + empty_history["trust_history"] + .as_array() + .map(|rows| rows.len()), + Some(0) ); - let (status, preview) = get_json( + let (status, missing) = get_json( &agent, &format!( - "{}/api/plugins/holographic/curation/preview", + "{}/api/plugins/holographic/fact/99999/trust-history", fixture.base_url ), ); - assert_eq!(status, 200); + assert_eq!(status, 404); assert!( - !preview["report"].is_null(), - "failed merge must not clear saved preview" - ); - assert_eq!( - preview["stale"], false, - "unchanged store should leave preview fresh" + missing["detail"] + .as_str() + .unwrap_or_default() + .contains("99999"), + "404 body should carry the requested fact id" ); }); } @@ -2416,7 +729,6 @@ fn lcm_project_store_wins_over_global_accounting_override() { let profile_root = tmp_root.join("profile").join(".tracedecay"); let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); - let cg = setup_project(&project_root).await; // The project store has rows; the overridden global accounting store has none. let session_store = open_project_session_store(&project_root).await; @@ -2458,144 +770,3 @@ fn lcm_project_store_wins_over_global_accounting_override() { server.stop(); }); } - -/// The dry-run curation preview must survive a dashboard restart: it is -/// mirrored to the resolved dashboard sidecar path and re-hydrated by -/// `build_state`, and applying curation clears both the memory copy and the -/// sidecar. -#[test] -fn curation_preview_persists_across_dashboard_restarts() { - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let runtime = create_runtime(); - runtime.block_on(async { - let tmp = tempdir_or_panic(); - let tmp_root = tmp - .path() - .canonicalize() - .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); - let project_root = tmp_root.join("project"); - let global_db_path = tmp_root.join("global").join("global.db"); - let profile_root = tmp_root.join("profile").join(".tracedecay"); - let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); - let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); - - let cg = setup_project(&project_root).await; - seed_memory_fixture(&cg).await; - let agent = http_agent(); - let sidecar = cg - .store_layout() - .dashboard_root - .join("curation_preview.json"); - - async fn start_server(cg: TraceDecay) -> (String, DashboardServer) { - let port = pick_free_port(); - let base_url = format!("http://127.0.0.1:{port}"); - let server = spawn_dashboard_server(cg, port); - (base_url, server) - } - - fn stop_server(mut server: DashboardServer) { - server.stop(); - } - - async fn reopen_project(project_root: &Path) -> TraceDecay { - match TraceDecay::open(project_root).await { - Ok(cg) => cg, - Err(err) => panic!("failed to reopen fixture project: {err}"), - } - } - - // Server 1: a dry-run saves the preview and writes the sidecar. - let (base_url, server) = start_server(cg).await; - wait_for_dashboard(&agent, &base_url).await; - let (status, curate) = post_json_body( - &agent, - &format!("{base_url}/api/plugins/holographic/curate"), - &serde_json::json!({ "dry_run": true }), - ); - assert_eq!(status, 200); - assert_eq!(curate["dry_run"], true); - let (status, preview) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/curation/preview"), - ); - assert_eq!(status, 200); - assert!(!preview["report"].is_null(), "dry-run must save a preview"); - let saved_at = preview["saved_at"].clone(); - assert!(saved_at.is_string(), "preview must carry saved_at"); - stop_server(server); - assert!( - sidecar.exists(), - "dry-run must persist the preview sidecar at {}", - sidecar.display() - ); - - // Server 2 (fresh state): the preview is re-hydrated from disk. - let cg = reopen_project(&project_root).await; - let (base_url, server) = start_server(cg).await; - wait_for_dashboard(&agent, &base_url).await; - let (status, preview) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/curation/preview"), - ); - assert_eq!(status, 200); - assert!( - !preview["report"].is_null(), - "preview must survive a server restart" - ); - assert_eq!( - preview["saved_at"], saved_at, - "re-hydrated preview must keep its original timestamp" - ); - assert_eq!( - preview["stale"], false, - "fact count is unchanged, so the restored preview is not stale" - ); - let (status, status_payload) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/curation/status"), - ); - assert_eq!(status, 200); - assert_eq!( - status_payload["state"]["last_preview_at"], saved_at, - "curation status must reflect the restored preview" - ); - - // Applying curation clears both the in-memory copy and the sidecar. - let (status, applied) = post_json_body( - &agent, - &format!("{base_url}/api/plugins/holographic/curate"), - &serde_json::json!({ "dry_run": false }), - ); - assert_eq!(status, 200); - assert_eq!(applied["dry_run"], false); - let (status, preview) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/curation/preview"), - ); - assert_eq!(status, 200); - assert!(preview["report"].is_null(), "apply must clear the preview"); - assert!( - !sidecar.exists(), - "apply must remove the persisted preview sidecar" - ); - stop_server(server); - - // Server 3: nothing is restored after the apply cleared the sidecar. - let cg = reopen_project(&project_root).await; - let (base_url, server) = start_server(cg).await; - wait_for_dashboard(&agent, &base_url).await; - let (status, preview) = get_json( - &agent, - &format!("{base_url}/api/plugins/holographic/curation/preview"), - ); - assert_eq!(status, 200); - assert!( - preview["report"].is_null(), - "no preview may reappear after curation was applied" - ); - stop_server(server); - }); -} diff --git a/tests/dashboard_automation_api_test.rs b/tests/dashboard_automation_api_test.rs new file mode 100644 index 00000000..80c34ca8 --- /dev/null +++ b/tests/dashboard_automation_api_test.rs @@ -0,0 +1,863 @@ +mod common; +mod dashboard_api_support; + +use dashboard_api_support::*; + +#[test] +fn curation_agent_plan_skips_when_automation_is_disabled_and_records_history() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let config_url = format!("{base_url}/api/plugins/holographic/curation/config"); + let (status, saved_config) = patch_json_body( + &agent, + &config_url, + &serde_json::json!({ + "enabled": false, + "backend": "codex_app_server", + "host_mode": "delegated_host", + "model": "queued-model" + }), + ); + assert_eq!(status, 200, "config patch should succeed: {saved_config}"); + assert_eq!(saved_config["effective"]["backend"], "codex_app_server"); + assert_eq!(saved_config["effective"]["host_mode"], "delegated_host"); + assert_eq!(saved_config["effective"]["model"], "queued-model"); + + let (status, payload) = post_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/agent-plan"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 200); + assert_eq!(payload["status"], "skipped"); + assert_eq!(payload["ledger_record"]["trigger"], "dashboard"); + assert_eq!(payload["ledger_record"]["error"], "automation_disabled"); + assert_eq!(payload["report"]["reason"], "automation_disabled"); + + let (status, memory_payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/memory-curator"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 202); + assert_eq!(memory_payload["status"], "queued"); + assert_eq!(memory_payload["ledger_record"]["trigger"], "dashboard"); + assert_eq!(memory_payload["ledger_record"]["task"], "memory_curator"); + assert_eq!( + memory_payload["ledger_record"]["backend"], + "codex_app_server" + ); + assert_eq!( + memory_payload["ledger_record"]["host_mode"], + "delegated_host" + ); + assert_eq!(memory_payload["ledger_record"]["model"], "queued-model"); + + let (status, session_payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/session-reflection"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 202); + assert_eq!(session_payload["status"], "queued"); + assert_eq!(session_payload["ledger_record"]["trigger"], "dashboard"); + assert_eq!( + session_payload["ledger_record"]["task"], + "session_reflector" + ); + assert_eq!( + session_payload["ledger_record"]["backend"], + "codex_app_server" + ); + assert_eq!( + session_payload["ledger_record"]["host_mode"], + "delegated_host" + ); + assert_eq!(session_payload["ledger_record"]["model"], "queued-model"); + + let (status, skill_payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/skill-writing"), + &serde_json::json!({ + "dry_run": true, + "provider": "cursor", + "query": "workflow corrections", + "evidence_limit": 7 + }), + ); + assert_eq!(status, 202); + assert_eq!(skill_payload["status"], "queued"); + assert_eq!(skill_payload["ledger_record"]["trigger"], "dashboard"); + assert_eq!(skill_payload["ledger_record"]["task"], "skill_writer"); + assert_eq!( + skill_payload["ledger_record"]["backend"], + "codex_app_server" + ); + assert_eq!( + skill_payload["ledger_record"]["host_mode"], + "delegated_host" + ); + assert_eq!(skill_payload["ledger_record"]["model"], "queued-model"); + + let mut rejected_skill_shape = agent + .post(&format!("{base_url}/api/automation/run/skill-writing")) + .send_json(serde_json::json!({ + "dry_run": true, + "storage_scope": "project_local" + })) + .expect("skill-writing request with unsupported field should receive response"); + let rejected_skill_status = rejected_skill_shape.status().as_u16(); + let rejected_skill_body = rejected_skill_shape + .body_mut() + .read_to_string() + .expect("skill-writing rejection body should be readable"); + assert_eq!(rejected_skill_status, 422); + assert!( + rejected_skill_body.contains("storage_scope"), + "rejection should name the unsupported field: {rejected_skill_body}" + ); + + let (status, rejected) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/session-reflection"), + &serde_json::json!({ "dry_run": false }), + ); + assert_eq!(status, 400); + assert!( + rejected["detail"] + .as_str() + .is_some_and(|detail| detail.contains("dry_run=true")), + "dry-run guard should explain the approval-only contract: {rejected}" + ); + + let run_ids = [ + memory_payload["run_id"].as_str().unwrap().to_string(), + session_payload["run_id"].as_str().unwrap().to_string(), + skill_payload["run_id"].as_str().unwrap().to_string(), + ]; + let mut records = Vec::new(); + let mut terminal_count = 0; + for _ in 0..200 { + records = tracedecay::automation::run_ledger::load_run_records(&dashboard_root, 10) + .await + .unwrap(); + terminal_count = records + .iter() + .filter(|record| { + run_ids.contains(&record.run_id) + && record.status.is_terminal() + && record.error.as_deref() == Some("automation_disabled") + }) + .count(); + if terminal_count == run_ids.len() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!( + terminal_count, + run_ids.len(), + "dashboard automation jobs did not reach terminal skipped records: {records:#?}" + ); + assert_eq!(records.len(), 4); + let tasks: Vec<_> = records.iter().map(|record| record.task).collect(); + assert_eq!( + tasks, + [ + tracedecay::automation::backend::AgentTaskKind::SkillWriter, + tracedecay::automation::backend::AgentTaskKind::SessionReflector, + tracedecay::automation::backend::AgentTaskKind::MemoryCurator, + tracedecay::automation::backend::AgentTaskKind::MemoryCurator, + ] + ); + for record in &records { + assert_eq!( + record.trigger, + tracedecay::automation::run_ledger::AutomationTrigger::Dashboard + ); + assert_eq!( + record.status, + tracedecay::automation::run_ledger::AutomationRunStatus::Skipped + ); + assert_eq!(record.error.as_deref(), Some("automation_disabled")); + assert_eq!(record.backend, "codex_app_server"); + assert_eq!(record.host_mode.as_deref(), Some("delegated_host")); + assert_eq!(record.model.as_deref(), Some("queued-model")); + } + + let (status, runs) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/runs?limit=5"), + ); + assert_eq!(status, 200); + assert_eq!(runs["count"], 4); + assert_eq!(runs["limit"], 5); + assert_eq!(runs["records"][0]["trigger"], "dashboard"); + assert_eq!(runs["records"][0]["status"], "skipped"); + assert_eq!(runs["records"][0]["error"], "automation_disabled"); + + let (status, activity) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/activity"), + ); + assert_eq!(status, 200); + let events = activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected activity events array: {activity}")); + let phases: Vec<_> = events + .iter() + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "evidence", + "backend", + "validation", + "apply", + "report", + "finish", + ] { + assert!( + phases.contains(&phase), + "agent-plan should emit {phase} activity; phases={phases:?}, activity={activity}" + ); + } + let memory_skip_phases: Vec<_> = events + .iter() + .filter(|event| { + event["message"].as_str().is_some_and(|message| { + message + .to_ascii_lowercase() + .contains("dashboard memory-curator automation run") + }) + }) + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "evidence", + "backend", + "validation", + "apply", + "report", + "finish", + ] { + assert!( + memory_skip_phases.contains(&phase), + "queued memory-curator skip should emit {phase} activity; phases={memory_skip_phases:?}, activity={activity}" + ); + } + for task_label in ["session-reflector", "skill-writer"] { + let task_skip_phases: Vec<_> = events + .iter() + .filter(|event| { + event["message"].as_str().is_some_and(|message| { + message + .to_ascii_lowercase() + .contains(&format!("dashboard {task_label} automation run")) + }) + }) + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "evidence", + "backend", + "validation", + "apply", + "report", + "finish", + ] { + assert!( + task_skip_phases.contains(&phase), + "queued {task_label} skip should emit {phase} activity; phases={task_skip_phases:?}, activity={activity}" + ); + } + } + assert!( + events.iter().any(|event| event["message"] + .as_str() + .is_some_and(|message| message + .contains("Dashboard memory-curator automation run skipped"))), + "dashboard memory-curator queued skip should emit visible activity: {activity}" + ); + assert!( + events.iter().any(|event| event["phase"] == "report"), + "agent-plan should write a visible curation activity event: {activity}" + ); + assert!( + events.iter().any(|event| { + event["phase"] == "finish" + && event["dry_run"] == true + && event["message"].as_str().is_some_and(|message| { + message.contains("Finished standalone memory-curator agent plan") + }) + }), + "agent-plan should emit a terminal finish activity event: {activity}" + ); + + let (status, runs) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/runs"), + ); + assert_eq!(status, 200); + assert_eq!(runs["count"], 4); + assert!( + runs["records"].as_array().is_some_and(|records| records + .iter() + .any(|record| record["run_id"] == memory_payload["run_id"] + && record["status"] == "skipped")), + "memory-curator run should remain visible in newest-first history: {runs}" + ); + server.stop(); + }); +} + +#[test] +fn dashboard_session_and_skill_runs_emit_activity_when_evidence_is_unavailable() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let (status, config) = patch_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/config"), + &serde_json::json!({ + "enabled": true, + "backend": "codex_app_server", + "host_mode": "standalone", + "session_reflector": { "enabled": true, "schedule": "manual" }, + "skill_writer": { "enabled": true, "schedule": "manual" } + }), + ); + assert_eq!(status, 200, "automation config patch failed: {config}"); + + let (status, session_payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/session-reflection"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 202, "session run should queue: {session_payload}"); + let session_run_id = session_payload["run_id"].as_str().unwrap().to_string(); + let mut records = Vec::new(); + let mut session_terminal = false; + for _ in 0..400 { + records = tracedecay::automation::run_ledger::load_run_records(&dashboard_root, 10) + .await + .unwrap(); + session_terminal = records.iter().any(|record| { + record.run_id == session_run_id && record.status.is_terminal() + }); + if session_terminal { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert!( + session_terminal, + "session-reflector job did not reach a terminal record: {records:#?}" + ); + + let (status, skill_payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/skill-writing"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 202, "skill run should queue: {skill_payload}"); + let skill_run_id = skill_payload["run_id"].as_str().unwrap().to_string(); + + let run_ids = [session_run_id, skill_run_id]; + let mut terminal_count = 0; + for _ in 0..400 { + records = tracedecay::automation::run_ledger::load_run_records(&dashboard_root, 10) + .await + .unwrap(); + terminal_count = records + .iter() + .filter(|record| run_ids.contains(&record.run_id) && record.status.is_terminal()) + .count(); + if terminal_count == run_ids.len() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!( + terminal_count, + run_ids.len(), + "dashboard automation jobs did not reach terminal records: {records:#?}" + ); + for run_id in &run_ids { + let terminal = records + .iter() + .find(|record| record.run_id == *run_id && record.status.is_terminal()) + .unwrap_or_else(|| panic!("missing terminal record for {run_id}: {records:#?}")); + assert_eq!( + terminal.status, + tracedecay::automation::run_ledger::AutomationRunStatus::Skipped + ); + assert!( + terminal.error.as_deref().is_some_and(|reason| reason + == "lcm_not_ingested" + || reason == "no_session_evidence" + || reason == "no_skill_writer_evidence"), + "unexpected evidence skip reason: {terminal:#?}" + ); + } + + let (status, activity) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/activity?limit=50"), + ); + assert_eq!(status, 200); + let events = activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected activity events array: {activity}")); + for task_label in ["session-reflector", "skill-writer"] { + let task_phases: Vec<_> = events + .iter() + .filter(|event| { + event["message"].as_str().is_some_and(|message| { + message + .to_ascii_lowercase() + .contains(&format!("dashboard {task_label} automation run")) + }) + }) + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "evidence", + "backend", + "validation", + "apply", + "report", + "finish", + ] { + assert!( + task_phases.contains(&phase), + "queued {task_label} run should emit {phase} activity; phases={task_phases:?}, activity={activity}" + ); + } + } + + server.stop(); + }); +} + +#[test] +fn final_self_improvement_smoke_covers_manual_curation_skill_approval_and_dashboard_review() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + let fake_codex = FakeCodexAppServer::new_memory_curator(); + let _codex_bin_guard = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &fake_codex.bin); + + let cg = setup_project(&project_root).await; + seed_memory_fixture(&cg).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let (status, config) = patch_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/config"), + &serde_json::json!({ + "enabled": true, + "backend": "codex_app_server", + "host_mode": "standalone", + "model": "dashboard-configured-model", + "memory_curator": { "enabled": true, "schedule": "manual" } + }), + ); + assert_eq!(status, 200, "automation config patch failed: {config}"); + assert_eq!(config["effective"]["enabled"], true); + assert_eq!(config["effective"]["backend"], "codex_app_server"); + + let (status, queued) = post_json_body( + &agent, + &format!("{base_url}/api/automation/run/memory-curator"), + &serde_json::json!({ + "dry_run": true, + "max_clusters": 4, + "min_confidence": 0.5 + }), + ); + assert_eq!(status, 202, "dashboard automation run failed: {queued}"); + assert_eq!(queued["status"], "queued"); + let run_id = queued["run_id"] + .as_str() + .unwrap_or_else(|| panic!("queued response should include run_id: {queued}")) + .to_string(); + + let mut record = None; + for _ in 0..200 { + let records = tracedecay::automation::run_ledger::load_run_records(&dashboard_root, 10) + .await + .unwrap(); + record = records + .into_iter() + .find(|record| record.run_id == run_id && record.status.is_terminal()); + if record.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let record = record.unwrap_or_else(|| { + panic!("dashboard automation run did not reach a terminal ledger record") + }); + assert_eq!( + record.status, + tracedecay::automation::run_ledger::AutomationRunStatus::Succeeded + ); + assert_eq!(record.accepted_count, 1); + assert_eq!(record.rejected_count, 0); + assert_eq!(record.artifacts.len(), 6); + + let artifact_url = format!("{base_url}/api/automation/runs/{run_id}/artifacts"); + let (status, listed) = get_json(&agent, &artifact_url); + assert_eq!(status, 200, "artifact list failed: {listed}"); + assert_eq!(listed["count"], 6); + assert_eq!(listed["artifact_chain"]["complete"], true); + assert_eq!( + listed["artifact_chain"]["present_kinds"], + serde_json::json!([ + "traces", + "feedback", + "generated_evals", + "validation_gate", + "optimizer_diagnosis", + "codex_handoff" + ]) + ); + + let (status, evals) = get_json(&agent, &format!("{artifact_url}/generated_evals")); + assert_eq!(status, 200, "generated eval artifact failed: {evals}"); + assert_eq!(evals["payload"]["format"], "tracedecay_automation_eval:v1"); + assert_eq!(evals["payload"]["runner"]["status"], "passed"); + assert_eq!( + evals["payload"]["runner"]["results"][0]["status"], + "passed" + ); + assert_eq!(evals["payload"]["promotion"]["state"], "validated"); + assert_eq!( + evals["payload"]["eval_definitions"][0]["eval_id"], + "memory_curator:accepted:0" + ); + + let (status, gate) = get_json(&agent, &format!("{artifact_url}/validation_gate")); + assert_eq!(status, 200, "validation gate artifact failed: {gate}"); + assert_eq!(gate["payload"]["task_validation"]["decision"], "passed"); + assert_eq!( + gate["payload"]["improvement_gate"]["decision"], + "ready_for_handoff" + ); + assert_eq!( + gate["payload"]["improvement_gate"]["generated_evals_status"], + "passed" + ); + + let (status, handoff) = get_json(&agent, &format!("{artifact_url}/codex_handoff")); + assert_eq!(status, 200, "Codex handoff artifact failed: {handoff}"); + assert_eq!(handoff["payload"]["status"], "ready_for_review"); + assert_eq!( + handoff["payload"]["machine_summary"]["next_stage"], + "codex_review" + ); + assert_eq!( + handoff["payload"]["artifact_manifest"]["api_list"], + format!("/api/automation/runs/{run_id}/artifacts") + ); + assert!( + handoff["payload"]["artifact_manifest"]["refs"] + .as_array() + .is_some_and(|refs| refs + .iter() + .any(|reference| reference["kind"] == "optimizer_diagnosis")), + "handoff should preserve upstream artifact refs: {handoff}" + ); + + let skills_url = format!("{base_url}/api/automation/skills"); + let (status, created_skill) = post_json_body( + &agent, + &skills_url, + &serde_json::json!({ + "id": "final-smoke-review", + "title": "Final smoke review", + "summary": "Review self-improvement run artifacts and approval state.", + "category": "workflow", + "body_markdown": "Check the run ledger, generated evals, validation gate, and pending skill approval before applying changes.", + "targets": ["codex"], + "provenance": { + "source": "automation_run", + "actor": "dashboard-smoke", + "run_id": run_id + } + }), + ); + assert_eq!(status, 200, "skill draft should be accepted: {created_skill}"); + assert_eq!( + created_skill["skill"]["metadata"]["state"], + "pending_approval" + ); + assert_eq!( + created_skill["skill"]["metadata"]["provenance"]["run_id"], + run_id + ); + + let (status, approved_skill) = post_json( + &agent, + &format!("{base_url}/api/automation/skills/final-smoke-review/approve"), + ); + assert_eq!(status, 200, "skill approval should succeed: {approved_skill}"); + assert_eq!(approved_skill["skill"]["metadata"]["state"], "active"); + + let (status, skill_detail) = get_json( + &agent, + &format!("{base_url}/api/automation/skills/final-smoke-review"), + ); + assert_eq!(status, 200, "approved skill should remain reviewable: {skill_detail}"); + assert_eq!(skill_detail["skill"]["metadata"]["state"], "active"); + assert_eq!( + skill_detail["skill"]["metadata"]["provenance"]["source"], + "automation_run" + ); + + let (status, runs) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/runs?limit=5"), + ); + assert_eq!(status, 200); + assert!( + runs["records"] + .as_array() + .is_some_and( + |records| records.iter().any(|record| record["run_id"] == run_id + && record["status"] == "succeeded" + && record["artifacts"] + .as_array() + .is_some_and(|artifacts| artifacts.len() == 6)) + ), + "successful dashboard automation run should be visible in history: {runs}" + ); + + let (status, activity) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/activity?limit=20"), + ); + assert_eq!(status, 200); + let activity_events = activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected curation activity events: {activity}")); + let activity_phases: Vec<_> = activity_events + .iter() + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "evidence", + "backend", + "validation", + "apply", + "report", + "finish", + ] { + assert!( + activity_phases.contains(&phase), + "successful dashboard automation run should emit {phase} activity; phases={activity_phases:?}, activity={activity}" + ); + } + + server.stop(); + }); +} + +#[test] +fn automation_run_artifact_api_serves_verified_sidecar_payloads() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let run_id = "artifact_api_run"; + let created_at = "2026-06-24T00:00:00Z"; + let artifact = tracedecay::automation::run_ledger::write_run_artifact( + &dashboard_root, + run_id, + tracedecay::automation::run_ledger::AutomationRunArtifactKind::CodexHandoff, + &serde_json::json!({ + "schema_version": 1, + "run_id": run_id, + "status": "ready_for_review", + "next_actions": ["review dashboard artifact payload"] + }), + Some("handoff ready".to_string()), + created_at, + ) + .await + .unwrap(); + tracedecay::automation::run_ledger::append_run_record( + &dashboard_root, + &tracedecay::automation::run_ledger::AutomationRunLedgerRecord { + schema_version: 2, + run_id: run_id.to_string(), + trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, + task: tracedecay::automation::backend::AgentTaskKind::MemoryCurator, + task_key: Some("memory_curator".to_string()), + backend: "codex_app_server".to_string(), + host_mode: Some("standalone".to_string()), + prompt_version: Some("memory_curator:v1".to_string()), + response_schema: None, + strict_json: None, + model: Some("test-model".to_string()), + status: tracedecay::automation::run_ledger::AutomationRunStatus::Succeeded, + evidence_hash: Some("sha256:evidence".to_string()), + input_hash: Some("sha256:input".to_string()), + output_hash: Some("sha256:output".to_string()), + proposed_ops: None, + applied_ops: None, + rejected_ops: None, + validation_report: None, + reviewed_count: 1, + accepted_count: 1, + rejected_count: 0, + skipped_count: 0, + error: None, + error_classification: None, + error_retryable: None, + fallback_status: None, + report_ref: None, + artifacts: vec![artifact], + started_at: created_at.to_string(), + completed_at: created_at.to_string(), + }, + ) + .await + .unwrap(); + + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let artifact_url = format!("{base_url}/api/automation/runs/{run_id}/artifacts"); + let (status, listed) = get_json(&agent, &artifact_url); + assert_eq!(status, 200); + assert_eq!(listed["count"], 1); + assert_eq!(listed["artifacts"][0]["kind"], "codex_handoff"); + assert_eq!(listed["artifacts"][0]["summary"], "handoff ready"); + assert_eq!(listed["artifact_chain"]["complete"], false); + assert_eq!( + listed["artifact_chain"]["expected_kinds"], + serde_json::json!([ + "traces", + "feedback", + "generated_evals", + "validation_gate", + "optimizer_diagnosis", + "codex_handoff" + ]) + ); + assert_eq!( + listed["artifact_chain"]["present_kinds"], + serde_json::json!(["codex_handoff"]) + ); + + let (status, payload) = get_json(&agent, &format!("{artifact_url}/codex_handoff")); + assert_eq!(status, 200); + assert_eq!(payload["artifact"]["kind"], "codex_handoff"); + assert_eq!(payload["payload"]["run_id"], run_id); + assert_eq!(payload["payload"]["status"], "ready_for_review"); + + let (status, missing) = get_json(&agent, &format!("{artifact_url}/validation_gate")); + assert_eq!(status, 404); + assert!(missing["detail"] + .as_str() + .is_some_and(|detail| detail.contains("not found"))); + + let artifact_path = tracedecay::automation::run_ledger::run_artifact_path( + &dashboard_root, + run_id, + tracedecay::automation::run_ledger::AutomationRunArtifactKind::CodexHandoff, + ) + .unwrap(); + std::fs::write(&artifact_path, "{\"tampered\":true}\n").unwrap(); + let (status, tampered) = get_json(&agent, &format!("{artifact_url}/codex_handoff")); + assert_eq!(status, 500); + assert!(tampered["detail"] + .as_str() + .is_some_and(|detail| detail.contains("hash mismatch"))); + + server.stop(); + }); +} diff --git a/tests/dashboard_automation_config_api_test.rs b/tests/dashboard_automation_config_api_test.rs new file mode 100644 index 00000000..5216a03c --- /dev/null +++ b/tests/dashboard_automation_config_api_test.rs @@ -0,0 +1,348 @@ +mod common; +mod dashboard_api_support; + +use dashboard_api_support::*; + +#[test] +fn automation_config_is_dashboard_controllable_and_persistent() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + let missing_codex_bin = tmp_root.join("missing-codex"); + let _codex_bin_guard = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &missing_codex_bin); + + let mut global_config = tracedecay::user_config::UserConfig::default(); + global_config.automation.enabled = true; + global_config.automation.backend = + tracedecay::automation::config::AutomationBackend::CodexAppServer; + global_config.automation.model = Some("global-model".to_string()); + assert!(global_config.save(), "global user config should save"); + + let cg = setup_project(&project_root).await; + let sidecar = cg + .store_layout() + .dashboard_root + .join("automation_config.json"); + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let config_url = format!("{base_url}/api/plugins/holographic/curation/config"); + let (status, config) = get_json(&agent, &config_url); + assert_eq!(status, 200); + assert_eq!(config["global"]["enabled"], true); + assert_eq!(config["global"]["backend"], "codex_app_server"); + assert_eq!(config["global"]["model"], "global-model"); + assert!(config["project"].is_null()); + assert_eq!(config["effective"]["model"], "global-model"); + assert_eq!(config["backend_availability"]["available"], false); + assert_eq!( + config["backend_availability"]["executable"], + missing_codex_bin.display().to_string() + ); + assert_eq!( + config["effective"]["tasks"]["memory_curator"]["enabled"], + false + ); + + let patch = serde_json::json!({ + "model": "project-model", + "timeout_secs": 90, + "scheduler_tick_secs": 15, + "memory_curator": { "enabled": true, "schedule": "manual" } + }); + let (status, saved) = patch_json_body(&agent, &config_url, &patch); + assert_eq!(status, 200); + assert_eq!(saved["project"]["model"], "project-model"); + assert_eq!(saved["effective"]["model"], "project-model"); + assert_eq!(saved["effective"]["timeout_secs"], 90); + assert_eq!(saved["effective"]["scheduler_tick_secs"], 15); + assert_eq!( + saved["effective"]["tasks"]["memory_curator"]["schedule"], + "manual" + ); + assert!(sidecar.exists(), "PATCH must persist a project sidecar"); + + let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(capabilities["features"]["automation"], true); + assert_eq!(capabilities["features"]["llm_curation"], true); + assert_eq!(capabilities["automation"]["mode"], "standalone_backend"); + assert_eq!(capabilities["automation"]["backend"], "codex_app_server"); + assert_eq!(capabilities["automation"]["host_mode"], "standalone"); + assert_eq!( + capabilities["automation"]["availability"]["available"], + false + ); + assert_eq!( + capabilities["automation"]["availability"]["executable"], + missing_codex_bin.display().to_string() + ); + assert!( + capabilities["automation"]["availability"]["reason"] + .as_str() + .is_some_and(|reason| reason.contains("was not found")), + "capabilities should explain unavailable app-server backend: {capabilities}" + ); + + let scheduler_url = format!("{base_url}/api/automation/scheduler/status"); + let (status, scheduler) = get_json(&agent, &scheduler_url); + assert_eq!(status, 200); + assert_eq!(scheduler["status"], "configured"); + assert_eq!(scheduler["paused"], false); + assert_eq!(scheduler["scheduler_tick_secs"], 15); + assert!( + scheduler["tasks"] + .as_array() + .is_some_and(|tasks| tasks.iter().any(|task| { + task["task"] == "memory_curator" + && task["due"] == false + && task["skip_reason"] == "scheduler_schedule_manual" + })), + "manual memory curator should be visible as a skipped scheduler task: {scheduler}" + ); + + let (status, paused) = post_json_body( + &agent, + &format!("{base_url}/api/automation/scheduler/pause"), + &serde_json::json!({}), + ); + assert_eq!(status, 200); + assert_eq!(paused["paused"], true); + assert_eq!(paused["status"], "paused"); + assert_eq!(paused["enabled"], true); + assert!( + paused["tasks"] + .as_array() + .is_some_and(|tasks| tasks.iter().all(|task| { + task["due"] == false && task["skip_reason"] == "scheduler_paused" + })), + "paused scheduler should not mark any task due: {paused}" + ); + let (status, config_after_pause) = get_json(&agent, &config_url); + assert_eq!(status, 200); + assert_eq!( + config_after_pause["effective"]["enabled"], true, + "scheduler pause must not disable automation config" + ); + let (status, resumed) = post_json_body( + &agent, + &format!("{base_url}/api/automation/scheduler/resume"), + &serde_json::json!({}), + ); + assert_eq!(status, 200); + assert_eq!(resumed["paused"], false); + assert_eq!(resumed["status"], "configured"); + + let hermes_patch = serde_json::json!({ + "host_mode": "delegated_host" + }); + let (status, saved) = patch_json_body(&agent, &config_url, &hermes_patch); + assert_eq!(status, 200); + assert_eq!(saved["effective"]["host_mode"], "delegated_host"); + let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(capabilities["features"]["automation"], true); + assert_eq!( + capabilities["features"]["llm_curation"], + false, + "delegated-host mode delegates intelligence and must not advertise TraceDecay-owned LLM curation" + ); + assert_eq!(capabilities["automation"]["mode"], "delegated_host"); + assert_eq!(capabilities["automation"]["backend"], "codex_app_server"); + assert_eq!(capabilities["automation"]["host_mode"], "delegated_host"); + + let legacy_host_mode_patch = serde_json::json!({ + "host_mode": "hermes_hosted" + }); + let (status, legacy_saved) = + patch_json_body(&agent, &config_url, &legacy_host_mode_patch); + assert_eq!(status, 200); + assert_eq!( + legacy_saved["effective"]["host_mode"], + "delegated_host", + "legacy hermes_hosted config must normalize to the provider-agnostic delegated_host mode" + ); + + let external_patch = serde_json::json!({ + "backend": "external_command", + "host_mode": "standalone" + }); + let (status, rejected) = patch_json_body(&agent, &config_url, &external_patch); + assert_eq!(status, 400); + assert_eq!(rejected["validation_errors"][0]["field"], "backend"); + assert!( + rejected["detail"] + .as_str() + .is_some_and(|detail| detail.contains("external_command")), + "external backend rejection should explain the unsupported backend: {rejected}" + ); + let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(capabilities["features"]["automation"], true); + assert_eq!(capabilities["features"]["llm_curation"], false); + assert_eq!(capabilities["automation"]["mode"], "delegated_host"); + assert_eq!(capabilities["automation"]["backend"], "codex_app_server"); + assert_eq!(capabilities["automation"]["host_mode"], "delegated_host"); + + let (status, saved_auto_apply) = patch_json_body( + &agent, + &config_url, + &serde_json::json!({ + "require_dashboard_approval": false, + "auto_apply_memory_ops": true + }), + ); + assert_eq!( + status, 200, + "explicit memory auto-apply should save: {saved_auto_apply}" + ); + assert_eq!( + saved_auto_apply["effective"]["require_dashboard_approval"], + false + ); + assert_eq!(saved_auto_apply["effective"]["auto_apply_memory_ops"], true); + + let (status, rejected) = patch_json_body( + &agent, + &config_url, + &serde_json::json!({ + "modle": "typo-model" + }), + ); + assert_eq!(status, 400); + assert_eq!(rejected["validation_errors"][0]["field"], "modle"); + assert!( + rejected["detail"] + .as_str() + .is_some_and(|detail| detail.contains("unknown field `modle`")), + "unknown top-level field should be rejected clearly: {rejected}" + ); + + let (status, rejected) = patch_json_body( + &agent, + &config_url, + &serde_json::json!({ + "memory_curator": { "schedul": "manual" } + }), + ); + assert_eq!(status, 400); + assert_eq!(rejected["validation_errors"][0]["field"], "schedul"); + assert!( + rejected["detail"] + .as_str() + .is_some_and(|detail| detail.contains("unknown field `schedul`")), + "unknown nested task field should be rejected clearly: {rejected}" + ); + server.stop(); + + let cg = TraceDecay::open(&project_root) + .await + .unwrap_or_else(|err| panic!("failed to reopen fixture project: {err}")); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + let (status, restored) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/config"), + ); + assert_eq!(status, 200); + assert_eq!(restored["project"]["model"], "project-model"); + assert_eq!(restored["effective"]["model"], "project-model"); + assert_eq!( + restored["effective"]["tasks"]["memory_curator"]["enabled"], + true + ); + let (status, reset) = delete_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/config"), + ); + assert_eq!(status, 200); + assert!(reset["project"].is_null()); + assert_eq!(reset["effective"]["model"], "global-model"); + assert_eq!( + reset["effective"]["tasks"]["memory_curator"]["enabled"], + false + ); + assert!(!sidecar.exists(), "DELETE must remove project sidecar"); + let (status, reset_capabilities) = + get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(reset_capabilities["automation"]["mode"], "standalone_backend"); + assert_eq!( + reset_capabilities["automation"]["backend"], + "codex_app_server" + ); + server.stop(); + }); +} + +#[test] +fn automation_config_patch_does_not_rewrite_invalid_project_sidecar() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let sidecar = cg + .store_layout() + .dashboard_root + .join("automation_config.json"); + let invalid_config = br#"{"enabled":true,"modle":"typo"}"#; + std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap(); + std::fs::write(&sidecar, invalid_config).unwrap(); + + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let (status, rejected) = patch_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/config"), + &serde_json::json!({ "timeout_secs": 120 }), + ); + assert_eq!(status, 500); + assert!( + rejected["detail"] + .as_str() + .is_some_and(|detail| detail.contains("failed to parse automation config")), + "invalid persisted config should block PATCH with a parse error: {rejected}" + ); + assert_eq!( + std::fs::read(&sidecar).unwrap(), + invalid_config, + "failed PATCH must not rewrite the invalid sidecar" + ); + + server.stop(); + }); +} diff --git a/tests/dashboard_automation_skills_api_test.rs b/tests/dashboard_automation_skills_api_test.rs new file mode 100644 index 00000000..980d2810 --- /dev/null +++ b/tests/dashboard_automation_skills_api_test.rs @@ -0,0 +1,592 @@ +mod common; +mod dashboard_api_support; + +use dashboard_api_support::*; + +#[test] +fn managed_skills_are_dashboard_controllable_and_persistent() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + let base_url = &fixture.base_url; + + let (status, capabilities) = get_json(&agent, &format!("{base_url}/api/capabilities")); + assert_eq!(status, 200); + assert_eq!(capabilities["features"]["managed_skills"], true); + + let skills_url = format!("{base_url}/api/automation/skills"); + let (status, empty) = get_json(&agent, &skills_url); + assert_eq!(status, 200); + assert_eq!(empty["count"], 0); + assert_eq!(empty["skills"].as_array().map(Vec::len), Some(0)); + + let draft = serde_json::json!({ + "id": "repo-hygiene", + "title": "Repo Hygiene", + "summary": "Keep repository maintenance tasks consistent.", + "category": "workflow", + "body_markdown": "Use this when cleaning generated changes.", + "support_files": [ + { + "path": "references/checklist.md", + "bytes": [99, 104, 101, 99, 107] + } + ], + "provenance": { + "source": "automation_run", + "actor": "dashboard-test", + "run_id": "run-dashboard-1" + } + }); + let (status, created) = post_json_body( + &agent, + &format!("{base_url}/api/automation/skills/draft"), + &draft, + ); + assert_eq!(status, 200); + assert_eq!(created["skill"]["metadata"]["id"], "repo-hygiene"); + assert_eq!(created["skill"]["metadata"]["state"], "pending_approval"); + assert!(created["skill"]["metadata"]["created_at"] + .as_i64() + .is_some_and(|value| value > 0)); + assert!(created["skill"]["metadata"]["updated_at"] + .as_i64() + .is_some_and(|value| value > 0)); + assert_eq!(created["usage_summary"]["view_count"], 0); + assert_eq!( + created["skill"]["metadata"]["provenance"]["run_id"], + "run-dashboard-1" + ); + let profile_root = tracedecay::storage::default_profile_root().unwrap(); + let skill = tracedecay::automation::managed_skills::load_managed_skill( + &profile_root, + "repo-hygiene", + ) + .await + .unwrap(); + tracedecay::automation::skill_usage::record_skill_usage( + &profile_root, + &skill, + tracedecay::automation::skill_usage::SkillUsageAction::Use, + "dashboard-test", + vec!["cursor".to_string(), "codex".to_string()], + Some("cursor".to_string()), + None, + ) + .await + .unwrap(); + let global_db = GlobalDb::open() + .await + .expect("dashboard fixture global db opens"); + global_db + .append_analytics_event(&tracedecay::global_db::AnalyticsEventInsert { + provider: "mcp".to_string(), + project_id: GlobalDb::canonical_project_key(&fixture.project_root), + session_id: Some("dashboard-skill-session".to_string()), + timestamp: tracedecay::tracedecay::current_timestamp(), + event_kind: "mcp_tool_call".to_string(), + hook_name: None, + tool_name: Some("tracedecay_skill_view".to_string()), + tool_category: None, + skill_name: None, + hint_category: None, + hint_id: None, + outcome: Some("success".to_string()), + metadata_json: Some( + serde_json::json!({ + "function": { + "name": "tracedecay_skill_view", + "arguments": { "id": "repo-hygiene" } + } + }) + .to_string(), + ), + }) + .await + .unwrap(); + + let (status, listed) = get_json(&agent, &skills_url); + assert_eq!(status, 200); + assert_eq!(listed["count"], 1); + assert_eq!(listed["skills"][0]["metadata"]["id"], "repo-hygiene"); + assert_eq!(listed["usage_summaries"][0]["view_count"], 1); + assert_eq!(listed["usage_summaries"][0]["use_count"], 1); + assert_eq!( + listed["usage_summaries"][0]["targets"], + serde_json::json!(["codex", "cursor", "mcp"]) + ); + assert_eq!(listed["stale_recommendations"][0]["skill_id"], "repo-hygiene"); + assert_eq!(listed["stale_recommendations"][0]["stale"], false); + assert_eq!(listed["stale_recommendations"][0]["recommendation"], "keep"); + assert_eq!( + listed["improvement_recommendations"][0]["skill_id"], + "repo-hygiene" + ); + assert_eq!( + listed["improvement_recommendations"][0]["recommendation"], + "none" + ); + + let skill_url = format!("{base_url}/api/automation/skills/repo-hygiene"); + let (status, viewed) = get_json(&agent, &skill_url); + assert_eq!(status, 200); + assert_eq!( + viewed["skill"]["body_markdown"], + "Use this when cleaning generated changes." + ); + assert_eq!(viewed["usage_summary"]["use_count"], 1); + assert_eq!(viewed["stale_recommendation"]["recommendation"], "keep"); + assert_eq!(viewed["improvement_recommendation"]["recommendation"], "none"); + + let (status, approved) = post_json(&agent, &format!("{skill_url}/approve")); + assert_eq!(status, 200); + assert_eq!(approved["skill"]["metadata"]["state"], "active"); + assert_eq!( + approved["skill"]["metadata"]["created_at"], + created["skill"]["metadata"]["created_at"] + ); + assert!( + approved["skill"]["metadata"]["updated_at"] + .as_i64() + .unwrap_or_default() + >= created["skill"]["metadata"]["updated_at"] + .as_i64() + .unwrap_or_default() + ); + + let duplicate = serde_json::json!({ + "id": "repo-hygiene", + "title": "Overwrite attempt", + "summary": "This should not replace the approved skill.", + "category": "workflow", + "body_markdown": "Duplicate drafts must not bypass PATCH staging.", + "support_files": [ + { + "path": "templates/overwrite.md", + "bytes": [111, 118, 101, 114, 119, 114, 105, 116, 101] + } + ] + }); + let (status, conflict) = post_json_body(&agent, &skills_url, &duplicate); + assert_eq!(status, 409); + assert!(conflict["detail"] + .as_str() + .is_some_and(|detail| detail.contains("already exists"))); + let persisted_after_duplicate = + tracedecay::automation::managed_skills::load_managed_skill( + &profile_root, + "repo-hygiene", + ) + .await + .unwrap(); + assert_eq!( + persisted_after_duplicate.body_markdown, + "Use this when cleaning generated changes." + ); + assert!(profile_root + .join("agent_managed/skills/repo-hygiene/references/checklist.md") + .is_file()); + assert!(!profile_root + .join("agent_managed/skills/repo-hygiene/templates/overwrite.md") + .exists()); + + let (status, missing_checksum) = patch_json_body( + &agent, + &skill_url, + &serde_json::json!({ + "summary": "Updated after dashboard review.", + "body_markdown": "Use this when cleaning generated changes and record focused checks.", + "pinned": true + }), + ); + assert_eq!(status, 400); + assert!(missing_checksum["detail"] + .as_str() + .is_some_and(|detail| detail.contains("base_checksum"))); + + let (status, patched) = patch_json_body( + &agent, + &skill_url, + &serde_json::json!({ + "base_checksum": approved["skill"]["metadata"]["checksum"], + "summary": "Updated after dashboard review.", + "body_markdown": "Use this when cleaning generated changes and record focused checks.", + "pinned": true + }), + ); + assert_eq!(status, 200); + assert_eq!( + patched["skill"]["metadata"]["summary"], + "Keep repository maintenance tasks consistent." + ); + assert_eq!(patched["skill"]["metadata"]["state"], "active"); + assert_eq!(patched["skill"]["metadata"]["pinned"], false); + assert_eq!( + patched["skill"]["pending_update"]["metadata"]["summary"], + "Updated after dashboard review." + ); + assert_eq!(patched["skill"]["pending_update"]["metadata"]["pinned"], true); + assert_eq!( + patched["skill"]["pending_update"]["base_checksum"], + approved["skill"]["metadata"]["checksum"] + ); + assert_eq!( + patched["skill"]["metadata"]["created_at"], + created["skill"]["metadata"]["created_at"] + ); + + for (action, expected_state) in [ + ("approve", "active"), + ("disable", "disabled"), + ("archive", "archived"), + ("restore", "pending_approval"), + ] { + let (status, updated) = post_json(&agent, &format!("{skill_url}/{action}")); + assert_eq!(status, 200, "{action} should succeed"); + assert_eq!(updated["skill"]["metadata"]["state"], expected_state); + } + + let persisted = tracedecay::automation::managed_skills::load_managed_skill( + &profile_root, + "repo-hygiene", + ) + .await + .unwrap(); + assert_eq!( + persisted.metadata.state, + tracedecay::automation::managed_skills::ManagedSkillState::PendingApproval + ); + }); +} + +#[test] +fn managed_skills_are_dashboard_controllable_with_explicit_approval() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let skills_url = format!("{base_url}/api/automation/skills"); + let (status, initial) = get_json(&agent, &skills_url); + assert_eq!(status, 200); + assert_eq!(initial["count"], 0); + + let draft = serde_json::json!({ + "id": "repo-hygiene", + "title": "Repository hygiene", + "summary": "Keep repository checks focused.", + "category": "maintenance", + "body_markdown": "Run focused tests before broad suites.", + "pinned": true + }); + let (status, created) = post_json_body(&agent, &skills_url, &draft); + assert_eq!(status, 200); + assert_eq!(created["skill"]["metadata"]["state"], "pending_approval"); + assert_eq!(created["skill"]["metadata"]["pinned"], true); + assert_eq!( + created["skill"]["metadata"]["provenance"]["source"], + "user_draft" + ); + + let (status, listed) = get_json(&agent, &skills_url); + assert_eq!(status, 200); + assert_eq!(listed["count"], 1); + assert_eq!(listed["skills"][0]["metadata"]["id"], "repo-hygiene"); + assert_eq!(listed["skills"][0]["metadata"]["state"], "pending_approval"); + + let skill_url = format!("{base_url}/api/automation/skills/repo-hygiene"); + let (status, updated) = patch_json_body( + &agent, + &skill_url, + &serde_json::json!({ + "summary": "Updated with review evidence.", + "body_markdown": "Record the narrow command that covers each change." + }), + ); + assert_eq!(status, 200); + assert_eq!( + updated["skill"]["metadata"]["summary"], + "Updated with review evidence." + ); + assert_eq!(updated["skill"]["metadata"]["state"], "pending_approval"); + + for (action, expected_state) in [ + ("approve", "active"), + ("disable", "disabled"), + ("archive", "archived"), + ("restore", "pending_approval"), + ] { + let (status, payload) = post_json_body( + &agent, + &format!("{base_url}/api/automation/skills/repo-hygiene/{action}"), + &serde_json::json!({}), + ); + assert_eq!(status, 200, "{action} should succeed: {payload}"); + assert_eq!(payload["skill"]["metadata"]["state"], expected_state); + } + + let skill_dir = profile_root + .join("agent_managed") + .join("skills") + .join("repo-hygiene"); + assert!(skill_dir.join("skill.json").is_file()); + assert!(skill_dir.join("SKILL.md").is_file()); + server.stop(); + }); +} + +#[test] +fn managed_skill_dashboard_api_persists_and_updates_lifecycle() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let draft = serde_json::json!({ + "id": "repo-hygiene", + "title": "Repository hygiene", + "summary": "Keep repository maintenance guidance current.", + "category": "maintenance", + "body_markdown": "Use focused checks before changing generated files.", + "support_files": [ + { + "path": "references/checklist.md", + "bytes": [45, 32, 114, 117, 110, 32, 116, 101, 115, 116, 115, 10] + } + ], + "provenance": { + "source": "user_draft", + "actor": "dashboard", + "run_id": null + } + }); + let skills_url = format!("{base_url}/api/automation/skills"); + let (status, created) = post_json_body(&agent, &skills_url, &draft); + assert_eq!(status, 200); + assert_eq!(created["skill"]["metadata"]["state"], "pending_approval"); + assert!(created["skill"]["metadata"]["created_at"] + .as_i64() + .is_some_and(|value| value > 0)); + assert!(created["skill"]["metadata"]["updated_at"] + .as_i64() + .is_some_and(|value| value > 0)); + assert!( + profile_root + .join("agent_managed/skills/repo-hygiene/SKILL.md") + .is_file(), + "drafting a managed skill must persist a SKILL.md package" + ); + + let (status, listed) = get_json(&agent, &skills_url); + assert_eq!(status, 200); + assert_eq!(listed["count"], 1); + assert_eq!(listed["skills"][0]["metadata"]["id"], "repo-hygiene"); + + let (status, viewed) = get_json( + &agent, + &format!("{base_url}/api/automation/skills/repo-hygiene"), + ); + assert_eq!(status, 200); + assert_eq!(viewed["skill"]["metadata"]["id"], "repo-hygiene"); + + for (action, expected_state) in [ + ("approve", "active"), + ("disable", "disabled"), + ("archive", "archived"), + ("restore", "pending_approval"), + ] { + let (status, response) = post_json( + &agent, + &format!("{base_url}/api/automation/skills/repo-hygiene/{action}"), + ); + assert_eq!(status, 200); + assert_eq!(response["skill"]["metadata"]["state"], expected_state); + } + server.stop(); + }); +} + +#[test] +fn managed_skill_dashboard_api_controls_staged_updates() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + let agent = http_agent(); + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let mut server = spawn_dashboard_server(cg, port); + wait_for_dashboard(&agent, &base_url).await; + + let draft = serde_json::json!({ + "id": "repo-hygiene", + "title": "Repository hygiene", + "summary": "Keep repository maintenance guidance current.", + "category": "maintenance", + "body_markdown": "Use focused checks before changing generated files.", + "support_files": [ + { + "path": "references/checklist.md", + "bytes": [45, 32, 114, 117, 110, 32, 116, 101, 115, 116, 115, 10] + } + ], + "provenance": { + "source": "user_draft", + "actor": "dashboard", + "run_id": null + } + }); + let skills_url = format!("{base_url}/api/automation/skills"); + let skill_url = format!("{skills_url}/repo-hygiene"); + let (status, _) = post_json_body(&agent, &skills_url, &draft); + assert_eq!(status, 200); + let (status, _) = post_json(&agent, &format!("{skill_url}/approve")); + assert_eq!(status, 200); + + let active = tracedecay::automation::managed_skills::load_managed_skill( + &profile_root, + "repo-hygiene", + ) + .await + .unwrap(); + let base_checksum = active.metadata.checksum.clone(); + tracedecay::automation::managed_skills::stage_managed_skill_update( + &profile_root, + "repo-hygiene", + &base_checksum, + tracedecay::automation::managed_skills::ManagedSkillUpdate { + summary: Some("Stage dashboard-visible generated guidance.".to_string()), + body_markdown: Some( + "Review the run ledger before applying generated edits.".to_string(), + ), + support_files: Some(vec![ + tracedecay::automation::managed_skills::ManagedSupportFile::new( + "templates/review.md", + b"review body".to_vec(), + ) + .unwrap(), + ]), + ..Default::default() + }, + ) + .await + .unwrap(); + + let (status, staged_view) = get_json(&agent, &skill_url); + assert_eq!(status, 200); + assert_eq!(staged_view["skill"]["metadata"]["state"], "active"); + assert_eq!( + staged_view["skill"]["metadata"]["summary"], + "Keep repository maintenance guidance current." + ); + assert_eq!( + staged_view["skill"]["pending_update"]["metadata"]["summary"], + "Stage dashboard-visible generated guidance." + ); + let skill_dir = profile_root.join("agent_managed/skills/repo-hygiene"); + assert!(skill_dir.join("references/checklist.md").is_file()); + assert!(!skill_dir.join("templates/review.md").exists()); + + let (status, discarded) = post_json(&agent, &format!("{skill_url}/discard-update")); + assert_eq!(status, 200); + assert!(discarded["skill"]["pending_update"].is_null()); + assert_eq!( + discarded["skill"]["metadata"]["summary"], + "Keep repository maintenance guidance current." + ); + + let active = tracedecay::automation::managed_skills::load_managed_skill( + &profile_root, + "repo-hygiene", + ) + .await + .unwrap(); + tracedecay::automation::managed_skills::stage_managed_skill_update( + &profile_root, + "repo-hygiene", + &active.metadata.checksum, + tracedecay::automation::managed_skills::ManagedSkillUpdate { + summary: Some("Approve dashboard-visible generated guidance.".to_string()), + body_markdown: Some( + "Review the run ledger before applying generated edits.".to_string(), + ), + support_files: Some(vec![ + tracedecay::automation::managed_skills::ManagedSupportFile::new( + "templates/review.md", + b"review body".to_vec(), + ) + .unwrap(), + ]), + ..Default::default() + }, + ) + .await + .unwrap(); + + let (status, approved) = post_json(&agent, &format!("{skill_url}/approve")); + assert_eq!(status, 200); + assert_eq!(approved["skill"]["metadata"]["state"], "active"); + assert_eq!( + approved["skill"]["metadata"]["summary"], + "Approve dashboard-visible generated guidance." + ); + assert!(approved["skill"]["pending_update"].is_null()); + assert!(!skill_dir.join("references/checklist.md").exists()); + assert!(skill_dir.join("templates/review.md").is_file()); + + server.stop(); + }); +} diff --git a/tests/dashboard_memory_curation_api_test.rs b/tests/dashboard_memory_curation_api_test.rs new file mode 100644 index 00000000..e097b1c4 --- /dev/null +++ b/tests/dashboard_memory_curation_api_test.rs @@ -0,0 +1,982 @@ +mod common; +mod dashboard_api_support; + +use dashboard_api_support::*; + +#[test] +fn curate_hygiene_scans_unvectored_facts() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let conn = project_db_conn(&fixture).await; + conn.execute( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, created_at, updated_at, source, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + libsql::params![ + 901_i64, + "api_key=Zx9mQ4tR7wLp2NvK8sBd1FgH", + "project", + "[]", + 0.5_f64, + 1_700_000_200_i64, + 1_700_000_200_i64, + "test", + "{}" + ], + ) + .await + .unwrap_or_else(|err| panic!("failed to insert unvectored hygiene fact: {err}")); + + let agent = http_agent(); + let (status, curate) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": true }), + ); + + assert_eq!(status, 200); + let secret_like = curate["hygiene_candidates"]["secret_like"] + .as_array() + .unwrap_or_else(|| panic!("expected hygiene_candidates.secret_like array")); + let secret_candidate = secret_like + .iter() + .find(|action| action["fact_id"].as_i64() == Some(901)) + .unwrap_or_else(|| { + panic!("hygiene scan must include secret-like facts without HRR vectors: {curate}") + }); + assert_eq!(secret_candidate["status"], "candidate"); + assert_eq!(secret_candidate["review_required"], true); + assert_eq!(secret_candidate["recommended_op"], "delete"); + + let (status, applied) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": false }), + ); + assert_eq!(status, 200); + assert!(applied["hygiene_candidates"]["secret_like"] + .as_array() + .is_some_and(|candidates| candidates + .iter() + .any(|candidate| candidate["fact_id"].as_i64() == Some(901)))); + assert_eq!( + count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", + 901, + ) + .await, + 1, + "deterministic curate apply must not delete hygiene candidates without explicit review" + ); + }); +} + +#[test] +fn curation_delete_lifecycle() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + + // --- Dry-run curation: expect a delete plan for the likely-duplicate pair --- + let (status, dry) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 200); + assert_eq!(dry["ran"], true); + assert_eq!(dry["dry_run"], true); + assert_eq!(dry["llm_calls"], 0); + let actions = dry["actions"] + .as_array() + .unwrap_or_else(|| panic!("expected actions array")); + assert!( + !actions.is_empty(), + "fixture with likely-duplicate vectors should produce at least one delete action" + ); + assert_eq!(actions[0]["op"], "delete"); + assert!( + actions[0]["fact_id"].is_number(), + "action must have fact_id" + ); + assert!( + actions[0]["duplicate_of"].is_number(), + "action must reference the surviving duplicate" + ); + let planned_delete_id = actions[0]["fact_id"] + .as_i64() + .unwrap_or_else(|| panic!("fact_id must be an integer")); + assert_eq!(dry["counts"]["delete"], actions.len() as i64); + assert_eq!(dry["coverage"]["active_total"], 3); + + // Preview should now be available and fresh. + let (status, preview) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/preview", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert!( + !preview["report"].is_null(), + "preview should be non-null after a dry-run" + ); + assert_eq!(preview["stale"], false); + + // Curation status should reflect the preview timestamp. + let (status, curation_status) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/status", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(curation_status["config"]["enabled"], true); + assert!( + !curation_status["state"]["last_preview_at"].is_null(), + "last_preview_at should be set after dry-run" + ); + + let (status, dry_activity) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/activity?limit=75", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(dry_activity["error"], ""); + assert_eq!(dry_activity["limit"], 75); + let dry_events = dry_activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected dry-run activity events array")); + assert_eq!( + dry_activity["count"].as_u64(), + Some(dry_events.len() as u64) + ); + assert!( + !dry_events.is_empty(), + "dry-run curation should emit activity events" + ); + let dry_phases: Vec<_> = dry_events + .iter() + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in [ + "queued", + "start", + "evidence", + "backend", + "validation", + "report", + "finish", + ] { + assert!( + dry_phases.contains(&phase), + "dry-run curation should emit {phase} activity; phases={dry_phases:?}" + ); + } + assert!( + dry_events.iter().any(|event| { + event["phase"] == "finish" + && event["dry_run"] == true + && event["message"] + .as_str() + .is_some_and(|message| !message.is_empty()) + && event["ts"].as_str().is_some_and(|ts| !ts.is_empty()) + }), + "dry-run curation should emit a finish activity event" + ); + + // --- Apply curation: hard-delete the duplicate --- + let (status, applied) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": false }), + ); + assert_eq!(status, 200); + assert_eq!(applied["ran"], true); + assert_eq!(applied["dry_run"], false); + assert!( + applied["applied_counts"]["delete"].as_i64().unwrap_or(0) > 0, + "apply should report at least one deleted fact" + ); + + let (status, apply_activity) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/activity?limit=75", + fixture.base_url + ), + ); + assert_eq!(status, 200); + let apply_events = apply_activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected apply activity events array")); + assert_eq!( + apply_activity["count"].as_u64(), + Some(apply_events.len() as u64) + ); + assert!( + apply_events.len() > dry_events.len(), + "apply should append activity events after dry-run events" + ); + let apply_phases: Vec<_> = apply_events + .iter() + .filter_map(|event| event["phase"].as_str()) + .collect(); + for phase in ["queued", "backend", "validation", "report", "apply"] { + assert!( + apply_phases.contains(&phase), + "apply curation should emit {phase} activity; phases={apply_phases:?}" + ); + } + assert!( + apply_events + .iter() + .rev() + .any(|event| event["phase"] == "finish" && event["dry_run"] == false), + "apply curation should emit a finish activity event" + ); + + let (status, status_after_apply) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/status", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(status_after_apply["state"]["run_count"], 1); + assert!( + status_after_apply["state"]["last_run_at"] + .as_str() + .is_some_and(|ts| !ts.is_empty()), + "last_run_at should be set after apply" + ); + assert!( + status_after_apply["state"]["last_run_summary"] + .as_str() + .is_some_and(|summary| summary.contains("deleted")), + "last_run_summary should describe the apply result" + ); + assert!( + status_after_apply["snapshots"] + .as_array() + .is_some_and(|snapshots| !snapshots.is_empty()), + "status snapshots should include recent apply history" + ); + + // --- Overview should show fewer facts and not contain the deleted one --- + let (status, overview) = get_json( + &agent, + &format!("{}/api/plugins/holographic/", fixture.base_url), + ); + assert_eq!(status, 200); + let fact_count = overview["holographic"]["overview"]["facts"] + .as_i64() + .unwrap_or(3); + assert!( + fact_count < 3, + "overview fact count should decrease after deletion" + ); + let facts = overview["holographic"]["facts"] + .as_array() + .unwrap_or_else(|| panic!("expected facts array")); + assert!( + facts + .iter() + .all(|fact| fact["fact_id"].as_i64() != Some(planned_delete_id)), + "deleted fact must not appear in the overview fact list" + ); + + // --- The row and its entity links must be gone from the store that + // tracedecay_fact_store recall reads (hard delete, not soft). --- + let remaining = count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", + planned_delete_id, + ) + .await; + assert_eq!( + remaining, 0, + "deleted fact row must be gone from memory_facts" + ); + let remaining_links = count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_fact_entities WHERE fact_id = ?1", + planned_delete_id, + ) + .await; + assert_eq!( + remaining_links, 0, + "entity links of a deleted fact must be cleaned up" + ); + + // Apply invalidates the saved preview. + let (status, preview_after) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/preview", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert!(preview_after["report"].is_null()); + }); +} + +#[test] +fn curation_preview_marks_same_count_updates_stale() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + + let (status, dry) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 200); + assert_eq!(dry["dry_run"], true); + + let conn = project_db_conn(&fixture).await; + conn.execute( + "UPDATE memory_facts + SET content = content || ' after preview', updated_at = updated_at + 1 + WHERE fact_id = 101", + (), + ) + .await + .unwrap(); + + let (status, preview) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/preview", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!( + preview["stale"], true, + "same-count edits must stale previews" + ); + assert!( + preview["stale_reason"] + .as_str() + .unwrap_or_default() + .contains("changed"), + "stale response should explain the memory store changed: {preview}" + ); + }); +} + +#[test] +fn memory_oplog_endpoint_lists_recent_operations() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + + // Fresh fixture: no operations recorded yet. + let (status, empty) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/oplog?limit=10", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(empty["count"], 0); + assert_eq!(empty["error"], ""); + + // An explicit-ops delete writes a per-fact "remove" row plus a + // "curate_apply" summary row. + let (status, applied) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate/apply", fixture.base_url), + &serde_json::json!({ + "ops": [{ "op": "delete", "fact_id": 103, "reason": "oplog fixture" }] + }), + ); + assert_eq!(status, 200); + assert_eq!(applied["counts"]["deleted"], 1); + + let (status, oplog) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/oplog?limit=10", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(oplog["error"], ""); + let events = oplog["events"] + .as_array() + .unwrap_or_else(|| panic!("expected oplog events array")); + assert_eq!(events.len(), 2, "expected remove + curate_apply rows"); + + // Newest first: the curate_apply summary follows the per-fact remove. + assert_eq!(events[0]["op"], "curate_apply"); + assert_eq!(events[0]["detail"]["deleted"], 1); + assert_eq!(events[1]["op"], "remove"); + assert_eq!(events[1]["fact_id"], 103); + let remove_detail = events[1]["detail"].to_string(); + assert!( + remove_detail.contains("content_hash"), + "remove rows must carry a content hash: {remove_detail}" + ); + assert!( + !remove_detail.contains("empty states"), + "remove rows must not leak deleted fact content: {remove_detail}" + ); + assert!( + events.iter().all(|event| event["ts"].is_number()), + "every oplog row carries a timestamp" + ); + }); +} + +#[test] +fn curate_apply_ops_contract() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + let apply_url = format!("{}/api/plugins/holographic/curate/apply", fixture.base_url); + + // Merge: fact 102 into 101 with rewritten content, plus an explicit + // delete of 103, plus an invalid delete — partial failure stays per-op. + let (status, response) = post_json_body( + &agent, + &apply_url, + &serde_json::json!({ + "ops": [ + { + "op": "merge", + "winner_id": 101, + "loser_ids": [102], + "merged_content": "Cache invalidation policy must be explicit (merged)" + }, + { "op": "delete", "fact_id": 103, "reason": "manual cleanup" }, + { "op": "delete", "fact_id": 99999 }, + { "op": "frobnicate" } + ] + }), + ); + assert_eq!(status, 200, "partial failures must not fail the request"); + let results = response["results"] + .as_array() + .unwrap_or_else(|| panic!("expected results array")); + assert_eq!(results.len(), 4); + + assert_eq!(results[0]["op"], "merge"); + assert_eq!( + results[0]["status"], "merged", + "merge op failed: {response}" + ); + assert_eq!(results[0]["content_updated"], true); + assert_eq!(results[0]["deleted_loser_ids"], serde_json::json!([102])); + + assert_eq!(results[1]["op"], "delete"); + assert_eq!(results[1]["status"], "deleted"); + assert_eq!(results[1]["fact_id"], 103); + + assert_eq!(results[2]["status"], "error"); + assert!( + results[2]["error"] + .as_str() + .unwrap_or_default() + .contains("not found"), + "invalid fact_id must produce a per-op not-found error" + ); + + assert_eq!(results[3]["status"], "error"); + assert!( + results[3]["error"] + .as_str() + .unwrap_or_default() + .contains("unsupported op"), + "unknown op kinds must produce a per-op error" + ); + + assert_eq!(response["counts"]["deleted"], 1); + assert_eq!(response["counts"]["merged"], 1); + assert_eq!(response["counts"]["errors"], 2); + + let (status, apply_activity) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/activity?limit=25", + fixture.base_url + ), + ); + assert_eq!(status, 200); + let apply_events = apply_activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected generic apply activity events array")); + assert!( + apply_events.iter().any(|event| { + event["phase"] == "finish" + && event["dry_run"] == false + && event["message"].as_str().is_some_and(|message| { + message.contains("Explicit apply completed") + && message.contains("1 delete") + && message.contains("1 merge") + && message.contains("2 op(s) errored") + }) + && event["ts"].as_str().is_some_and(|ts| !ts.is_empty()) + }), + "/curate/apply should emit a finish activity event: {apply_activity}" + ); + for phase in ["queued", "apply", "validation", "report"] { + assert!( + apply_events + .iter() + .any(|event| event["phase"].as_str() == Some(phase)), + "/curate/apply should emit {phase} activity: {apply_activity}" + ); + } + assert!( + apply_events.iter().any(|event| { + event["phase"] == "rejection" + && event["level"] == "warning" + && event["message"] + .as_str() + .is_some_and(|message| message.contains("2 explicit curation op(s)")) + }), + "/curate/apply should emit a rejection activity event for invalid ops: {apply_activity}" + ); + + let (status, rejected_only) = post_json_body( + &agent, + &apply_url, + &serde_json::json!({ + "ops": [ + { "op": "delete", "fact_id": 99999 }, + { "op": "frobnicate" } + ] + }), + ); + assert_eq!(status, 200); + assert_eq!(rejected_only["counts"]["deleted"], 0); + assert_eq!(rejected_only["counts"]["merged"], 0); + assert_eq!(rejected_only["counts"]["errors"], 2); + let (status, rejected_activity) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/activity?limit=25", + fixture.base_url + ), + ); + assert_eq!(status, 200); + let rejected_events = rejected_activity["events"] + .as_array() + .unwrap_or_else(|| panic!("expected rejected activity events array: {rejected_activity}")); + for phase in ["queued", "apply", "validation", "rejection", "report", "failure"] { + assert!( + rejected_events + .iter() + .any(|event| event["phase"].as_str() == Some(phase)), + "all-rejected apply should emit {phase} activity: {rejected_activity}" + ); + } + assert!( + rejected_events.iter().any(|event| { + event["phase"] == "finish" + && event["dry_run"] == false + && event["message"].as_str().is_some_and(|message| { + message.contains("0 delete") + && message.contains("0 merge") + && message.contains("2 op(s) errored") + }) + }), + "all-rejected apply requests should still emit a terminal finish event: {rejected_activity}" + ); + + let (status, apply_status) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/status", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert_eq!(apply_status["state"]["run_count"], 2); + assert!( + apply_status["state"]["last_run_at"] + .as_str() + .is_some_and(|ts| !ts.is_empty()), + "last_run_at should be set after /curate/apply" + ); + let summary = apply_status["state"]["last_run_summary"] + .as_str() + .unwrap_or_default(); + assert!( + summary.contains("Explicit apply completed") + && summary.contains("0 delete") + && summary.contains("0 merge") + && summary.contains("2 op(s) errored"), + "/curate/apply should drive the status summary: {apply_status}" + ); + assert!( + apply_status["snapshots"] + .as_array() + .is_some_and(|snapshots| { + snapshots.iter().any(|snapshot| { + snapshot["summary"] + .as_str() + .is_some_and(|summary| summary.contains("Explicit apply completed")) + }) + }), + "/curate/apply should appear in status snapshots: {apply_status}" + ); + + // Hard deletes: rows + entity links gone from the project DB. + for gone_id in [102_i64, 103] { + let remaining = count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", + gone_id, + ) + .await; + assert_eq!(remaining, 0, "fact {gone_id} must be hard-deleted"); + let links = count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_fact_entities WHERE fact_id = ?1", + gone_id, + ) + .await; + assert_eq!(links, 0, "entity links of fact {gone_id} must be gone"); + } + + // Winner survived with merged content. + let (status, overview) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/?q=merged&limit=10", + fixture.base_url + ), + ); + assert_eq!(status, 200); + let facts = overview["holographic"]["facts"] + .as_array() + .unwrap_or_else(|| panic!("expected facts array")); + assert!( + facts.iter().any(|fact| { + fact["fact_id"].as_i64() == Some(101) + && fact["content"] + .as_str() + .unwrap_or_default() + .contains("(merged)") + }), + "winner fact must survive with the merged content" + ); + + // Merge with a missing winner: per-op error, losers untouched. + let (status, response) = post_json_body( + &agent, + &apply_url, + &serde_json::json!({ + "ops": [{ "op": "merge", "winner_id": 4242, "loser_ids": [101] }] + }), + ); + assert_eq!(status, 200); + assert_eq!(response["results"][0]["status"], "error"); + assert_eq!(response["counts"]["errors"], 1); + let survivor = count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", + 101, + ) + .await; + assert_eq!( + survivor, 1, + "loser must be untouched when the winner is missing" + ); + + // Malformed body (no ops field) is the only whole-request failure mode. + let (status, _) = post_json(&agent, &apply_url); + assert!( + status == 400 || status == 415 || status == 422, + "missing/malformed body should be rejected, got {status}" + ); + }); +} + +#[test] +fn curate_apply_merge_with_missing_loser_is_atomic() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + let apply_url = format!("{}/api/plugins/holographic/curate/apply", fixture.base_url); + + let (status, dry) = post_json_body( + &agent, + &format!("{}/api/plugins/holographic/curate", fixture.base_url), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 200); + assert_eq!(dry["dry_run"], true); + + let original_winner = string_in_project_db( + &fixture, + "SELECT content FROM memory_facts WHERE fact_id = ?1", + 101, + ) + .await + .expect("winner content"); + + let (status, response) = post_json_body( + &agent, + &apply_url, + &serde_json::json!({ + "ops": [{ + "op": "merge", + "winner_id": 101, + "loser_ids": [102, 99999], + "merged_content": "Cache invalidation policy should not partially merge" + }] + }), + ); + assert_eq!(status, 200, "per-op failures stay in-band"); + assert_eq!(response["counts"]["deleted"], 0); + assert_eq!(response["counts"]["merged"], 0); + assert_eq!(response["counts"]["errors"], 1); + assert_eq!(response["results"][0]["op"], "merge"); + assert_eq!(response["results"][0]["status"], "error"); + assert!( + response["results"][0]["error"] + .as_str() + .unwrap_or_default() + .contains("loser fact 99999 not found"), + "missing loser should be reported before mutation: {response}" + ); + + let winner_after = string_in_project_db( + &fixture, + "SELECT content FROM memory_facts WHERE fact_id = ?1", + 101, + ) + .await + .expect("winner content after failed merge"); + assert_eq!( + winner_after, original_winner, + "failed merge must not update winner content" + ); + assert_eq!( + count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_facts WHERE fact_id = ?1", + 102, + ) + .await, + 1, + "failed merge must not delete valid losers" + ); + assert_eq!( + count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_oplog WHERE fact_id = ?1", + 101, + ) + .await, + 0, + "failed merge must not write a winner update oplog" + ); + assert_eq!( + count_in_project_db( + &fixture, + "SELECT COUNT(*) FROM memory_oplog WHERE fact_id = ?1", + 102, + ) + .await, + 0, + "failed merge must not write loser delete oplogs" + ); + + let (status, preview) = get_json( + &agent, + &format!( + "{}/api/plugins/holographic/curation/preview", + fixture.base_url + ), + ); + assert_eq!(status, 200); + assert!( + !preview["report"].is_null(), + "failed merge must not clear saved preview" + ); + assert_eq!( + preview["stale"], false, + "unchanged store should leave preview fresh" + ); + }); +} + +/// The dry-run curation preview must survive a dashboard restart: it is +/// mirrored to the resolved dashboard sidecar path and re-hydrated by +/// `build_state`, and applying curation clears both the memory copy and the +/// sidecar. +#[test] +fn curation_preview_persists_across_dashboard_restarts() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let tmp = tempdir_or_panic(); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); + let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); + + let cg = setup_project(&project_root).await; + seed_memory_fixture(&cg).await; + let agent = http_agent(); + let sidecar = cg + .store_layout() + .dashboard_root + .join("curation_preview.json"); + + async fn start_server(cg: TraceDecay) -> (String, DashboardServer) { + let port = pick_free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let server = spawn_dashboard_server(cg, port); + (base_url, server) + } + + fn stop_server(mut server: DashboardServer) { + server.stop(); + } + + async fn reopen_project(project_root: &Path) -> TraceDecay { + match TraceDecay::open(project_root).await { + Ok(cg) => cg, + Err(err) => panic!("failed to reopen fixture project: {err}"), + } + } + + // Server 1: a dry-run saves the preview and writes the sidecar. + let (base_url, server) = start_server(cg).await; + wait_for_dashboard(&agent, &base_url).await; + let (status, curate) = post_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curate"), + &serde_json::json!({ "dry_run": true }), + ); + assert_eq!(status, 200); + assert_eq!(curate["dry_run"], true); + let (status, preview) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/preview"), + ); + assert_eq!(status, 200); + assert!(!preview["report"].is_null(), "dry-run must save a preview"); + let saved_at = preview["saved_at"].clone(); + assert!(saved_at.is_string(), "preview must carry saved_at"); + stop_server(server); + assert!( + sidecar.exists(), + "dry-run must persist the preview sidecar at {}", + sidecar.display() + ); + + // Server 2 (fresh state): the preview is re-hydrated from disk. + let cg = reopen_project(&project_root).await; + let (base_url, server) = start_server(cg).await; + wait_for_dashboard(&agent, &base_url).await; + let (status, preview) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/preview"), + ); + assert_eq!(status, 200); + assert!( + !preview["report"].is_null(), + "preview must survive a server restart" + ); + assert_eq!( + preview["saved_at"], saved_at, + "re-hydrated preview must keep its original timestamp" + ); + assert_eq!( + preview["stale"], false, + "fact count is unchanged, so the restored preview is not stale" + ); + let (status, status_payload) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/status"), + ); + assert_eq!(status, 200); + assert_eq!( + status_payload["state"]["last_preview_at"], saved_at, + "curation status must reflect the restored preview" + ); + + // Applying curation clears both the in-memory copy and the sidecar. + let (status, applied) = post_json_body( + &agent, + &format!("{base_url}/api/plugins/holographic/curate"), + &serde_json::json!({ "dry_run": false }), + ); + assert_eq!(status, 200); + assert_eq!(applied["dry_run"], false); + let (status, preview) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/preview"), + ); + assert_eq!(status, 200); + assert!(preview["report"].is_null(), "apply must clear the preview"); + assert!( + !sidecar.exists(), + "apply must remove the persisted preview sidecar" + ); + stop_server(server); + + // Server 3: nothing is restored after the apply cleared the sidecar. + let cg = reopen_project(&project_root).await; + let (base_url, server) = start_server(cg).await; + wait_for_dashboard(&agent, &base_url).await; + let (status, preview) = get_json( + &agent, + &format!("{base_url}/api/plugins/holographic/curation/preview"), + ); + assert_eq!(status, 200); + assert!( + preview["report"].is_null(), + "no preview may reappear after curation was applied" + ); + stop_server(server); + }); +}