use crate::Role;
use crate::Workspace;
use crate::WorkspaceStatus;
use crate::agent::run_default_agent;
use crate::config_db::ConfigStore;
use crate::role::DIAGNOSTICS_ROLE;
use crate::session::discovery_agent_id;
use crate::turso::{self, Value};
use crate::util::UnwrapPoison;
use anyhow::{Context, Result};
use chrono::{DateTime, Timelike, Utc};
use futures_util::future::join_all;
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use strum::IntoEnumIterator;
use tracing::warn;
crate::define_store! {
pub static WORKSPACES: WorkspaceStore,
db_name = "workspaces",
schema = SCHEMA,
expect = "workspace::WORKSPACES not initialized — call workspace::init_global() in main.rs",
}
pub async fn get_by_name(name: &str) -> Result<Option<Workspace>> {
store().get_by_name(name).await
}
const SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS workspaces (
name TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
maintenance INTEGER NOT NULL DEFAULT 0,
paused INTEGER NOT NULL DEFAULT 1,
maintainer_debounce_mins INTEGER NOT NULL DEFAULT 5,
maintainer_last_run_at TEXT,
diagnostics TEXT,
diagnostics_generation INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
last_analyzed_commit TEXT,
discovery_generation INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS workspace_contexts (
workspace_name TEXT NOT NULL REFERENCES workspaces(name) ON DELETE CASCADE,
role TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(workspace_name, role)
);
CREATE UNIQUE INDEX IF NOT EXISTS workspace_contexts_null_role ON workspace_contexts(workspace_name) WHERE role IS NULL;
CREATE TABLE IF NOT EXISTS editor_tabs (
workspace_name TEXT NOT NULL REFERENCES workspaces(name) ON DELETE CASCADE,
file_path TEXT NOT NULL,
tab_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 0,
is_dirty INTEGER NOT NULL DEFAULT 0,
dirty_content TEXT,
PRIMARY KEY (workspace_name, file_path)
);";
crate::columns! {
WORKSPACE_COLUMNS [WS] {
NAME => "name",
PATH => "path",
STATUS => "status",
MAINTENANCE_ENABLED => "maintenance",
PAUSED => "paused",
MAINTAINER_DEBOUNCE_MINS => "maintainer_debounce_mins",
MAINTAINER_LAST_RUN_AT => "maintainer_last_run_at",
DIAGNOSTICS => "diagnostics",
NOTES => "notes",
LAST_ANALYZED_COMMIT => "last_analyzed_commit",
}
}
crate::columns! {
EDITOR_TAB_COLUMNS [ET] {
FILE_PATH => "file_path",
TAB_ORDER => "tab_order",
IS_ACTIVE => "is_active",
IS_DIRTY => "is_dirty",
DIRTY_CONTENT => "dirty_content",
}
}
crate::columns! {
WS_STATE_COLUMNS [WSST] {
NAME => "name",
PAUSED => "paused",
MAINTENANCE_ENABLED => "maintenance",
}
}
#[derive(Clone, Copy)]
struct GenerationColumn {
name: &'static str,
log_label: &'static str,
}
impl GenerationColumn {
const DISCOVERY: Self = Self {
name: "discovery_generation",
log_label: "Discovery",
};
const DIAGNOSTICS: Self = Self {
name: "diagnostics_generation",
log_label: "Diagnostics",
};
}
async fn check_generation(
storage: &WorkspaceStore,
workspace_name: &str,
generation: i64,
column: GenerationColumn,
label: &str,
) -> bool {
let current_gen = storage
.get_generation(workspace_name, column)
.await
.unwrap_or(generation + 1);
if current_gen != generation {
tracing::warn!(
workspace_name,
captured_gen = generation,
current_gen,
label = %label,
"{} generation mismatch — skipping stale write",
column.log_label
);
return false;
}
true
}
async fn run_workspace_discovery(
ws: &Workspace,
role: Role,
discovery_generation: i64,
) -> Result<(), DiscoveryRunError> {
run_discovery_task(
ws,
role.discovery_prompt(),
discovery_generation,
Some(role.as_str()),
)
.await
}
async fn run_general_discovery(
ws: &Workspace,
discovery_generation: i64,
) -> Result<(), DiscoveryRunError> {
run_discovery_task(
ws,
crate::prompt::load_prompt("discovery/general.md"),
discovery_generation,
None,
)
.await
}
async fn run_discovery_task(
ws: &Workspace,
prompt: String,
discovery_generation: i64,
role: Option<&str>,
) -> Result<(), DiscoveryRunError> {
let label = role.unwrap_or("general");
let storage = WORKSPACES
.get()
.context("WORKSPACES not initialized")?
.clone();
tracing::info!(workspace_name = ws.name, role = %label, "Starting workspace discovery");
let agent_id = discovery_agent_id(&ws.name, label);
let (agent, response) =
run_default_agent(&agent_id, Role::Discovery, ws, &prompt, None, None, None).await;
let Some(response) = response else {
return Err(discovery_no_response_error(&agent, "Discovery"));
};
let content = response.trim().to_string();
if content.is_empty() {
return Err(DiscoveryRunError::fatal(anyhow::anyhow!(
"Empty response for '{label}'"
)));
}
if !check_generation(
&storage,
&ws.name,
discovery_generation,
GenerationColumn::DISCOVERY,
"context",
)
.await
{
return Ok(());
}
let result = match role {
Some(r) => storage.set_context(&ws.name, r, &content).await,
None => storage.set_general_context(&ws.name, &content).await,
};
if let Err(e) = result {
tracing::error!(workspace_name = ws.name, role = %label, error = %e, "Failed to store context");
return Err(DiscoveryRunError::fatal(e));
}
tracing::info!(workspace_name = ws.name, role = %label, "Workspace discovery for {label} completed");
Ok(())
}
async fn run_workspace_diagnostics(
ws: &Workspace,
diagnostics_generation: i64,
) -> Result<(), DiscoveryRunError> {
let storage = WORKSPACES
.get()
.context("WORKSPACES not initialized")?
.clone();
tracing::info!(workspace_name = ws.name, "Starting diagnostics discovery");
let agent_id = discovery_agent_id(&ws.name, DIAGNOSTICS_ROLE);
let prompt = crate::prompt::load_prompt("discovery/diagnostics.md");
let (agent, response) =
run_default_agent(&agent_id, Role::Discovery, ws, &prompt, None, None, None).await;
let Some(_response) = response else {
return Err(discovery_no_response_error(&agent, "Diagnostics discovery"));
};
let extraction_prompt = crate::prompt::load_prompt("extraction/diagnostics.md");
let cmds: crate::DiagnosticsCommands = agent
.extract_verdict(&extraction_prompt, None, None)
.await
.map_err(|e| DiscoveryRunError::classified(&agent, anyhow::Error::from(e)))?;
if !check_generation(
&storage,
&ws.name,
diagnostics_generation,
GenerationColumn::DIAGNOSTICS,
DIAGNOSTICS_ROLE,
)
.await
{
return Ok(());
}
storage.set_diagnostics(&ws.name, &cmds).await?;
tracing::info!(
workspace_name = ws.name,
format = ?cmds.format,
lint = ?cmds.lint,
build = ?cmds.build,
unit_test = ?cmds.unit_test,
"Diagnostics discovery completed"
);
Ok(())
}
async fn finalize_discovery(
storage: &WorkspaceStore,
ws_name: &str,
ws_path: &str,
discovery_generation: i64,
outcome: DiscoveryOutcome,
errors: &[String],
) {
if !check_generation(
storage,
ws_name,
discovery_generation,
GenerationColumn::DISCOVERY,
"final status",
)
.await
{
return;
}
match outcome {
DiscoveryOutcome::AllOk => {
clear_pending_pickup_cooldown(ws_name);
let commit_hash = crate::git_commands::run_git_head(std::path::Path::new(ws_path))
.await
.ok();
if let Err(e) = storage
.exec_update_with_updated_at(
"status = ?, paused = 0, last_analyzed_commit = ?",
vec![
Value::from(WorkspaceStatus::Ready.to_string()),
Value::from(commit_hash.as_deref()),
],
ws_name,
)
.await
{
tracing::warn!(
workspace = ws_name,
error = %e,
"Failed to update workspace status after discovery",
);
}
tracing::info!(workspace = ws_name, "Workspace pipeline resumed");
tracing::info!(
workspace_name = ws_name,
"Workspace analysis complete — all roles ready"
);
}
DiscoveryOutcome::Fatal => {
let msg = errors.join("; ");
clear_pending_pickup_cooldown(ws_name);
if let Err(e) = storage.set_status(ws_name, &WorkspaceStatus::Failed).await {
tracing::warn!(
workspace = ws_name,
error = %e,
"Failed to mark workspace Failed after fatal discovery failure"
);
}
tracing::warn!(workspace_name = ws_name, error = %msg, "Workspace analysis failed");
}
DiscoveryOutcome::Transient => {
record_pending_pickup_cooldown(ws_name);
if let Err(e) = storage.set_status(ws_name, &WorkspaceStatus::Pending).await {
tracing::warn!(
workspace = ws_name,
error = %e,
"Failed to return workspace to Pending after provider-class failure"
);
}
tracing::warn!(
workspace_name = ws_name,
error = %errors.join("; "),
"Workspace analysis stalled on provider failure — pending pickup retry after cooldown"
);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum DiscoveryFailureKind {
Transient,
Fatal,
}
#[derive(Debug)]
struct DiscoveryRunError {
kind: DiscoveryFailureKind,
error: anyhow::Error,
}
impl DiscoveryRunError {
fn transient(error: anyhow::Error) -> Self {
Self {
kind: DiscoveryFailureKind::Transient,
error,
}
}
fn fatal(error: anyhow::Error) -> Self {
Self {
kind: DiscoveryFailureKind::Fatal,
error,
}
}
fn classified(agent: &crate::Agent, error: anyhow::Error) -> Self {
match classify_discovery_failure(agent, Some(&error)) {
DiscoveryFailureKind::Transient => Self::transient(error),
DiscoveryFailureKind::Fatal => Self::fatal(error),
}
}
}
impl std::fmt::Display for DiscoveryRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.error)
}
}
impl From<anyhow::Error> for DiscoveryRunError {
fn from(error: anyhow::Error) -> Self {
Self::fatal(error)
}
}
fn classify_discovery_failure(
agent: &crate::Agent,
error: Option<&anyhow::Error>,
) -> DiscoveryFailureKind {
if crate::shutdown::is_draining()
|| crate::shutdown::shutdown_token().is_cancelled()
|| agent.is_cancelled()
{
return DiscoveryFailureKind::Transient;
}
let class = agent
.failure_class
.or_else(|| error.and_then(crate::agent::failure_class_from_error));
let Some(class) = class else {
return DiscoveryFailureKind::Fatal;
};
match class {
crate::retry::FailureClass::Transport
| crate::retry::FailureClass::TruncatedEnvelope
| crate::retry::FailureClass::NoResponse
| crate::retry::FailureClass::TruncatedOutput
| crate::retry::FailureClass::NonRetryable
| crate::retry::FailureClass::WallClockExceeded
| crate::retry::FailureClass::Shutdown => DiscoveryFailureKind::Transient,
crate::retry::FailureClass::Parse
| crate::retry::FailureClass::OutOfRangeScore
| crate::retry::FailureClass::Membership
| crate::retry::FailureClass::Completeness
| crate::retry::FailureClass::ContradictionAgents
| crate::retry::FailureClass::ValidationOther => DiscoveryFailureKind::Fatal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiscoveryOutcome {
AllOk,
Fatal,
Transient,
}
fn fold_discovery_result(
outcome: &mut DiscoveryOutcome,
errors: &mut Vec<String>,
result: Result<(), DiscoveryRunError>,
prefix: &str,
) {
if let Err(e) = result {
errors.push(crate::util::scrub_credentials(&format!("{prefix}{e}")));
if e.kind == DiscoveryFailureKind::Fatal {
*outcome = DiscoveryOutcome::Fatal;
} else if *outcome == DiscoveryOutcome::AllOk {
*outcome = DiscoveryOutcome::Transient;
}
}
}
fn discovery_no_response_error(agent: &crate::Agent, task_label: &str) -> DiscoveryRunError {
DiscoveryRunError::classified(
agent,
anyhow::anyhow!(
"{task_label} agent returned no response: {}",
agent.failure_reason("unknown error")
),
)
}
const PENDING_PICKUP_COOLDOWN_BASE_MINS: u64 = 15;
const PENDING_PICKUP_COOLDOWN_MAX_MINS: u64 = 240;
fn pending_pickup_cooldown_duration(attempts: u32) -> Duration {
let exponent = attempts.saturating_sub(1).min(4);
let minutes = (PENDING_PICKUP_COOLDOWN_BASE_MINS * 2u64.pow(exponent))
.min(PENDING_PICKUP_COOLDOWN_MAX_MINS);
Duration::from_mins(minutes)
}
#[derive(Debug, Clone, Copy)]
struct PickupCooldown {
deadline: Instant,
attempts: u32,
}
static PENDING_PICKUP_COOLDOWNS: OnceLock<std::sync::Mutex<HashMap<String, PickupCooldown>>> =
OnceLock::new();
fn pending_pickup_cooldowns() -> &'static std::sync::Mutex<HashMap<String, PickupCooldown>> {
PENDING_PICKUP_COOLDOWNS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
pub(crate) fn record_pending_pickup_cooldown(ws_name: &str) {
let mut map = pending_pickup_cooldowns().lock().unwrap_poison();
let attempts = map.get(ws_name).map_or(0, |c| c.attempts) + 1;
let deadline = Instant::now() + pending_pickup_cooldown_duration(attempts);
map.insert(ws_name.to_string(), PickupCooldown { deadline, attempts });
}
#[cfg(test)]
pub(crate) fn record_pending_pickup_cooldown_until(ws_name: &str, deadline: Instant) {
let mut map = pending_pickup_cooldowns().lock().unwrap_poison();
map.insert(
ws_name.to_string(),
PickupCooldown {
deadline,
attempts: 1,
},
);
}
pub(crate) fn clear_pending_pickup_cooldown(ws_name: &str) {
pending_pickup_cooldowns()
.lock()
.unwrap_poison()
.remove(ws_name);
}
pub(crate) fn pending_pickup_cooldown_active(ws_name: &str) -> bool {
let map = pending_pickup_cooldowns().lock().unwrap_poison();
map.get(ws_name)
.is_some_and(|c| Instant::now() < c.deadline)
}
async fn spawn_panic_guarded(
ws_name: &str,
task: &str,
future: impl std::future::Future<Output = ()> + Send + 'static,
) {
let inner = tokio::spawn(future);
match inner.await {
Ok(()) => {}
Err(e) => {
let kind = if e.is_panic() { "panic" } else { "cancelled" };
tracing::error!(
workspace_name = %ws_name,
kind = kind,
error = %e,
"{task} task failed",
);
}
}
}
pub fn spawn_workspace_discovery(
ws: &Workspace,
discovery_generation: i64,
discover_diagnostics: bool,
) {
let ws = ws.clone();
tokio::spawn(async move {
let ws_name = ws.name.clone();
let ws_path = ws.path.clone();
let ws_name_for_finalize = ws_name.clone();
let ws_name_for_inner = ws_name.clone();
let ws_path_for_finalize = ws_path.clone();
let inner = async move {
let role_futures: Vec<_> = Role::iter()
.filter(|r| crate::role::role_info(r).has_discovery)
.map(|role| {
let ws = ws.clone();
async move { run_workspace_discovery(&ws, role, discovery_generation).await }
})
.collect();
let (role_results, general_result, diagnostics_result) = if discover_diagnostics {
let diag_gen = match WORKSPACES.get() {
Some(s) => s
.get_generation(&ws_name_for_inner, GenerationColumn::DIAGNOSTICS)
.await
.unwrap_or(0),
None => 0,
};
tokio::join!(
join_all(role_futures),
run_general_discovery(&ws, discovery_generation),
run_workspace_diagnostics(&ws, diag_gen),
)
} else {
let (roles, general) = tokio::join!(
join_all(role_futures),
run_general_discovery(&ws, discovery_generation),
);
(roles, general, Ok(()))
};
let mut outcome = DiscoveryOutcome::AllOk;
let mut errors: Vec<String> = Vec::new();
for result in role_results {
fold_discovery_result(&mut outcome, &mut errors, result, "");
}
fold_discovery_result(&mut outcome, &mut errors, general_result, "");
fold_discovery_result(
&mut outcome,
&mut errors,
diagnostics_result,
"Diagnostics discovery failed: ",
);
let Some(storage) = WORKSPACES.get() else {
tracing::error!("WORKSPACES not initialized during final status update");
return;
};
finalize_discovery(
storage,
&ws_name_for_finalize,
&ws_path_for_finalize,
discovery_generation,
outcome,
&errors,
)
.await;
};
spawn_panic_guarded(&ws_name, "spawn_workspace_discovery", Box::pin(inner)).await;
});
}
pub fn spawn_diagnostics_discovery(ws: &Workspace, diagnostics_generation: i64) {
let ws = ws.clone();
tokio::spawn(async move {
let ws_name = ws.name.clone();
let inner = async move {
if let Err(e) = run_workspace_diagnostics(&ws, diagnostics_generation).await {
tracing::error!(
workspace_name = %ws.name,
error = %e,
"Diagnostics rediscovery failed",
);
}
};
spawn_panic_guarded(&ws_name, "spawn_diagnostics_discovery", inner).await;
});
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Workspace name must not be empty");
}
if name.len() > 40 {
anyhow::bail!("Workspace name must not exceed 40 characters");
}
if !name.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
anyhow::bail!("Workspace name must contain only ASCII letters (a-z, A-Z) and underscores");
}
if !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
anyhow::bail!("Workspace name must start with a letter");
}
if !name.chars().any(|c| c.is_ascii_alphabetic()) {
anyhow::bail!("Workspace name must contain at least one letter");
}
Ok(())
}
fn ensure_trailing_slash(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
format!("{trimmed}/")
}
fn canonicalize_workspace_path(raw: &str) -> Result<String, String> {
let expanded = crate::util::expand_tilde(raw);
let canonical = crate::util::with_block_in_place(|| {
std::fs::canonicalize(&expanded).map_err(|e| {
if expanded.exists() {
format!("Cannot access path '{}': {e}", expanded.display())
} else {
format!("Path does not exist: {}", expanded.display())
}
})
})?;
if !canonical.is_dir() {
return Err(format!("Path is not a directory: {}", canonical.display()));
}
Ok(canonical.to_string_lossy().to_string())
}
fn workspace_from_row(row: &turso::Row) -> anyhow::Result<Workspace> {
Ok(Workspace {
name: row.get(COL_WS_NAME)?,
path: row.get(COL_WS_PATH)?,
status: row
.get::<String>(COL_WS_STATUS)?
.parse::<WorkspaceStatus>()?,
maintenance_enabled: row.get::<bool>(COL_WS_MAINTENANCE_ENABLED)?,
paused: row.get::<bool>(COL_WS_PAUSED)?,
maintainer_debounce_mins: row.get::<i64>(COL_WS_MAINTAINER_DEBOUNCE_MINS)?,
maintainer_last_run_at: row.get::<Option<String>>(COL_WS_MAINTAINER_LAST_RUN_AT)?,
diagnostics: row.get::<Option<String>>(COL_WS_DIAGNOSTICS)?,
notes: row.get::<String>(COL_WS_NOTES)?,
last_analyzed_commit: row.get::<Option<String>>(COL_WS_LAST_ANALYZED_COMMIT)?,
ephemeral: false,
})
}
pub(crate) const MAX_WORKSPACE_NOTES_CHARS: usize = 4000;
pub(crate) fn truncate_workspace_notes(s: &str) -> String {
s.chars().take(MAX_WORKSPACE_NOTES_CHARS).collect()
}
impl WorkspaceStore {
async fn query_one(
&self,
where_clause: &str,
params: impl turso::IntoParams + Send + 'static,
) -> Result<Option<Workspace>> {
let sql = format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE {where_clause}");
self.conn
.query_optional(&sql, params, workspace_from_row)
.await
}
async fn exec_update_with_updated_at(
&self,
set_clause: &str,
set_params: Vec<turso::Value>,
name: &str,
) -> Result<()> {
let sql = format!("UPDATE workspaces SET {set_clause}, updated_at = ? WHERE name = ?");
let mut params = set_params;
params.push(Value::from(turso::now()));
params.push(Value::from(name));
self.conn.execute(&sql, params).await?;
Ok(())
}
pub async fn add(&self, name: &str, path: &str) -> Result<Workspace> {
validate_name(name)?;
let canonical = canonicalize_workspace_path(path).map_err(|e| anyhow::anyhow!("{e}"))?;
let path = ensure_trailing_slash(&canonical);
let now = turso::now();
let pending = WorkspaceStatus::Pending.to_string();
let ws = self
.conn
.query_row(
&format!(
"INSERT INTO workspaces (name, path, status, created_at, updated_at, paused) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING {WORKSPACE_COLUMNS}"
),
turso::params![name, path, pending, now.clone(), now.clone(), 1],
workspace_from_row,
)
.await?;
clear_pending_pickup_cooldown(name);
if let Err(e) =
crate::search_engine::get_or_init_engine(name, std::path::Path::new(&ws.path), false)
{
tracing::warn!(workspace_name = name, error = %e, "Failed to init search engine on workspace add");
}
Ok(ws)
}
pub async fn list(&self) -> Result<Vec<Workspace>> {
self.conn
.query_map_strict(
&format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces ORDER BY name"),
turso::params![],
workspace_from_row,
)
.await
}
pub async fn list_states(&self) -> Result<Vec<(String, bool, bool)>> {
let rows = self
.conn
.query(
&format!("SELECT {WS_STATE_COLUMNS} FROM workspaces ORDER BY name"),
turso::params![],
)
.await?;
let mut states = Vec::with_capacity(rows.len());
for row in &rows {
let name: String = row.get(COL_WSST_NAME)?;
let paused: bool = row.get(COL_WSST_PAUSED)?;
let maintenance_enabled: bool = row.get(COL_WSST_MAINTENANCE_ENABLED)?;
states.push((name, paused, maintenance_enabled));
}
Ok(states)
}
pub async fn get_by_name(&self, name: &str) -> Result<Option<Workspace>> {
self.query_one("name = ?1", turso::params![name]).await
}
pub async fn delete(&self, name: &str) -> Result<()> {
self.conn
.execute(
"DELETE FROM workspaces WHERE name = ?1",
turso::params![name],
)
.await?;
crate::search_engine::remove_engine(name);
clear_pending_pickup_cooldown(name);
Ok(())
}
pub async fn set_status(&self, name: &str, status: &WorkspaceStatus) -> Result<()> {
self.exec_update_with_updated_at("status = ?", vec![Value::from(status.to_string())], name)
.await
}
pub(crate) async fn claim_pending_for_discovery(&self, name: &str) -> Result<Option<i64>> {
self.conn
.query_optional(
"UPDATE workspaces SET status = ?, paused = 1, updated_at = ? \
WHERE name = ? AND status = ? RETURNING discovery_generation",
turso::params![
WorkspaceStatus::Analyzing.to_string(),
turso::now(),
name,
WorkspaceStatus::Pending.to_string()
],
|row| row.get(0),
)
.await
}
pub async fn reclassify_analyzing_to_pending(&self) -> Result<u64> {
let affected = self
.conn
.execute(
"UPDATE workspaces SET status = ?, updated_at = ? WHERE status = ?",
turso::params![
WorkspaceStatus::Pending.to_string(),
turso::now(),
WorkspaceStatus::Analyzing.to_string()
],
)
.await?;
if affected > 0 {
tracing::info!(
count = affected,
"Boot recovery: reclassified stranded analyzing workspaces to pending"
);
}
Ok(affected)
}
pub async fn set_maintenance_enabled(&self, name: &str, enabled: bool) -> Result<()> {
let val: i64 = i64::from(enabled);
if enabled {
self.exec_update_with_updated_at(
"maintenance = ?, maintainer_debounce_mins = 5, maintainer_last_run_at = NULL",
vec![Value::from(val)],
name,
)
.await?;
} else {
self.exec_update_with_updated_at("maintenance = ?", vec![Value::from(val)], name)
.await?;
if let Some(ws) = self.get_by_name(name).await? {
crate::registry::AGENT_REGISTRY
.cancel_by_role_and_workspace_path(Role::Maintainer.as_str(), &ws.path);
}
}
if enabled {
tracing::info!(workspace = name, "Maintainer enabled");
} else {
tracing::info!(workspace = name, "Maintainer disabled");
}
Ok(())
}
pub async fn set_paused(&self, name: &str, paused: bool) -> Result<()> {
let val: i64 = i64::from(paused);
self.exec_update_with_updated_at("paused = ?", vec![Value::from(val)], name)
.await?;
if paused {
tracing::info!(workspace = name, "Workspace pipeline paused");
} else {
tracing::info!(workspace = name, "Workspace pipeline resumed");
}
Ok(())
}
pub async fn set_maintenance_debounce(
&self,
name: &str,
debounce_mins: i64,
last_run_at: &str,
) -> Result<()> {
self.exec_update_with_updated_at(
"maintainer_debounce_mins = ?, maintainer_last_run_at = ?",
vec![Value::from(debounce_mins), Value::from(last_run_at)],
name,
)
.await
}
pub(crate) async fn set_diagnostics(
&self,
name: &str,
commands: &crate::DiagnosticsCommands,
) -> Result<()> {
let json = serde_json::to_string(commands)?;
self.exec_update_with_updated_at(
"diagnostics = ?, diagnostics_generation = diagnostics_generation + 1",
vec![Value::from(json)],
name,
)
.await
}
pub(crate) async fn get_diagnostics(
&self,
name: &str,
) -> Result<Option<crate::DiagnosticsCommands>> {
let json: Option<String> = self
.conn
.query_optional(
"SELECT diagnostics FROM workspaces WHERE name = ?1",
turso::params![name],
|row| row.get::<Option<String>>(0),
)
.await?
.flatten();
match json {
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
None => Ok(None),
}
}
pub async fn set_notes(&self, name: &str, notes: &str) -> Result<()> {
let notes = truncate_workspace_notes(notes);
self.exec_update_with_updated_at("notes = ?", vec![Value::from(notes)], name)
.await
}
async fn clear_contexts(&self, name: &str) -> Result<()> {
self.conn
.execute(
"DELETE FROM workspace_contexts WHERE workspace_name = ?1",
turso::params![name],
)
.await?;
Ok(())
}
async fn get_generation(&self, name: &str, column: GenerationColumn) -> Result<i64> {
self.conn
.query_row(
&format!("SELECT {} FROM workspaces WHERE name = ?1", column.name),
turso::params![name],
|row| row.get(0),
)
.await
.map_err(Into::into)
}
pub async fn rediscover(&self, name: &str) -> Result<()> {
let ws = self
.get_by_name(name)
.await?
.ok_or_else(|| anyhow::anyhow!("Workspace {name} not found"))?;
self.exec_update_with_updated_at(
"discovery_generation = discovery_generation + 1, status = ?, paused = 1",
vec![Value::from(WorkspaceStatus::Analyzing.to_string())],
name,
)
.await?;
clear_pending_pickup_cooldown(name);
self.clear_contexts(name).await?;
let generation = self
.get_generation(name, GenerationColumn::DISCOVERY)
.await?;
let discover_diagnostics = ws.diagnostics.is_none();
spawn_workspace_discovery(&ws, generation, discover_diagnostics);
Ok(())
}
pub async fn rediscover_diagnostics(&self, name: &str) -> Result<()> {
let ws = self
.get_by_name(name)
.await?
.ok_or_else(|| anyhow::anyhow!("Workspace {name} not found"))?;
self.exec_update_with_updated_at(
"diagnostics_generation = diagnostics_generation + 1, diagnostics = NULL",
vec![],
name,
)
.await?;
let generation = self
.get_generation(name, GenerationColumn::DIAGNOSTICS)
.await?;
spawn_diagnostics_discovery(&ws, generation);
Ok(())
}
pub async fn get_context(&self, name: &str, role: &str) -> Result<Option<String>> {
self.conn
.query_optional(
"SELECT content FROM workspace_contexts WHERE workspace_name = ?1 AND role = ?2",
turso::params![name, role],
|row| row.get::<String>(0),
)
.await
}
pub async fn set_context(&self, name: &str, role: &str, content: &str) -> Result<()> {
let now = turso::now();
self.conn
.execute(
"INSERT INTO workspace_contexts (workspace_name, role, content, created_at) VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(workspace_name, role) DO UPDATE SET content = excluded.content, created_at = excluded.created_at",
turso::params![name, role, content, now],
)
.await?;
Ok(())
}
pub async fn get_general_context(&self, name: &str) -> Result<Option<String>> {
self.conn
.query_optional(
"SELECT content FROM workspace_contexts WHERE workspace_name = ?1 AND role IS NULL",
turso::params![name],
|row| row.get::<String>(0),
)
.await
}
pub async fn set_general_context(&self, name: &str, content: &str) -> Result<()> {
let now = turso::now();
self.conn
.execute(
"INSERT INTO workspace_contexts (workspace_name, role, content, created_at) VALUES (?1, NULL, ?2, ?3) \
ON CONFLICT(workspace_name) WHERE role IS NULL DO UPDATE SET content = excluded.content, created_at = excluded.created_at",
turso::params![name, content, now],
)
.await?;
Ok(())
}
pub async fn save_editor_tabs(&self, name: &str, tabs: &[EditorTabRecord]) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM editor_tabs WHERE workspace_name = ?1",
turso::params![name],
)
.await?;
for tab in tabs {
tx.execute(
"INSERT INTO editor_tabs (workspace_name, file_path, tab_order, is_active, is_dirty, dirty_content) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
turso::params![
name,
tab.file_path.clone(),
i64::try_from(tab.tab_order).unwrap_or(i64::MAX),
i64::from(tab.is_active),
i64::from(tab.is_dirty),
tab.dirty_content.clone(),
],
)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn load_editor_tabs(&self, name: &str) -> Result<Vec<EditorTabRecord>> {
let rows = self.conn
.query_map(
&format!("SELECT {EDITOR_TAB_COLUMNS} FROM editor_tabs WHERE workspace_name = ?1 ORDER BY tab_order"),
turso::params![name],
|row| -> std::result::Result<EditorTabRecord, String> {
Ok(EditorTabRecord {
file_path: row
.get::<String>(COL_ET_FILE_PATH)
.map_err(|e| format!("failed to read file_path: {e}"))?,
tab_order: usize::try_from(
row.get::<i64>(COL_ET_TAB_ORDER)
.map_err(|e| format!("failed to read tab_order: {e}"))?,
)
.unwrap_or(0),
is_active: row
.get::<bool>(COL_ET_IS_ACTIVE)
.map_err(|e| format!("failed to read is_active: {e}"))?,
is_dirty: row
.get::<bool>(COL_ET_IS_DIRTY)
.map_err(|e| format!("failed to read is_dirty: {e}"))?,
dirty_content: row
.get::<Option<String>>(COL_ET_DIRTY_CONTENT)
.map_err(|e| format!("failed to read dirty_content: {e}"))?,
})
},
)
.await?;
let mut tabs = Vec::new();
for row in rows {
let tab = row.map_err(|e| anyhow::anyhow!("Failed to parse editor tab row: {e}"))?;
if tab.file_path.is_empty() || tab.file_path.trim().is_empty() {
warn!(
workspace = %name,
tab_order = tab.tab_order,
"Skipping editor tab with empty file_path — would resolve to workspace root"
);
continue;
}
tabs.push(tab);
}
Ok(tabs)
}
}
#[derive(Debug, Clone)]
pub struct EditorTabRecord {
pub file_path: String,
pub tab_order: usize,
pub is_active: bool,
pub is_dirty: bool,
pub dirty_content: Option<String>,
}
pub async fn get_workspaces() -> anyhow::Result<Vec<Workspace>> {
let store = WORKSPACES
.get()
.ok_or_else(|| anyhow::anyhow!("Workspace store not initialized"))?;
store.list().await
}
const NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY: &str = "nightly_discovery_last_pass_at";
fn is_nightly_check_hour(local_hour: u32) -> bool {
(2..3).contains(&local_hour)
}
fn nightly_gate_allows(last_pass_at: Option<&str>, now: DateTime<Utc>) -> bool {
match last_pass_at {
None => true,
Some(raw) => match turso::parse_utc_timestamp(raw) {
Ok(last) => now.signed_duration_since(last) >= chrono::Duration::days(7),
Err(e) => {
warn!(
nightly_discovery_last_pass_at = %raw,
error = %e,
"Failed to parse nightly discovery last-pass timestamp, letting through"
);
true
}
},
}
}
async fn nightly_gate_should_run(config_store: Option<&ConfigStore>) -> bool {
let last_pass_at = if let Some(store) = config_store {
match store.get_kv(NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(
error = %e,
"Nightly check: failed to read last-pass timestamp — running pass ungated"
);
None
}
}
} else {
tracing::warn!("Nightly check: CONFIG_STORE not initialized — running pass ungated");
None
};
if !nightly_gate_allows(last_pass_at.as_deref(), Utc::now()) {
tracing::debug!("Nightly check: last pass is less than 7 days old — skipping this night");
return false;
}
if let Some(store) = config_store {
let started_at = turso::now();
if let Err(e) = store
.set_kv(NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY, &started_at)
.await
{
tracing::warn!(
error = %e,
"Nightly check: failed to record pass start — skipping this pass"
);
return false;
}
}
true
}
fn has_new_commits(last_analyzed_commit: Option<&str>, current_hash: &str) -> bool {
match last_analyzed_commit {
Some(stored) => stored != current_hash,
None => true,
}
}
pub async fn run_nightly_check_loop() {
let interval = Duration::from_mins(30);
let shutdown = crate::shutdown::shutdown_token();
loop {
if !crate::shutdown::sleep_or_shutdown_or_drain(interval).await {
break;
}
if !is_nightly_check_hour(chrono::Local::now().hour()) {
continue;
}
if !nightly_gate_should_run(crate::config_db::CONFIG_STORE.get()).await {
continue;
}
if let Err(e) = crate::temp_cleanup::dispatch_temp_cleanup().await {
tracing::warn!(error = %e, "Nightly check: temp-dir cleaner dispatch failed — discovery pass continues");
}
let store = if let Some(s) = WORKSPACES.get() {
s.clone()
} else {
tracing::warn!("Nightly check: WORKSPACES not initialized");
continue;
};
let workspaces = match store.list().await {
Ok(list) => list,
Err(e) => {
tracing::warn!(error = %e, "Nightly check: failed to list workspaces");
continue;
}
};
for ws in &workspaces {
if shutdown.is_cancelled() {
break;
}
if ws.status != WorkspaceStatus::Ready || ws.paused {
continue;
}
let repo_path = std::path::Path::new(&ws.path);
let current_hash = match crate::git_commands::run_git_head(repo_path).await {
Ok(hash) => hash,
Err(e) => {
tracing::debug!(
workspace = %ws.name,
error = %e,
"Nightly check: git rev-parse HEAD failed — skipping workspace",
);
continue;
}
};
let should_rediscover =
has_new_commits(ws.last_analyzed_commit.as_deref(), ¤t_hash);
if !should_rediscover {
tracing::debug!(
workspace = %ws.name,
"Nightly check: no new commits — skipping",
);
continue;
}
tracing::info!(
workspace = %ws.name,
"Nightly check: new commits detected — triggering rediscover",
);
if let Err(e) = store.rediscover(&ws.name).await {
tracing::warn!(
workspace = %ws.name,
error = %e,
"Nightly check: rediscover failed",
);
continue;
}
let deadline = std::time::Instant::now() + Duration::from_hours(4);
loop {
if shutdown.is_cancelled() {
break;
}
if std::time::Instant::now() >= deadline {
tracing::warn!(
workspace = %ws.name,
"Nightly check: discovery timed out — proceeding to next workspace",
);
break;
}
tokio::time::sleep(Duration::from_secs(10)).await;
match store.get_by_name(&ws.name).await {
Ok(Some(current)) if current.status != WorkspaceStatus::Analyzing => {
break;
}
Ok(_) => {} Err(e) => {
tracing::warn!(
workspace = %ws.name,
error = %e,
"Nightly check: failed to poll workspace status",
);
break;
}
}
}
}
}
}
#[cfg(test)]
#[must_use]
pub fn test_ws(path: impl AsRef<std::path::Path>) -> Workspace {
Workspace::from_path(path.as_ref())
}
#[cfg(test)]
#[must_use]
pub fn test_ws_named(path: &str, name: &str) -> Workspace {
Workspace {
name: name.to_string(),
path: path.to_string(),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn test_store() -> (WorkspaceStore, TempDir) {
crate::open_test_store!(WorkspaceStore, "workspace")
}
async fn insert_direct(
store: &WorkspaceStore,
name: &str,
path: &str,
paused: bool,
maintenance_enabled: bool,
discovery_generation: i64,
diagnostics_generation: i64,
) -> Workspace {
let now = crate::turso::now();
let paused_int: i64 = i64::from(paused);
let maint_int: i64 = i64::from(maintenance_enabled);
store
.conn
.execute(
"INSERT INTO workspaces (name, path, created_at, updated_at, paused, maintenance, discovery_generation, diagnostics_generation) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
crate::turso::params![name, path, now.clone(), now.clone(), paused_int, maint_int, discovery_generation, diagnostics_generation],
)
.await
.expect("insert workspace");
Workspace {
name: name.to_string(),
path: path.to_string(),
status: WorkspaceStatus::Pending,
maintenance_enabled,
paused,
maintainer_debounce_mins: 5,
maintainer_last_run_at: None,
diagnostics: None,
notes: String::new(),
last_analyzed_commit: None,
ephemeral: false,
}
}
#[tokio::test]
async fn schema_default_is_paused() {
let (store, _tmp) = test_store().await;
let now = crate::turso::now();
store
.conn
.execute(
"INSERT INTO workspaces (name, path, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4)",
crate::turso::params!["schema_test", "/tmp/schema_test", now.clone(), now.clone()],
)
.await
.expect("insert workspace");
let ws = store
.get_by_name("schema_test")
.await
.expect("fetch")
.expect("exists");
assert!(
ws.paused,
"Schema DEFAULT should produce paused = true for new rows"
);
}
#[tokio::test]
async fn set_paused_toggles_pause_state() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "toggle_test", "/tmp/toggle_test", true, false, 0, 0).await;
store.set_paused("toggle_test", false).await.unwrap();
let fetched = store
.get_by_name("toggle_test")
.await
.expect("fetch")
.expect("exists");
assert!(
!fetched.paused,
"Should be unpaused after set_paused(false)"
);
store.set_paused("toggle_test", true).await.unwrap();
let fetched = store
.get_by_name("toggle_test")
.await
.expect("fetch")
.expect("exists");
assert!(fetched.paused, "Should be paused after set_paused(true)");
}
#[tokio::test]
async fn set_maintenance_toggles_maintenance_state() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "maint_test", "/tmp/maint_test", true, false, 0, 0).await;
store
.set_maintenance_enabled("maint_test", true)
.await
.unwrap();
let fetched = store
.get_by_name("maint_test")
.await
.expect("fetch")
.expect("exists");
assert!(
fetched.maintenance_enabled,
"Should have maintenance enabled after set_maintenance_enabled(true)"
);
store
.set_maintenance_enabled("maint_test", false)
.await
.unwrap();
let fetched = store
.get_by_name("maint_test")
.await
.expect("fetch")
.expect("exists");
assert!(
!fetched.maintenance_enabled,
"Should have maintenance disabled after set_maintenance_enabled(false)"
);
}
#[tokio::test]
async fn set_notes_roundtrip() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "notes_test", "/tmp/notes_test", true, false, 0, 0).await;
let ws = store
.get_by_name("notes_test")
.await
.expect("fetch")
.expect("exists");
assert!(ws.notes.is_empty(), "New workspace should have empty notes");
let test_notes = "These are important context notes for agents.";
store
.set_notes("notes_test", test_notes)
.await
.expect("set_notes");
let ws = store
.get_by_name("notes_test")
.await
.expect("fetch")
.expect("exists");
assert_eq!(ws.notes, test_notes, "Notes should round-trip correctly");
let updated_notes = "Updated notes with more context.";
store
.set_notes("notes_test", updated_notes)
.await
.expect("set_notes");
let ws = store
.get_by_name("notes_test")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
ws.notes, updated_notes,
"Notes update should round-trip correctly"
);
let long_notes = "x".repeat(MAX_WORKSPACE_NOTES_CHARS + 1000);
store
.set_notes("notes_test", &long_notes)
.await
.expect("set_notes");
let ws = store
.get_by_name("notes_test")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
ws.notes.chars().count(),
MAX_WORKSPACE_NOTES_CHARS,
"Notes should be truncated to {MAX_WORKSPACE_NOTES_CHARS} chars"
);
assert_eq!(
ws.notes,
"x".repeat(MAX_WORKSPACE_NOTES_CHARS),
"Notes content should match truncated"
);
let multi_byte = "é".repeat(MAX_WORKSPACE_NOTES_CHARS + 1000);
store
.set_notes("notes_test", &multi_byte)
.await
.expect("set_notes");
let ws = store
.get_by_name("notes_test")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
ws.notes.chars().count(),
MAX_WORKSPACE_NOTES_CHARS,
"Notes should be truncated to {MAX_WORKSPACE_NOTES_CHARS} chars (multi-byte)"
);
assert_eq!(
ws.notes,
"é".repeat(MAX_WORKSPACE_NOTES_CHARS),
"Notes content should match truncated (multi-byte, no broken chars)"
);
}
#[tokio::test]
async fn list_states_returns_name_paused_maintenance() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "alice", "/tmp/alice", true, false, 0, 0).await;
store.set_maintenance_enabled("alice", false).await.unwrap();
insert_direct(&store, "bob", "/tmp/bob", false, false, 0, 0).await;
store.set_maintenance_enabled("bob", true).await.unwrap();
let states = store.list_states().await.expect("list_states");
assert_eq!(states.len(), 2, "Should return both workspaces");
let mut map: std::collections::HashMap<&str, (bool, bool)> =
std::collections::HashMap::new();
for (name, paused, maintenance_enabled) in &states {
map.insert(name.as_str(), (*paused, *maintenance_enabled));
}
assert_eq!(
map.get("alice").copied(),
Some((true, false)),
"Alice: paused=true, maintenance_enabled=false"
);
assert_eq!(
map.get("bob").copied(),
Some((false, true)),
"Bob: paused=false, maintenance_enabled=true"
);
}
#[tokio::test]
async fn finalize_discovery_success_auto_unpauses() {
for (suffix, generation) in [("gen0", 0), ("gen1", 1)] {
let (store, _tmp) = test_store().await;
insert_direct(
&store,
suffix,
&format!("/tmp/{suffix}"),
true,
false,
generation,
generation,
)
.await;
finalize_discovery(
&store,
suffix,
&format!("/tmp/{suffix}"),
generation,
DiscoveryOutcome::AllOk,
&[],
)
.await;
let ws = store
.get_by_name(suffix)
.await
.expect("fetch")
.expect("exists");
assert!(
!ws.paused,
"Should auto-unpause after discovery OK (gen {generation})"
);
assert_eq!(
ws.status,
WorkspaceStatus::Ready,
"Status should be 'ready'"
);
}
}
#[tokio::test]
async fn finalize_discovery_failure_keeps_paused() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "fail_gen0", "/tmp/fail_gen0", true, false, 0, 0).await;
let errors = vec!["Empty response for 'general'".to_string()];
finalize_discovery(
&store,
"fail_gen0",
"/tmp/fail_gen0",
0,
DiscoveryOutcome::Fatal,
&errors,
)
.await;
let ws = store
.get_by_name("fail_gen0")
.await
.expect("fetch")
.expect("exists");
assert!(ws.paused, "Should remain paused after discovery failure");
assert_eq!(
ws.status,
WorkspaceStatus::Failed,
"Status should be 'failed'"
);
}
#[tokio::test]
async fn finalize_discovery_provider_failure_returns_to_pending() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "prov_gen0", "/tmp/prov_gen0", true, false, 0, 0).await;
let errors = vec![
"Diagnostics discovery failed: exhausted retry budget (last: transport): connection reset"
.to_string(),
];
finalize_discovery(
&store,
"prov_gen0",
"/tmp/prov_gen0",
0,
DiscoveryOutcome::Transient,
&errors,
)
.await;
let ws = store
.get_by_name("prov_gen0")
.await
.expect("fetch")
.expect("exists");
assert!(ws.paused, "Should remain paused (analysis pause)");
assert_eq!(
ws.status,
WorkspaceStatus::Pending,
"Provider-class discovery failure should return the workspace to pending"
);
assert!(
pending_pickup_cooldown_active("prov_gen0"),
"Provider-class failure must arm the in-memory pickup cooldown"
);
finalize_discovery(
&store,
"prov_gen0",
"/tmp/prov_gen0",
0,
DiscoveryOutcome::AllOk,
&[],
)
.await;
assert!(
!pending_pickup_cooldown_active("prov_gen0"),
"Successful discovery must clear the pickup cooldown"
);
}
#[tokio::test]
async fn finalize_discovery_stale_generation_skips_writes() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "stale", "/tmp/stale", true, false, 0, 0).await;
store
.exec_update_with_updated_at("discovery_generation = 1", vec![], "stale")
.await
.expect("bump generation");
finalize_discovery(
&store,
"stale",
"/tmp/stale",
0,
DiscoveryOutcome::AllOk,
&[],
)
.await;
let ws = store
.get_by_name("stale")
.await
.expect("fetch")
.expect("exists");
assert!(
ws.paused,
"Should stay paused — writes skipped by generation guard"
);
assert_eq!(
ws.status,
WorkspaceStatus::Pending,
"Status should remain unchanged — writes skipped"
);
}
#[tokio::test]
async fn rediscover_sets_paused() {
let (store, _tmp) = test_store().await;
insert_direct(
&store,
"rediscover_test",
"/tmp/rediscover_test",
false,
false,
0,
0,
)
.await;
store
.set_status("rediscover_test", &WorkspaceStatus::Ready)
.await
.unwrap();
let ws = store
.get_by_name("rediscover_test")
.await
.expect("fetch")
.expect("exists");
assert!(!ws.paused, "Precondition: workspace should start unpaused");
assert_eq!(
ws.status,
WorkspaceStatus::Ready,
"Precondition: status should be 'ready'"
);
store
.rediscover("rediscover_test")
.await
.expect("rediscover");
let ws = store
.get_by_name("rediscover_test")
.await
.expect("fetch")
.expect("exists");
assert!(
ws.paused,
"rediscover() must set paused = true when transitioning to 'analyzing'"
);
}
#[tokio::test]
#[serial_test::serial(config_persist)] async fn add_returns_paused_true() {
let (store, _tmp) = test_store().await;
let dir = TempDir::new().expect("temp dir for workspace path");
crate::search_engine::init_global();
let _ = crate::config::CONFIG.try_set_storage_root(crate::util::test::test_root().clone());
crate::config::CONFIG.swap(crate::config::ConfigData::STRUCT_FIELDS_DEFAULT);
let ws = store
.add("add_test", dir.path().to_str().unwrap())
.await
.expect("add workspace");
assert!(
ws.paused,
"add() must return a Workspace with paused = true"
);
assert_eq!(
ws.status,
WorkspaceStatus::Pending,
"add() must return a Workspace with status = pending — \
discovery is deferred to the pickup step until the provider is configured"
);
assert!(
!ws.maintenance_enabled,
"add() must return a Workspace with maintenance_enabled = false"
);
assert_eq!(
ws.maintainer_debounce_mins, 5,
"add() must return a Workspace with maintainer_debounce_mins = 5"
);
let fetched = store
.get_by_name("add_test")
.await
.expect("fetch")
.expect("exists");
assert!(
fetched.paused,
"Persisted workspace must have paused = true"
);
assert_eq!(
fetched.status,
WorkspaceStatus::Pending,
"Persisted workspace must have status = pending"
);
}
#[tokio::test]
async fn claim_pending_for_discovery_is_atomic_and_returns_live_generation() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "pickup", "/tmp/pickup", true, false, 3, 0).await;
let generation = store
.claim_pending_for_discovery("pickup")
.await
.expect("claim")
.expect("pending row should be claimable");
assert_eq!(generation, 3, "must return the live discovery_generation");
let ws = store
.get_by_name("pickup")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
ws.status,
WorkspaceStatus::Analyzing,
"claim must transition pending → analyzing"
);
assert!(ws.paused, "claim must set the analysis pause");
let second = store
.claim_pending_for_discovery("pickup")
.await
.expect("claim");
assert!(
second.is_none(),
"a non-pending row must not be claimable twice"
);
}
#[tokio::test]
async fn reclassify_analyzing_to_pending_recovers_stranded_workspaces() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "stranded1", "/tmp/stranded1", true, false, 0, 0).await;
insert_direct(&store, "stranded2", "/tmp/stranded2", true, false, 0, 0).await;
insert_direct(&store, "fine", "/tmp/fine", false, false, 0, 0).await;
store
.set_status("stranded1", &WorkspaceStatus::Analyzing)
.await
.unwrap();
store
.set_status("stranded2", &WorkspaceStatus::Analyzing)
.await
.unwrap();
store
.set_status("fine", &WorkspaceStatus::Ready)
.await
.unwrap();
let affected = store
.reclassify_analyzing_to_pending()
.await
.expect("reclassify");
assert_eq!(affected, 2, "only analyzing workspaces are reclassified");
for name in ["stranded1", "stranded2"] {
let ws = store
.get_by_name(name)
.await
.expect("fetch")
.expect("exists");
assert_eq!(
ws.status,
WorkspaceStatus::Pending,
"stranded analyzing workspace must become pending at boot"
);
}
let fine = store
.get_by_name("fine")
.await
.expect("fetch")
.expect("exists");
assert_eq!(
fine.status,
WorkspaceStatus::Ready,
"non-analyzing workspaces are untouched"
);
}
fn classify_with(class: Option<crate::retry::FailureClass>) -> DiscoveryFailureKind {
let ws = test_ws("/tmp/classify_ws");
let mut agent = crate::Agent::new(
"classify-test".into(),
crate::Role::Discovery,
&ws,
None,
String::new(),
String::new(),
None,
None,
);
agent.failure_class = class;
classify_discovery_failure(&agent, None)
}
#[test]
fn discovery_failure_taxonomy_maps_provider_and_genuine_classes() {
for class in [
crate::retry::FailureClass::Transport,
crate::retry::FailureClass::TruncatedEnvelope,
crate::retry::FailureClass::NoResponse,
crate::retry::FailureClass::TruncatedOutput,
crate::retry::FailureClass::NonRetryable, crate::retry::FailureClass::WallClockExceeded,
crate::retry::FailureClass::Shutdown,
] {
assert_eq!(
classify_with(Some(class)),
DiscoveryFailureKind::Transient,
"{class:?} must be provider-class (Transient)"
);
}
for class in [
crate::retry::FailureClass::Parse,
crate::retry::FailureClass::OutOfRangeScore,
crate::retry::FailureClass::Membership,
crate::retry::FailureClass::Completeness,
crate::retry::FailureClass::ContradictionAgents,
crate::retry::FailureClass::ValidationOther,
] {
assert_eq!(
classify_with(Some(class)),
DiscoveryFailureKind::Fatal,
"{class:?} must be a genuine failure (Fatal)"
);
}
assert_eq!(
classify_with(None),
DiscoveryFailureKind::Fatal,
"unclassified runtime failures must be Fatal"
);
}
#[tokio::test]
async fn pending_pickup_cooldown_arms_clears_and_expires() {
clear_pending_pickup_cooldown("cooldown_ws");
assert!(
!pending_pickup_cooldown_active("cooldown_ws"),
"no cooldown by default"
);
record_pending_pickup_cooldown_until(
"cooldown_ws",
Instant::now() + Duration::from_mins(1),
);
assert!(
pending_pickup_cooldown_active("cooldown_ws"),
"armed cooldown must gate the pickup"
);
clear_pending_pickup_cooldown("cooldown_ws");
assert!(
!pending_pickup_cooldown_active("cooldown_ws"),
"clear must disarm the cooldown"
);
let past = Instant::now()
.checked_sub(Duration::from_mins(1))
.expect("instant subtraction cannot underflow in a test");
record_pending_pickup_cooldown_until("cooldown_ws", past);
assert!(
!pending_pickup_cooldown_active("cooldown_ws"),
"an expired cooldown must not gate the pickup"
);
clear_pending_pickup_cooldown("cooldown_ws");
}
#[test]
fn pending_pickup_cooldown_escalates_and_resets() {
assert_eq!(pending_pickup_cooldown_duration(1), Duration::from_mins(15));
assert_eq!(pending_pickup_cooldown_duration(2), Duration::from_mins(30));
assert_eq!(pending_pickup_cooldown_duration(3), Duration::from_hours(1));
assert_eq!(pending_pickup_cooldown_duration(4), Duration::from_hours(2));
assert_eq!(
pending_pickup_cooldown_duration(5),
Duration::from_hours(4),
"escalation caps at 4 h"
);
assert_eq!(
pending_pickup_cooldown_duration(99),
Duration::from_hours(4),
"escalation never exceeds the 4 h cap"
);
clear_pending_pickup_cooldown("escalate_ws");
record_pending_pickup_cooldown("escalate_ws");
record_pending_pickup_cooldown("escalate_ws");
let map = pending_pickup_cooldowns().lock().unwrap_poison();
let entry = map
.get("escalate_ws")
.expect("cooldown entry after two failures");
assert_eq!(entry.attempts, 2, "two failures → attempt count 2");
let remaining = entry.deadline.saturating_duration_since(Instant::now());
assert!(
remaining >= Duration::from_mins(25),
"second failure must arm a ~30 min cooldown (got {remaining:?})"
);
drop(map);
clear_pending_pickup_cooldown("escalate_ws");
assert!(
!pending_pickup_cooldown_active("escalate_ws"),
"clear must reset the cooldown"
);
}
#[tokio::test]
async fn editor_tabs_round_trip_dirty_content() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "ws1", "/tmp/ws1", false, false, 0, 0).await;
let tabs = vec![EditorTabRecord {
file_path: "notes.md".to_string(),
tab_order: 0,
is_active: true,
is_dirty: true,
dirty_content: Some("draft text".to_string()),
}];
store
.save_editor_tabs("ws1", &tabs)
.await
.expect("save tabs");
let loaded = store.load_editor_tabs("ws1").await.expect("load tabs");
assert_eq!(loaded.len(), 1);
assert!(loaded[0].is_active);
assert!(loaded[0].is_dirty);
assert_eq!(loaded[0].dirty_content.as_deref(), Some("draft text"));
}
#[tokio::test]
async fn set_diagnostics_roundtrip() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "diag_test", "/tmp/diag_test", false, false, 0, 0).await;
let cmds = crate::DiagnosticsCommands {
format: Some("cargo fmt".into()),
format_check: Some("cargo fmt -- --check".into()),
lint: Some("cargo clippy -- -D warnings".into()),
..Default::default()
};
store
.set_diagnostics("diag_test", &cmds)
.await
.expect("set_diagnostics");
let loaded = store
.get_diagnostics("diag_test")
.await
.expect("get_diagnostics")
.expect("should have diagnostics");
assert_eq!(loaded.format.as_deref(), Some("cargo fmt"));
assert_eq!(loaded.format_check.as_deref(), Some("cargo fmt -- --check"));
assert_eq!(loaded.lint.as_deref(), Some("cargo clippy -- -D warnings"));
assert!(loaded.lint_fix.is_none());
assert!(loaded.type_check.is_none());
assert!(loaded.build.is_none());
assert!(loaded.unit_test.is_none());
}
#[tokio::test]
async fn get_generation_reads_diagnostics_column() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "gen_test", "/tmp/gen_test", false, false, 5, 3).await;
let diag_gen_val = store
.get_generation("gen_test", GenerationColumn::DIAGNOSTICS)
.await
.expect("get_generation");
assert_eq!(
diag_gen_val, 3,
"Should return the stored diagnostics_generation"
);
}
#[tokio::test]
async fn set_diagnostics_bumps_diagnostics_generation() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "bump_test", "/tmp/bump_test", false, false, 0, 0).await;
let cmds = crate::DiagnosticsCommands::default();
store
.set_diagnostics("bump_test", &cmds)
.await
.expect("set_diagnostics");
let diag_gen_val = store
.get_generation("bump_test", GenerationColumn::DIAGNOSTICS)
.await
.expect("get_generation");
assert_eq!(
diag_gen_val, 1,
"set_diagnostics should bump diagnostics_generation to 1"
);
}
#[tokio::test]
async fn rediscover_diagnostics_clears_and_bumps() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "redia_test", "/tmp/redia_test", false, false, 0, 0).await;
let cmds = crate::DiagnosticsCommands {
build: Some("cargo build".into()),
..Default::default()
};
store
.set_diagnostics("redia_test", &cmds)
.await
.expect("set_diagnostics");
assert!(
store
.get_diagnostics("redia_test")
.await
.expect("get_diagnostics")
.is_some()
);
let diag_gen_before = store
.get_generation("redia_test", GenerationColumn::DIAGNOSTICS)
.await
.expect("get_generation");
assert_eq!(diag_gen_before, 1, "Should be 1 after set_diagnostics");
store
.rediscover_diagnostics("redia_test")
.await
.expect("rediscover_diagnostics");
assert!(
store
.get_diagnostics("redia_test")
.await
.expect("get_diagnostics")
.is_none(),
"rediscover_diagnostics should clear diagnostics"
);
let diag_gen_after = store
.get_generation("redia_test", GenerationColumn::DIAGNOSTICS)
.await
.expect("get_generation");
assert_eq!(
diag_gen_after, 2,
"rediscover_diagnostics should bump diagnostics_generation to 2"
);
let discovery_gen_val = store
.get_generation("redia_test", GenerationColumn::DISCOVERY)
.await
.expect("get_generation");
assert_eq!(
discovery_gen_val, 0,
"rediscover_diagnostics should NOT affect discovery_generation"
);
}
#[tokio::test]
async fn diagnostics_generation_guard_skips_stale_writes() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "diag_stale", "/tmp/diag_stale", true, false, 0, 0).await;
let cmds = crate::DiagnosticsCommands {
format: Some("cargo fmt".into()),
..Default::default()
};
store
.set_diagnostics("diag_stale", &cmds)
.await
.expect("set_diagnostics");
store
.conn
.execute(
"UPDATE workspaces SET diagnostics_generation = 99 WHERE name = ?1",
crate::turso::params!["diag_stale"],
)
.await
.expect("bump diagnostics_generation");
let stale_gen_val = 1;
let is_ok = check_generation(
&store,
"diag_stale",
stale_gen_val,
GenerationColumn::DIAGNOSTICS,
"test",
)
.await;
assert!(!is_ok, "check_generation should reject stale generation");
let fresh_gen_val = 99;
let is_ok = check_generation(
&store,
"diag_stale",
fresh_gen_val,
GenerationColumn::DIAGNOSTICS,
"test",
)
.await;
assert!(is_ok, "check_generation should accept fresh generation");
}
#[tokio::test]
async fn general_context_roundtrip_single_row_per_workspace() {
let (store, _tmp) = test_store().await;
insert_direct(&store, "gctx", "/tmp/gctx", true, false, 0, 0).await;
assert_eq!(store.get_general_context("gctx").await.unwrap(), None);
store
.set_general_context("gctx", "overview v1")
.await
.unwrap();
store
.set_general_context("gctx", "overview v2")
.await
.unwrap();
assert_eq!(
store.get_general_context("gctx").await.unwrap().as_deref(),
Some("overview v2")
);
let err = store
.conn
.execute(
"INSERT INTO workspace_contexts (workspace_name, role, content, created_at) \
VALUES (?1, NULL, 'dup', ?2)",
crate::turso::params!["gctx", crate::turso::now()],
)
.await
.unwrap_err();
assert!(err.to_string().contains("UNIQUE"), "got: {err}");
store.set_context("gctx", "Manager", "mgr").await.unwrap();
assert_eq!(
store.get_general_context("gctx").await.unwrap().as_deref(),
Some("overview v2")
);
assert_eq!(
store
.get_context("gctx", "Manager")
.await
.unwrap()
.as_deref(),
Some("mgr")
);
insert_direct(&store, "gctx2", "/tmp/gctx2", true, false, 0, 0).await;
store
.set_general_context("gctx2", "other overview")
.await
.unwrap();
assert_eq!(
store.get_general_context("gctx2").await.unwrap().as_deref(),
Some("other overview")
);
}
#[test]
fn nightly_check_hour_before_window() {
assert!(!is_nightly_check_hour(1), "1:00 AM is before the window");
}
#[test]
fn nightly_check_hour_start_inclusive() {
assert!(
is_nightly_check_hour(2),
"2:00 AM is the start of the window"
);
}
#[test]
fn nightly_check_hour_end_exclusive() {
assert!(
!is_nightly_check_hour(3),
"3:00 AM is excluded from the window"
);
}
#[test]
fn nightly_check_hour_after_window() {
assert!(!is_nightly_check_hour(4), "4:00 AM is after the window");
}
#[test]
fn nightly_check_hour_off_hours() {
assert!(!is_nightly_check_hour(0), "Midnight is outside the window");
assert!(!is_nightly_check_hour(12), "Noon is outside the window");
assert!(!is_nightly_check_hour(23), "11 PM is outside the window");
}
#[test]
fn nightly_gate_allows_first_pass() {
assert!(
nightly_gate_allows(None, Utc::now()),
"No recorded pass (first night ever) must be allowed",
);
}
#[test]
fn nightly_gate_allows_exactly_seven_days() {
let now = Utc::now();
let last = (now - chrono::Duration::days(7)).to_rfc3339();
assert!(
nightly_gate_allows(Some(&last), now),
"Exactly 7 days elapsed must be allowed (>= 7 days)",
);
}
#[test]
fn nightly_gate_blocks_before_seven_days() {
let now = Utc::now();
let last = (now - chrono::Duration::days(7) + chrono::Duration::seconds(1)).to_rfc3339();
assert!(
!nightly_gate_allows(Some(&last), now),
"6d23h59m59s elapsed must be blocked",
);
let just_ran = now.to_rfc3339();
assert!(
!nightly_gate_allows(Some(&just_ran), now),
"A just-recorded pass must block",
);
}
#[test]
fn nightly_gate_allows_after_seven_days() {
let now = Utc::now();
let last = (now - chrono::Duration::days(8)).to_rfc3339();
assert!(
nightly_gate_allows(Some(&last), now),
"8 days elapsed must be allowed",
);
}
#[test]
fn nightly_gate_blocks_future_timestamp() {
let now = Utc::now();
let last = (now + chrono::Duration::hours(1)).to_rfc3339();
assert!(
!nightly_gate_allows(Some(&last), now),
"A future timestamp must block the pass",
);
}
#[test]
fn nightly_gate_allows_unparseable_timestamp() {
assert!(
nightly_gate_allows(Some("not-a-timestamp"), Utc::now()),
"An unparseable timestamp must let the pass through",
);
}
#[tokio::test]
async fn nightly_gate_records_pass_start_and_blocks() {
let (store, _tmp) = crate::open_test_store!(crate::config_db::ConfigStore, "config");
assert!(
nightly_gate_should_run(Some(&store)).await,
"First pass (no stored timestamp) must run",
);
assert!(
store
.get_kv(NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY)
.await
.unwrap()
.is_some(),
"Pass start must be recorded in config_kv",
);
assert!(
!nightly_gate_should_run(Some(&store)).await,
"A second pass within the same 7-day window must be blocked",
);
store
.set_kv(NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY, "garbage")
.await
.unwrap();
assert!(
nightly_gate_should_run(Some(&store)).await,
"An unparseable stored value must let the pass through",
);
let healed = store
.get_kv(NIGHTLY_DISCOVERY_LAST_PASS_KV_KEY)
.await
.unwrap()
.expect("pass-start write must store a value");
assert!(
crate::turso::parse_utc_timestamp(&healed).is_ok(),
"pass-start write must self-heal the stored value",
);
}
#[test]
fn new_commits_null_stored_triggers_rediscovery() {
assert!(
has_new_commits(None, "abc123"),
"NULL last_analyzed_commit should trigger rediscovery",
);
}
#[test]
fn new_commits_matching_hash_skips() {
assert!(
!has_new_commits(Some("abc123"), "abc123"),
"Same hash should not trigger rediscovery",
);
}
#[test]
fn new_commits_different_hash_triggers() {
assert!(
has_new_commits(Some("abc123"), "def456"),
"Different hash should trigger rediscovery",
);
}
#[test]
fn new_commits_empty_current_hash_triggers() {
assert!(
has_new_commits(Some("abc123"), ""),
"Empty current hash should trigger rediscovery",
);
}
}