//! Problems inbox commands. //! //! A problem is a candidate for work pulled from an external source (wam //! tickets, `/audit` and `/fuzz` findings). GoingsOn is the list of solutions; //! these commands are the triage surface that turns a candidate into one. //! //! There is deliberately no `create_problem` here. Problems arrive from //! adapters and from go-mcp's `report_problems`, never by hand in the UI: a //! problem the user typed would have no source to reconcile against, so a //! re-pull could not update or settle it. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ DbValue, NewTask, Priority, Problem, ProblemFilter, ProblemId, ProblemStatus, ProjectId, TaskId, }; use super::{ApiError, OptionNotFound}; use crate::state::{AppState, DESKTOP_USER_ID}; // Types #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProblemFilterInput { pub source: Option, /// `Open` | `Promoted` | `Dismissed` | `Resolved`, or `all` for every state. /// Absent means Open, since the inbox question is what is untriaged. pub status: Option, pub project_id: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProblemResponse { pub id: ProblemId, pub source: String, pub source_ref: String, pub title: String, pub body: String, pub pain: u8, pub scale: u8, /// The 0-100 urgency score. Computed on read, never stored: an untriaged /// problem climbs on its own, so this moves between one fetch and the next. pub painhours: u32, /// Color band derived from the score: low | medium | high | critical. pub band: String, /// Human-readable age, e.g. `3d`. pub age: String, pub status: String, pub project_id: Option, pub project_name: Option, pub tags: Vec, pub promoted_task_id: Option, /// The source no longer reports this problem: its `last_seen_at` predates /// that source's most recent pull. Shown rather than deleted, so a service /// being briefly unreachable cannot erase triage history. pub stale: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PromoteProblemInput { /// Task description. Defaults to the problem's title, with its body /// appended when there is one. pub description: Option, /// `High` | `Medium` | `Low`. Defaults from the painhours band. pub priority: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PromoteProblemResponse { pub task_id: TaskId, pub problem: ProblemResponse, /// False when the problem was already promoted, in which case `task_id` is /// the task it already has. pub created: bool, } /// Project the domain type onto the wire shape, resolving the project name and /// staleness the frontend cannot compute for itself. /// /// `source_last_pull` is that source's newest `last_seen_at`; a problem older /// than it was not seen by the latest pull. fn to_response( p: Problem, project_name: Option, source_last_pull: Option>, ) -> ProblemResponse { let stale = source_last_pull.is_some_and(|at| p.is_stale(at)); ProblemResponse { painhours: p.painhours(), band: p.band().to_string(), age: p.age(), status: p.status.db_value().to_string(), id: p.id, source: p.source, source_ref: p.source_ref, title: p.title, body: p.body, pain: p.pain, scale: p.scale, project_id: p.project_id, project_name, tags: p.tags, promoted_task_id: p.promoted_task_id, stale, } } /// Parse the wire status word. `all` (or absent-as-`all`) is the caller's job to /// map to `None`; anything else must be a real variant, since silently falling /// back to `Open` would show a different list than the one asked for. fn parse_status(raw: &str) -> Result { raw.parse::().map_err(|_| { ApiError::validation("status", "Expected Open, Promoted, Dismissed, or Resolved") }) } // Commands /// Lists problems, most urgent first. #[tauri::command] #[instrument(skip_all)] pub async fn list_problems( state: State<'_, Arc>, filter: Option, ) -> Result, ApiError> { let input = filter.unwrap_or_default(); let status = match input.status.as_deref().map(str::trim) { Some(s) if s.eq_ignore_ascii_case("all") => None, Some("") => Some(ProblemStatus::Open), Some(s) => Some(parse_status(s)?), None => Some(ProblemStatus::Open), }; let problems = state .problems .list( DESKTOP_USER_ID, &ProblemFilter { source: input.source, status, project_id: input.project_id, }, ) .await?; // One project lookup for the whole page rather than per row. let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let name_of = |id: Option| { id.and_then(|id| projects.iter().find(|p| p.id == id).map(|p| p.name.clone())) }; // One last-pull lookup per distinct source, not per row. let mut last_pulls: HashMap>> = HashMap::new(); for source in problems .iter() .map(|p| p.source.clone()) .collect::>() { let at = state .problems .last_pulled_at(DESKTOP_USER_ID, &source) .await?; last_pulls.insert(source, at); } Ok(problems .into_iter() .map(|p| { let name = name_of(p.project_id); let last_pull = last_pulls.get(&p.source).copied().flatten(); to_response(p, name, last_pull) }) .collect()) } /// Promotes a problem into a task, linking the two. /// /// The task is created before the backlink is written. The reverse order could /// leave a problem marked Promoted pointing at nothing, which nothing recovers /// from; this way a failure leaves a stray task and an Open problem, and /// retrying is safe. #[tauri::command] #[instrument(skip_all)] pub async fn promote_problem( state: State<'_, Arc>, id: ProblemId, input: Option, ) -> Result { let input = input.unwrap_or(PromoteProblemInput { description: None, priority: None, }); let problem = state .problems .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("Problem", id)?; let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let project_name = |pid: Option| { pid.and_then(|pid| { projects .iter() .find(|p| p.id == pid) .map(|p| p.name.clone()) }) }; // Idempotent: a retried promote reports the task it already made. if let Some(existing) = problem.promoted_task_id { let name = project_name(problem.project_id); return Ok(PromoteProblemResponse { task_id: existing, problem: to_response(problem, name, None), created: false, }); } let description = match input.description.as_deref().map(str::trim) { Some(d) if !d.is_empty() => d.to_string(), _ if problem.body.trim().is_empty() => problem.title.clone(), _ => format!("{}\n\n{}", problem.title, problem.body), }; // The band is the problem's own read on urgency, so it is the sensible // default. Critical and High both land on High: tasks have no fourth level. let priority = match input.priority.as_deref() { Some(p) if !p.trim().is_empty() => Priority::from_str_or_default(p), _ => match problem.band() { painhours::Priority::Critical | painhours::Priority::High => Priority::High, painhours::Priority::Medium => Priority::Medium, painhours::Priority::Low => Priority::Low, }, }; let mut tags = problem.tags.clone(); tags.push(format!("problem:{}:{}", problem.source, problem.source_ref)); let mut builder = NewTask::builder(description).priority(priority).tags(tags); if let Some(pid) = problem.project_id { builder = builder.project_id(pid); } let task = state.tasks.create(DESKTOP_USER_ID, builder.build()).await?; let promoted = state .problems .promote(id, DESKTOP_USER_ID, task.id) .await? .or_not_found("Problem", id)?; let name = project_name(promoted.project_id); Ok(PromoteProblemResponse { task_id: task.id, problem: to_response(promoted, name, None), created: true, }) } /// Sets a problem's triage state. /// /// `Dismissed` is the "seen it, not acting" verdict and is the right way to /// clear something from the inbox: a deleted problem comes straight back on the /// next pull, while a dismissed one stays down. `Open` sends a settled problem /// back to triage, clearing any task backlink. #[tauri::command] #[instrument(skip_all)] pub async fn set_problem_status( state: State<'_, Arc>, id: ProblemId, status: String, ) -> Result { let status = parse_status(&status)?; let updated = state .problems .set_status(id, DESKTOP_USER_ID, status) .await? .or_not_found("Problem", id)?; let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let name = updated.project_id.and_then(|pid| { projects .iter() .find(|p| p.id == pid) .map(|p| p.name.clone()) }); Ok(to_response(updated, name, None)) } /// Attributes a problem to a project, or clears the attribution when /// `project_id` is absent. #[tauri::command] #[instrument(skip_all)] pub async fn set_problem_project( state: State<'_, Arc>, id: ProblemId, project_id: Option, ) -> Result { let updated = state .problems .set_project(id, DESKTOP_USER_ID, project_id) .await? .or_not_found("Problem", id)?; let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let name = updated.project_id.and_then(|pid| { projects .iter() .find(|p| p.id == pid) .map(|p| p.name.clone()) }); Ok(to_response(updated, name, None)) } /// Counts the problems awaiting triage, for the badge on the Problems pill. #[tauri::command] #[instrument(skip_all)] pub async fn count_open_problems(state: State<'_, Arc>) -> Result { let problems = state .problems .list( DESKTOP_USER_ID, &ProblemFilter { status: Some(ProblemStatus::Open), ..Default::default() }, ) .await?; Ok(problems.len()) } // Source configuration and pulling /// `user_config` key holding wam's HTTP root. Device-local: the reachable /// address for a tailnet service differs per machine (`localhost` on the box /// running it, a tailnet name elsewhere), so syncing it would hand one device /// another's endpoint. const WAM_URL_KEY: &str = "wam_url"; /// Keychain key for wam's shared bearer token. A secret, so it never touches /// `user_config` — that table syncs, and this must not. const WAM_TOKEN_KEY: &str = "wam:token"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceConfigResponse { pub url: String, /// Whether a token is stored. The token itself is never returned: reading /// it back into the UI would put a secret in the webview for no gain. pub has_token: bool, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PullResponse { pub source: String, /// Problems the source reported and we upserted. pub pulled: usize, /// Set when the pull failed. The inbox keeps showing what it already had; /// an unreachable source must never look like an empty one. pub error: Option, } /// Read the stored wam endpoint, empty when unset. async fn read_wam_url(state: &AppState) -> Result { let row: Option<(String,)> = sqlx::query_as("SELECT value FROM user_config WHERE key = ?1") .bind(WAM_URL_KEY) .fetch_optional(&state.pool) .await .map_err(|e| ApiError::internal(format!("Config read failed: {e}")))?; Ok(row.map(|(v,)| v).unwrap_or_default()) } /// Returns the configured wam endpoint, if any. #[tauri::command] #[instrument(skip_all)] pub async fn get_wam_config( state: State<'_, Arc>, ) -> Result { let url = read_wam_url(&state).await?; Ok(SourceConfigResponse { url, has_token: keyring::Entry::new("goingson", WAM_TOKEN_KEY) .ok() .and_then(|e| e.get_password().ok()) .is_some(), }) } /// Sets the wam endpoint, and the bearer token when one is supplied. /// /// An absent `token` leaves the stored one alone, so saving a changed URL does /// not silently clear the secret; an empty string deletes it, which is how a /// user turns auth off. #[tauri::command] #[instrument(skip_all)] pub async fn set_wam_config( state: State<'_, Arc>, url: String, token: Option, ) -> Result { sqlx::query( "INSERT INTO user_config (key, value) VALUES (?1, ?2) \ ON CONFLICT(key) DO UPDATE SET value = excluded.value", ) .bind(WAM_URL_KEY) .bind(url.trim()) .execute(&state.pool) .await .map_err(|e| ApiError::internal(format!("Config write failed: {e}")))?; if let Some(token) = token { let entry = keyring::Entry::new("goingson", WAM_TOKEN_KEY) .map_err(|e| ApiError::internal(format!("Keychain unavailable: {e}")))?; if token.trim().is_empty() { // NoEntry is success here: the caller asked for no token and there // is no token. match entry.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => {} Err(e) => return Err(ApiError::internal(format!("Keychain write failed: {e}"))), } } else { entry .set_password(token.trim()) .map_err(|e| ApiError::internal(format!("Keychain write failed: {e}")))?; } } get_wam_config(state).await } /// Pulls every configured source and upserts what they report. /// /// Never deletes. A source that goes quiet leaves its problems in place, marked /// stale by their `lastSeenAt`, because a service being briefly unreachable must /// not erase triage history. #[tauri::command] #[instrument(skip_all)] pub async fn pull_problems(state: State<'_, Arc>) -> Result, ApiError> { use crate::problems::{ProblemSource, PullError, WamSource}; let url = read_wam_url(&state).await?; let token = keyring::Entry::new("goingson", WAM_TOKEN_KEY) .ok() .and_then(|e| e.get_password().ok()); let sources: Vec> = vec![Box::new(WamSource::new(url, token))]; let mut out = Vec::with_capacity(sources.len()); for source in sources { let name = source.name().to_string(); match source.pull().await { Ok(problems) => { let mut pulled = 0usize; for problem in problems { // One failed row must not abandon the rest of the pull, so // this logs and carries on rather than bailing. match state.problems.ingest(DESKTOP_USER_ID, problem).await { Ok(_) => pulled += 1, Err(e) => tracing::warn!(source = %name, "ingest failed: {e}"), } } out.push(PullResponse { source: name, pulled, error: None, }); } // Not configured is not a failure to report: there is simply // nothing to pull from yet. Err(PullError::NotConfigured(_)) => out.push(PullResponse { source: name, pulled: 0, error: None, }), Err(e) => out.push(PullResponse { source: name, pulled: 0, error: Some(e.to_string()), }), } } Ok(out) }