max / goingson
8 files changed,
+727 insertions,
-11 deletions
| @@ -204,6 +204,8 @@ | |||
| 204 | 204 | <option value="Resolved">Resolved</option> | |
| 205 | 205 | <option value="all">All</option> | |
| 206 | 206 | </select> | |
| 207 | + | <button class="btn btn-secondary" id="problems-refresh" data-act="problems.pull" title="Pull the latest from every configured source">Refresh</button> | |
| 208 | + | <button class="btn btn-secondary" data-act="problems.configure" title="Configure the wam endpoint">Sources</button> | |
| 207 | 209 | </div> | |
| 208 | 210 | </div> | |
| 209 | 211 | <p class="view-hint">Candidates, not work. Ranked by painhours, so anything left untriaged climbs on its own. Promote the ones worth doing.</p> |
| @@ -96,6 +96,9 @@ const api = { | |||
| 96 | 96 | promote: (id, input) => invoke('promote_problem', { id, input }), // Creates the task + backlink | |
| 97 | 97 | setStatus: (id, status) => invoke('set_problem_status', { id, status }), | |
| 98 | 98 | setProject: (id, projectId) => invoke('set_problem_project', { id, projectId }), | |
| 99 | + | pull: () => invoke('pull_problems'), // Refresh every configured source | |
| 100 | + | getWamConfig: () => invoke('get_wam_config'), | |
| 101 | + | setWamConfig: (url, token) => invoke('set_wam_config', { url, token }), | |
| 99 | 102 | }, | |
| 100 | 103 | ||
| 101 | 104 | // Tasks: CRUD + lifecycle (start/complete) + snooze/waiting status |
| @@ -9,7 +9,9 @@ | |||
| 9 | 9 | //! problem the user typed would have no source to reconcile against, so a | |
| 10 | 10 | //! re-pull could not update or settle it. | |
| 11 | 11 | ||
| 12 | + | use chrono::{DateTime, Utc}; | |
| 12 | 13 | use serde::{Deserialize, Serialize}; | |
| 14 | + | use std::collections::HashMap; | |
| 13 | 15 | use std::sync::Arc; | |
| 14 | 16 | use tauri::State; | |
| 15 | 17 | use tracing::instrument; | |
| @@ -55,6 +57,10 @@ pub struct ProblemResponse { | |||
| 55 | 57 | pub project_name: Option<String>, | |
| 56 | 58 | pub tags: Vec<String>, | |
| 57 | 59 | pub promoted_task_id: Option<TaskId>, | |
| 60 | + | /// The source no longer reports this problem: its `last_seen_at` predates | |
| 61 | + | /// that source's most recent pull. Shown rather than deleted, so a service | |
| 62 | + | /// being briefly unreachable cannot erase triage history. | |
| 63 | + | pub stale: bool, | |
| 58 | 64 | } | |
| 59 | 65 | ||
| 60 | 66 | #[derive(Debug, Deserialize)] | |
| @@ -77,13 +83,17 @@ pub struct PromoteProblemResponse { | |||
| 77 | 83 | pub created: bool, | |
| 78 | 84 | } | |
| 79 | 85 | ||
| 80 | - | /// Project the domain type onto the wire shape, resolving the project name the | |
| 81 | - | /// frontend cannot compute for itself. | |
| 86 | + | /// Project the domain type onto the wire shape, resolving the project name and | |
| 87 | + | /// staleness the frontend cannot compute for itself. | |
| 82 | 88 | /// | |
| 83 | - | /// `Problem::is_stale` is deliberately not surfaced yet: with no pull adapter, | |
| 84 | - | /// every problem's `last_seen_at` equals its source's newest, so the flag would | |
| 85 | - | /// always be false. It belongs here once the wam adapter lands. | |
| 86 | - | fn to_response(p: Problem, project_name: Option<String>) -> ProblemResponse { | |
| 89 | + | /// `source_last_pull` is that source's newest `last_seen_at`; a problem older | |
| 90 | + | /// than it was not seen by the latest pull. | |
| 91 | + | fn to_response( | |
| 92 | + | p: Problem, | |
| 93 | + | project_name: Option<String>, | |
| 94 | + | source_last_pull: Option<DateTime<Utc>>, | |
| 95 | + | ) -> ProblemResponse { | |
| 96 | + | let stale = source_last_pull.is_some_and(|at| p.is_stale(at)); | |
| 87 | 97 | ProblemResponse { | |
| 88 | 98 | painhours: p.painhours(), | |
| 89 | 99 | band: p.band().to_string(), | |
| @@ -100,6 +110,7 @@ fn to_response(p: Problem, project_name: Option<String>) -> ProblemResponse { | |||
| 100 | 110 | project_name, | |
| 101 | 111 | tags: p.tags, | |
| 102 | 112 | promoted_task_id: p.promoted_task_id, | |
| 113 | + | stale, | |
| 103 | 114 | } | |
| 104 | 115 | } | |
| 105 | 116 | ||
| @@ -148,11 +159,26 @@ pub async fn list_problems( | |||
| 148 | 159 | id.and_then(|id| projects.iter().find(|p| p.id == id).map(|p| p.name.clone())) | |
| 149 | 160 | }; | |
| 150 | 161 | ||
| 162 | + | // One last-pull lookup per distinct source, not per row. | |
| 163 | + | let mut last_pulls: HashMap<String, Option<DateTime<Utc>>> = HashMap::new(); | |
| 164 | + | for source in problems | |
| 165 | + | .iter() | |
| 166 | + | .map(|p| p.source.clone()) | |
| 167 | + | .collect::<std::collections::HashSet<_>>() | |
| 168 | + | { | |
| 169 | + | let at = state | |
| 170 | + | .problems | |
| 171 | + | .last_pulled_at(DESKTOP_USER_ID, &source) | |
| 172 | + | .await?; | |
| 173 | + | last_pulls.insert(source, at); | |
| 174 | + | } | |
| 175 | + | ||
| 151 | 176 | Ok(problems | |
| 152 | 177 | .into_iter() | |
| 153 | 178 | .map(|p| { | |
| 154 | 179 | let name = name_of(p.project_id); | |
| 155 | - | to_response(p, name) | |
| 180 | + | let last_pull = last_pulls.get(&p.source).copied().flatten(); | |
| 181 | + | to_response(p, name, last_pull) | |
| 156 | 182 | }) | |
| 157 | 183 | .collect()) | |
| 158 | 184 | } | |
| @@ -196,7 +222,7 @@ pub async fn promote_problem( | |||
| 196 | 222 | let name = project_name(problem.project_id); | |
| 197 | 223 | return Ok(PromoteProblemResponse { | |
| 198 | 224 | task_id: existing, | |
| 199 | - | problem: to_response(problem, name), | |
| 225 | + | problem: to_response(problem, name, None), | |
| 200 | 226 | created: false, | |
| 201 | 227 | }); | |
| 202 | 228 | } | |
| @@ -237,7 +263,7 @@ pub async fn promote_problem( | |||
| 237 | 263 | let name = project_name(promoted.project_id); | |
| 238 | 264 | Ok(PromoteProblemResponse { | |
| 239 | 265 | task_id: task.id, | |
| 240 | - | problem: to_response(promoted, name), | |
| 266 | + | problem: to_response(promoted, name, None), | |
| 241 | 267 | created: true, | |
| 242 | 268 | }) | |
| 243 | 269 | } | |
| @@ -271,7 +297,7 @@ pub async fn set_problem_status( | |||
| 271 | 297 | .map(|p| p.name.clone()) | |
| 272 | 298 | }); | |
| 273 | 299 | ||
| 274 | - | Ok(to_response(updated, name)) | |
| 300 | + | Ok(to_response(updated, name, None)) | |
| 275 | 301 | } | |
| 276 | 302 | ||
| 277 | 303 | /// Attributes a problem to a project, or clears the attribution when | |
| @@ -297,7 +323,7 @@ pub async fn set_problem_project( | |||
| 297 | 323 | .map(|p| p.name.clone()) | |
| 298 | 324 | }); | |
| 299 | 325 | ||
| 300 | - | Ok(to_response(updated, name)) | |
| 326 | + | Ok(to_response(updated, name, None)) | |
| 301 | 327 | } | |
| 302 | 328 | ||
| 303 | 329 | /// Counts the problems awaiting triage, for the badge on the Problems pill. | |
| @@ -316,3 +342,159 @@ pub async fn count_open_problems(state: State<'_, Arc<AppState>>) -> Result<usiz | |||
| 316 | 342 | .await?; | |
| 317 | 343 | Ok(problems.len()) | |
| 318 | 344 | } | |
| 345 | + | ||
| 346 | + | // Source configuration and pulling | |
| 347 | + | ||
| 348 | + | /// `user_config` key holding wam's HTTP root. Device-local: the reachable | |
| 349 | + | /// address for a tailnet service differs per machine (`localhost` on the box | |
| 350 | + | /// running it, a tailnet name elsewhere), so syncing it would hand one device | |
| 351 | + | /// another's endpoint. | |
| 352 | + | const WAM_URL_KEY: &str = "wam_url"; | |
| 353 | + | ||
| 354 | + | /// Keychain key for wam's shared bearer token. A secret, so it never touches | |
| 355 | + | /// `user_config` — that table syncs, and this must not. | |
| 356 | + | const WAM_TOKEN_KEY: &str = "wam:token"; | |
| 357 | + | ||
| 358 | + | #[derive(Debug, Serialize)] | |
| 359 | + | #[serde(rename_all = "camelCase")] | |
| 360 | + | pub struct SourceConfigResponse { | |
| 361 | + | pub url: String, | |
| 362 | + | /// Whether a token is stored. The token itself is never returned: reading | |
| 363 | + | /// it back into the UI would put a secret in the webview for no gain. | |
| 364 | + | pub has_token: bool, | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[derive(Debug, Serialize)] | |
| 368 | + | #[serde(rename_all = "camelCase")] | |
| 369 | + | pub struct PullResponse { | |
| 370 | + | pub source: String, | |
| 371 | + | /// Problems the source reported and we upserted. | |
| 372 | + | pub pulled: usize, | |
| 373 | + | /// Set when the pull failed. The inbox keeps showing what it already had; | |
| 374 | + | /// an unreachable source must never look like an empty one. | |
| 375 | + | pub error: Option<String>, | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// Read the stored wam endpoint, empty when unset. | |
| 379 | + | async fn read_wam_url(state: &AppState) -> Result<String, ApiError> { | |
| 380 | + | let row: Option<(String,)> = sqlx::query_as("SELECT value FROM user_config WHERE key = ?1") | |
| 381 | + | .bind(WAM_URL_KEY) | |
| 382 | + | .fetch_optional(&state.pool) | |
| 383 | + | .await | |
| 384 | + | .map_err(|e| ApiError::internal(format!("Config read failed: {e}")))?; | |
| 385 | + | Ok(row.map(|(v,)| v).unwrap_or_default()) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | /// Returns the configured wam endpoint, if any. | |
| 389 | + | #[tauri::command] | |
| 390 | + | #[instrument(skip_all)] | |
| 391 | + | pub async fn get_wam_config( | |
| 392 | + | state: State<'_, Arc<AppState>>, | |
| 393 | + | ) -> Result<SourceConfigResponse, ApiError> { | |
| 394 | + | let url = read_wam_url(&state).await?; | |
| 395 | + | ||
| 396 | + | Ok(SourceConfigResponse { | |
| 397 | + | url, | |
| 398 | + | has_token: keyring::Entry::new("goingson", WAM_TOKEN_KEY) | |
| 399 | + | .ok() | |
| 400 | + | .and_then(|e| e.get_password().ok()) | |
| 401 | + | .is_some(), | |
| 402 | + | }) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | /// Sets the wam endpoint, and the bearer token when one is supplied. | |
| 406 | + | /// | |
| 407 | + | /// An absent `token` leaves the stored one alone, so saving a changed URL does | |
| 408 | + | /// not silently clear the secret; an empty string deletes it, which is how a | |
| 409 | + | /// user turns auth off. | |
| 410 | + | #[tauri::command] | |
| 411 | + | #[instrument(skip_all)] | |
| 412 | + | pub async fn set_wam_config( | |
| 413 | + | state: State<'_, Arc<AppState>>, | |
| 414 | + | url: String, | |
| 415 | + | token: Option<String>, | |
| 416 | + | ) -> Result<SourceConfigResponse, ApiError> { | |
| 417 | + | sqlx::query( | |
| 418 | + | "INSERT INTO user_config (key, value) VALUES (?1, ?2) \ | |
| 419 | + | ON CONFLICT(key) DO UPDATE SET value = excluded.value", | |
| 420 | + | ) | |
| 421 | + | .bind(WAM_URL_KEY) | |
| 422 | + | .bind(url.trim()) | |
| 423 | + | .execute(&state.pool) | |
| 424 | + | .await | |
| 425 | + | .map_err(|e| ApiError::internal(format!("Config write failed: {e}")))?; | |
| 426 | + | ||
| 427 | + | if let Some(token) = token { | |
| 428 | + | let entry = keyring::Entry::new("goingson", WAM_TOKEN_KEY) | |
| 429 | + | .map_err(|e| ApiError::internal(format!("Keychain unavailable: {e}")))?; | |
| 430 | + | if token.trim().is_empty() { | |
| 431 | + | // NoEntry is success here: the caller asked for no token and there | |
| 432 | + | // is no token. | |
| 433 | + | match entry.delete_credential() { | |
| 434 | + | Ok(()) | Err(keyring::Error::NoEntry) => {} | |
| 435 | + | Err(e) => return Err(ApiError::internal(format!("Keychain write failed: {e}"))), | |
| 436 | + | } | |
| 437 | + | } else { | |
| 438 | + | entry | |
| 439 | + | .set_password(token.trim()) | |
| 440 | + | .map_err(|e| ApiError::internal(format!("Keychain write failed: {e}")))?; | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | get_wam_config(state).await | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | /// Pulls every configured source and upserts what they report. | |
| 448 | + | /// | |
| 449 | + | /// Never deletes. A source that goes quiet leaves its problems in place, marked | |
| 450 | + | /// stale by their `lastSeenAt`, because a service being briefly unreachable must | |
| 451 | + | /// not erase triage history. | |
| 452 | + | #[tauri::command] | |
| 453 | + | #[instrument(skip_all)] | |
| 454 | + | pub async fn pull_problems(state: State<'_, Arc<AppState>>) -> Result<Vec<PullResponse>, ApiError> { | |
| 455 | + | use crate::problems::{ProblemSource, PullError, WamSource}; | |
| 456 | + | ||
| 457 | + | let url = read_wam_url(&state).await?; | |
| 458 | + | let token = keyring::Entry::new("goingson", WAM_TOKEN_KEY) | |
| 459 | + | .ok() | |
| 460 | + | .and_then(|e| e.get_password().ok()); | |
| 461 | + | ||
| 462 | + | let sources: Vec<Box<dyn ProblemSource>> = vec![Box::new(WamSource::new(url, token))]; | |
| 463 | + | ||
| 464 | + | let mut out = Vec::with_capacity(sources.len()); | |
| 465 | + | for source in sources { | |
| 466 | + | let name = source.name().to_string(); | |
| 467 | + | match source.pull().await { | |
| 468 | + | Ok(problems) => { | |
| 469 | + | let mut pulled = 0usize; | |
| 470 | + | for problem in problems { | |
| 471 | + | // One failed row must not abandon the rest of the pull, so | |
| 472 | + | // this logs and carries on rather than bailing. | |
| 473 | + | match state.problems.ingest(DESKTOP_USER_ID, problem).await { | |
| 474 | + | Ok(_) => pulled += 1, | |
| 475 | + | Err(e) => tracing::warn!(source = %name, "ingest failed: {e}"), | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | out.push(PullResponse { | |
| 479 | + | source: name, | |
| 480 | + | pulled, | |
| 481 | + | error: None, | |
| 482 | + | }); | |
| 483 | + | } | |
| 484 | + | // Not configured is not a failure to report: there is simply | |
| 485 | + | // nothing to pull from yet. | |
| 486 | + | Err(PullError::NotConfigured(_)) => out.push(PullResponse { | |
| 487 | + | source: name, | |
| 488 | + | pulled: 0, | |
| 489 | + | error: None, | |
| 490 | + | }), | |
| 491 | + | Err(e) => out.push(PullResponse { | |
| 492 | + | source: name, | |
| 493 | + | pulled: 0, | |
| 494 | + | error: Some(e.to_string()), | |
| 495 | + | }), | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | Ok(out) | |
| 500 | + | } |
| @@ -191,3 +191,149 @@ async fn project_attribution_resolves_to_a_name() { | |||
| 191 | 191 | .expect("problem missing"); | |
| 192 | 192 | assert!(cleared.project_id.is_none()); | |
| 193 | 193 | } | |
| 194 | + | ||
| 195 | + | /// Serve one canned HTTP response on an ephemeral port and return its base URL. | |
| 196 | + | /// | |
| 197 | + | /// A real socket rather than a mocked client: the point is to exercise the | |
| 198 | + | /// actual reqwest call, wam's real JSON shape, and the deserialize, which a | |
| 199 | + | /// hand-built `Ticket` would skip. | |
| 200 | + | async fn stub_wam(body: &'static str) -> String { | |
| 201 | + | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| 202 | + | ||
| 203 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 204 | + | let addr = listener.local_addr().unwrap(); | |
| 205 | + | ||
| 206 | + | tokio::spawn(async move { | |
| 207 | + | if let Ok((mut socket, _)) = listener.accept().await { | |
| 208 | + | let mut buf = [0u8; 2048]; | |
| 209 | + | let _ = socket.read(&mut buf).await; | |
| 210 | + | let response = format!( | |
| 211 | + | "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | |
| 212 | + | body.len(), | |
| 213 | + | body, | |
| 214 | + | ); | |
| 215 | + | let _ = socket.write_all(response.as_bytes()).await; | |
| 216 | + | let _ = socket.shutdown().await; | |
| 217 | + | } | |
| 218 | + | }); | |
| 219 | + | ||
| 220 | + | format!("http://{addr}") | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[tokio::test] | |
| 224 | + | async fn pulling_wam_mirrors_tickets_without_creating_tasks() { | |
| 225 | + | use crate::problems::{ProblemSource, WamSource}; | |
| 226 | + | ||
| 227 | + | let (state, user_id) = setup_test_state().await; | |
| 228 | + | ||
| 229 | + | let url = stub_wam( | |
| 230 | + | r#"{"data":[ | |
| 231 | + | {"id":"t-1","title":"disk filling","body":"astra /var at 94%", | |
| 232 | + | "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1", | |
| 233 | + | "source":"pom","source_ref":"alert-9", | |
| 234 | + | "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z", | |
| 235 | + | "resolved_at":null}, | |
| 236 | + | {"id":"t-2","title":"already handled","body":null, | |
| 237 | + | "pain":2,"scale":2,"status":"closed","channel":"task","node_id":"n1", | |
| 238 | + | "source":null,"source_ref":null, | |
| 239 | + | "created_at":"2026-05-01T12:00:00Z","updated_at":"2026-06-10T12:00:00Z", | |
| 240 | + | "resolved_at":"2026-06-10T12:00:00Z"} | |
| 241 | + | ],"count":2}"#, | |
| 242 | + | ) | |
| 243 | + | .await; | |
| 244 | + | ||
| 245 | + | crate::problems::init_crypto_for_tests(); | |
| 246 | + | let pulled = WamSource::new(url, None).pull().await.expect("pull failed"); | |
| 247 | + | assert_eq!(pulled.len(), 2); | |
| 248 | + | ||
| 249 | + | for problem in pulled { | |
| 250 | + | state.problems.ingest(user_id, problem).await.unwrap(); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // The closed ticket settles on arrival, so only the live one is in the inbox. | |
| 254 | + | let open = state | |
| 255 | + | .problems | |
| 256 | + | .list( | |
| 257 | + | user_id, | |
| 258 | + | &goingson_core::ProblemFilter { | |
| 259 | + | status: Some(ProblemStatus::Open), | |
| 260 | + | ..Default::default() | |
| 261 | + | }, | |
| 262 | + | ) | |
| 263 | + | .await | |
| 264 | + | .unwrap(); | |
| 265 | + | assert_eq!(open.len(), 1); | |
| 266 | + | assert_eq!(open[0].source_ref, "t-1"); | |
| 267 | + | assert_eq!(open[0].source, "wam"); | |
| 268 | + | assert!(open[0].tags.contains(&"channel:system".to_string())); | |
| 269 | + | ||
| 270 | + | let closed = state | |
| 271 | + | .problems | |
| 272 | + | .find_by_source_ref(user_id, "wam", "t-2") | |
| 273 | + | .await | |
| 274 | + | .unwrap() | |
| 275 | + | .expect("closed ticket should still be mirrored"); | |
| 276 | + | assert_eq!(closed.status, ProblemStatus::Resolved); | |
| 277 | + | ||
| 278 | + | // wam's own provenance (it got t-1 from pom) must not become ours. | |
| 279 | + | assert_eq!(open[0].source, "wam"); | |
| 280 | + | ||
| 281 | + | // A month-old ticket arrives already a month old rather than restarting its | |
| 282 | + | // climb, which is what makes the ranking honest across the bridge. | |
| 283 | + | assert!(open[0].age_weeks() > 1); | |
| 284 | + | ||
| 285 | + | // And pulling created no work. | |
| 286 | + | let tasks = state.tasks.list_all(user_id).await.unwrap(); | |
| 287 | + | assert!(tasks.is_empty(), "a pull must never create tasks"); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[tokio::test] | |
| 291 | + | async fn re_pulling_updates_in_place_and_keeps_triage() { | |
| 292 | + | use crate::problems::{ProblemSource, WamSource}; | |
| 293 | + | ||
| 294 | + | let (state, user_id) = setup_test_state().await; | |
| 295 | + | ||
| 296 | + | const TICKET: &str = r#"{"data":[ | |
| 297 | + | {"id":"t-1","title":"disk filling","body":"astra /var at 94%", | |
| 298 | + | "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1", | |
| 299 | + | "source":null,"source_ref":null, | |
| 300 | + | "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z", | |
| 301 | + | "resolved_at":null} | |
| 302 | + | ],"count":1}"#; | |
| 303 | + | ||
| 304 | + | crate::problems::init_crypto_for_tests(); | |
| 305 | + | ||
| 306 | + | for _ in 0..2 { | |
| 307 | + | let url = stub_wam(TICKET).await; | |
| 308 | + | for problem in WamSource::new(url, None).pull().await.unwrap() { | |
| 309 | + | state.problems.ingest(user_id, problem).await.unwrap(); | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | let all = state | |
| 314 | + | .problems | |
| 315 | + | .list(user_id, &goingson_core::ProblemFilter::default()) | |
| 316 | + | .await | |
| 317 | + | .unwrap(); | |
| 318 | + | assert_eq!(all.len(), 1, "the ticket id is the upsert key"); | |
| 319 | + | ||
| 320 | + | // Dismiss it, then pull again: wam still reports it, but the ruling holds. | |
| 321 | + | state | |
| 322 | + | .problems | |
| 323 | + | .set_status(all[0].id, user_id, ProblemStatus::Dismissed) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | ||
| 327 | + | let url = stub_wam(TICKET).await; | |
| 328 | + | for problem in WamSource::new(url, None).pull().await.unwrap() { | |
| 329 | + | state.problems.ingest(user_id, problem).await.unwrap(); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | let after = state | |
| 333 | + | .problems | |
| 334 | + | .find_by_source_ref(user_id, "wam", "t-1") | |
| 335 | + | .await | |
| 336 | + | .unwrap() | |
| 337 | + | .unwrap(); | |
| 338 | + | assert_eq!(after.status, ProblemStatus::Dismissed); | |
| 339 | + | } |
| @@ -44,6 +44,10 @@ pub const CONFIG: ConfigSpec = ConfigSpec::new( | |||
| 44 | 44 | ("ui_mode", Posture::Local), | |
| 45 | 45 | ("welcomed", Posture::Local), | |
| 46 | 46 | ("hint_shortcuts", Posture::Local), | |
| 47 | + | // The wam endpoint: the reachable address for a tailnet service differs | |
| 48 | + | // per machine, so syncing it would hand one device another's endpoint. | |
| 49 | + | // Its bearer token is a secret and lives in the OS keychain, not here. | |
| 50 | + | ("wam_url", Posture::Local), | |
| 47 | 51 | ], | |
| 48 | 52 | ); | |
| 49 | 53 |
| @@ -14,6 +14,7 @@ pub mod export; | |||
| 14 | 14 | pub mod external_sync; | |
| 15 | 15 | pub mod jmap; | |
| 16 | 16 | pub mod oauth; | |
| 17 | + | pub mod problems; | |
| 17 | 18 | pub mod state; | |
| 18 | 19 | pub mod syncstore; | |
| 19 | 20 | pub mod tz; | |
| @@ -111,6 +112,9 @@ macro_rules! all_commands { | |||
| 111 | 112 | $crate::commands::set_problem_status, | |
| 112 | 113 | $crate::commands::set_problem_project, | |
| 113 | 114 | $crate::commands::count_open_problems, | |
| 115 | + | $crate::commands::pull_problems, | |
| 116 | + | $crate::commands::get_wam_config, | |
| 117 | + | $crate::commands::set_wam_config, | |
| 114 | 118 | // Events | |
| 115 | 119 | $crate::commands::list_events, | |
| 116 | 120 | $crate::commands::get_event, |
| @@ -0,0 +1,76 @@ | |||
| 1 | + | //! Problem sources: the pull side of the Problems inbox. | |
| 2 | + | //! | |
| 3 | + | //! GoingsOn pulls; sources never write `goingson.db`. That direction is forced | |
| 4 | + | //! by the repo layout — wam lives in `MNW/`, GoingsOn in `Apps/`, and a | |
| 5 | + | //! `wam promote` writing our database would need a path-dep on `goingson-core` | |
| 6 | + | //! across repos — but it is also the better shape: task creation stays on the | |
| 7 | + | //! side that owns tasks, and a source only has to expose a read surface. | |
| 8 | + | //! | |
| 9 | + | //! A source is defined here rather than in `goingson-core` because pulling is | |
| 10 | + | //! integration, not storage: adapters speak HTTP and read config, neither of | |
| 11 | + | //! which core knows about. Core owns the `Problem` model and the repository; | |
| 12 | + | //! this module owns getting rows into it. | |
| 13 | + | ||
| 14 | + | use async_trait::async_trait; | |
| 15 | + | use goingson_core::NewProblem; | |
| 16 | + | ||
| 17 | + | pub mod wam; | |
| 18 | + | ||
| 19 | + | pub use wam::WamSource; | |
| 20 | + | ||
| 21 | + | /// Install the rustls crypto provider for tests. | |
| 22 | + | /// | |
| 23 | + | /// The app does this at startup (`main` and the mobile entry point); a unit test | |
| 24 | + | /// that builds a reqwest client never runs either, and rustls panics with | |
| 25 | + | /// "No provider set". Idempotent, so every test can call it. | |
| 26 | + | #[cfg(test)] | |
| 27 | + | pub(crate) fn init_crypto_for_tests() { | |
| 28 | + | static ONCE: std::sync::Once = std::sync::Once::new(); | |
| 29 | + | ONCE.call_once(|| { | |
| 30 | + | let _ = rustls::crypto::ring::default_provider().install_default(); | |
| 31 | + | }); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// A read surface over some external system's open problems. | |
| 35 | + | /// | |
| 36 | + | /// One implementation per source. `pull` returns what the source currently | |
| 37 | + | /// reports; reconciling that against what GoingsOn already holds is the | |
| 38 | + | /// repository's job, via the `(source, source_ref)` upsert, so an adapter never | |
| 39 | + | /// has to know what it said last time. | |
| 40 | + | #[async_trait] | |
| 41 | + | pub trait ProblemSource: Send + Sync { | |
| 42 | + | /// The adapter's name, stored on every problem it produces and used to | |
| 43 | + | /// filter the inbox. Must be stable: it is half the provenance key. | |
| 44 | + | fn name(&self) -> &str; | |
| 45 | + | ||
| 46 | + | /// Fetch the source's current problems. | |
| 47 | + | /// | |
| 48 | + | /// Returns everything the source reports, including items it considers | |
| 49 | + | /// resolved (flagged via `resolved_upstream`), so a fix upstream can settle | |
| 50 | + | /// the mirrored row instead of leaving it open forever. | |
| 51 | + | async fn pull(&self) -> Result<Vec<NewProblem>, PullError>; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Why a pull failed. | |
| 55 | + | /// | |
| 56 | + | /// Separated from a generic error string because the inbox reacts differently | |
| 57 | + | /// to each: an unconfigured source is not a problem to report, an unreachable | |
| 58 | + | /// one is worth saying out loud but must not delete anything already mirrored. | |
| 59 | + | #[derive(Debug, thiserror::Error)] | |
| 60 | + | pub enum PullError { | |
| 61 | + | /// The source has no endpoint configured. Not an error condition; the inbox | |
| 62 | + | /// simply has nothing to pull from yet. | |
| 63 | + | #[error("{0} is not configured")] | |
| 64 | + | NotConfigured(&'static str), | |
| 65 | + | ||
| 66 | + | /// The source could not be reached, or refused the request. | |
| 67 | + | /// | |
| 68 | + | /// The adapter name is `name`, not `source`: thiserror reads a field called | |
| 69 | + | /// `source` as the underlying error cause, which a `&str` cannot be. | |
| 70 | + | #[error("{name} unreachable: {message}")] | |
| 71 | + | Unreachable { name: &'static str, message: String }, | |
| 72 | + | ||
| 73 | + | /// The source answered with something this adapter could not read. | |
| 74 | + | #[error("{name} returned an unreadable response: {message}")] | |
| 75 | + | Malformed { name: &'static str, message: String }, | |
| 76 | + | } |
| @@ -0,0 +1,299 @@ | |||
| 1 | + | //! The wam adapter: GoingsOn's first problem source. | |
| 2 | + | //! | |
| 3 | + | //! wam (Whack-a-Mole) is the distributed ticket manager under `MNW/wam`. It | |
| 4 | + | //! keeps its own SQLite store and its own painhours model; this adapter reads | |
| 5 | + | //! `GET /tickets` over its authenticated HTTP API and hands the results to the | |
| 6 | + | //! Problems repository. | |
| 7 | + | //! | |
| 8 | + | //! The two systems already agree on the ranking, because both compute painhours | |
| 9 | + | //! from the shared `painhours` crate. So a wam ticket and an `/audit` finding | |
| 10 | + | //! land in one list ordered by the same rule, which is the whole reason that | |
| 11 | + | //! crate was extracted. | |
| 12 | + | ||
| 13 | + | use async_trait::async_trait; | |
| 14 | + | use chrono::{DateTime, Utc}; | |
| 15 | + | use goingson_core::NewProblem; | |
| 16 | + | use serde::Deserialize; | |
| 17 | + | use tracing::{debug, warn}; | |
| 18 | + | ||
| 19 | + | use super::{ProblemSource, PullError}; | |
| 20 | + | ||
| 21 | + | /// The adapter's stable name. Half of every provenance key it produces, so | |
| 22 | + | /// changing it would orphan every problem already mirrored from wam. | |
| 23 | + | pub const SOURCE: &str = "wam"; | |
| 24 | + | ||
| 25 | + | /// How long to wait on wam before giving up. | |
| 26 | + | /// | |
| 27 | + | /// Short on purpose: wam is a tailnet-local service, and the inbox should tell | |
| 28 | + | /// the user it is unreachable rather than hang on a view refresh. | |
| 29 | + | const TIMEOUT_SECS: u64 = 10; | |
| 30 | + | ||
| 31 | + | /// One ticket as wam serializes it. | |
| 32 | + | /// | |
| 33 | + | /// Only the fields this adapter maps are declared; wam's `node_id` and its own | |
| 34 | + | /// `source`/`source_ref` (which describe where *wam* got the ticket) are | |
| 35 | + | /// deliberately ignored, since re-exporting them would confuse wam's provenance | |
| 36 | + | /// with GoingsOn's. | |
| 37 | + | #[derive(Debug, Deserialize)] | |
| 38 | + | struct Ticket { | |
| 39 | + | id: String, | |
| 40 | + | title: String, | |
| 41 | + | #[serde(default)] | |
| 42 | + | body: Option<String>, | |
| 43 | + | pain: u8, | |
| 44 | + | scale: u8, | |
| 45 | + | /// `open` | `in-progress` | `resolved` | `closed`. | |
| 46 | + | status: String, | |
| 47 | + | /// `system` | `request` | `task`. | |
| 48 | + | #[serde(default)] | |
| 49 | + | channel: Option<String>, | |
| 50 | + | created_at: DateTime<Utc>, | |
| 51 | + | updated_at: DateTime<Utc>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Debug, Deserialize)] | |
| 55 | + | struct ListResponse { | |
| 56 | + | data: Vec<Ticket>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl Ticket { | |
| 60 | + | /// Whether wam considers this ticket finished. | |
| 61 | + | /// | |
| 62 | + | /// Both terminal states settle the mirrored problem. GoingsOn does not | |
| 63 | + | /// distinguish "resolved" from "closed": either way it is no longer a live | |
| 64 | + | /// problem here, and the triage state a human already set wins regardless | |
| 65 | + | /// (the repository's upsert guards that). | |
| 66 | + | fn resolved_upstream(&self) -> bool { | |
| 67 | + | matches!(self.status.as_str(), "resolved" | "closed") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | fn into_new_problem(self) -> NewProblem { | |
| 71 | + | // Read the terminal state before the struct is taken apart below. | |
| 72 | + | let resolved_upstream = self.resolved_upstream(); | |
| 73 | + | // wam's channel is a useful lens once these sit next to audit findings: | |
| 74 | + | // a `system` ticket is an automated alert, a `task` one is a human note. | |
| 75 | + | let mut tags = vec![SOURCE.to_string()]; | |
| 76 | + | if let Some(channel) = &self.channel { | |
| 77 | + | tags.push(format!("channel:{channel}")); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | NewProblem { | |
| 81 | + | source: SOURCE.to_string(), | |
| 82 | + | // wam's ticket id is stable and unique within wam, which is exactly | |
| 83 | + | // what the provenance key needs. | |
| 84 | + | source_ref: self.id, | |
| 85 | + | title: self.title, | |
| 86 | + | body: self.body.unwrap_or_default(), | |
| 87 | + | pain: self.pain, | |
| 88 | + | scale: self.scale, | |
| 89 | + | project_id: None, | |
| 90 | + | tags, | |
| 91 | + | // wam's timestamps, not ours. Age drives the score, so a ticket | |
| 92 | + | // filed weeks ago must not read as new because we pulled it today. | |
| 93 | + | created_at: self.created_at, | |
| 94 | + | updated_at: self.updated_at, | |
| 95 | + | resolved_upstream, | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Reads open problems from a wam instance. | |
| 101 | + | pub struct WamSource { | |
| 102 | + | base_url: String, | |
| 103 | + | token: Option<String>, | |
| 104 | + | client: reqwest::Client, | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | impl WamSource { | |
| 108 | + | /// Build an adapter for a wam instance. | |
| 109 | + | /// | |
| 110 | + | /// `base_url` is wam's HTTP root (e.g. `http://fw13:7890`); a trailing slash | |
| 111 | + | /// is tolerated. `token` is the shared bearer wam enforces when it was | |
| 112 | + | /// started with one. Passing `None` is allowed because wam also runs | |
| 113 | + | /// unauthenticated, and it warns loudly on its own side when it does; a | |
| 114 | + | /// mismatched token surfaces here as a 401. | |
| 115 | + | pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self { | |
| 116 | + | let base_url = base_url.into().trim_end_matches('/').to_string(); | |
| 117 | + | let client = reqwest::Client::builder() | |
| 118 | + | .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) | |
| 119 | + | .build() | |
| 120 | + | .unwrap_or_default(); | |
| 121 | + | Self { | |
| 122 | + | base_url, | |
| 123 | + | token, | |
| 124 | + | client, | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[async_trait] | |
| 130 | + | impl ProblemSource for WamSource { | |
| 131 | + | fn name(&self) -> &str { | |
| 132 | + | SOURCE | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | async fn pull(&self) -> Result<Vec<NewProblem>, PullError> { | |
| 136 | + | if self.base_url.is_empty() { | |
| 137 | + | return Err(PullError::NotConfigured(SOURCE)); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // No status filter: terminal tickets have to come back too, or a ticket | |
| 141 | + | // fixed in wam would sit open in the inbox forever with nothing to | |
| 142 | + | // settle it. | |
| 143 | + | let url = format!("{}/tickets", self.base_url); | |
| 144 | + | let mut request = self.client.get(&url); | |
| 145 | + | if let Some(token) = &self.token { | |
| 146 | + | request = request.bearer_auth(token); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | let response = request.send().await.map_err(|e| PullError::Unreachable { | |
| 150 | + | name: SOURCE, | |
| 151 | + | message: e.to_string(), | |
| 152 | + | })?; | |
| 153 | + | ||
| 154 | + | let status = response.status(); | |
| 155 | + | if !status.is_success() { | |
| 156 | + | return Err(PullError::Unreachable { | |
| 157 | + | name: SOURCE, | |
| 158 | + | message: if status == reqwest::StatusCode::UNAUTHORIZED { | |
| 159 | + | "unauthorized (check the wam token)".to_string() | |
| 160 | + | } else { | |
| 161 | + | format!("HTTP {status}") | |
| 162 | + | }, | |
| 163 | + | }); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | let body: ListResponse = response.json().await.map_err(|e| PullError::Malformed { | |
| 167 | + | name: SOURCE, | |
| 168 | + | message: e.to_string(), | |
| 169 | + | })?; | |
| 170 | + | ||
| 171 | + | debug!(count = body.data.len(), "pulled tickets from wam"); | |
| 172 | + | ||
| 173 | + | // A ticket whose factors are out of range still ranks (the model clamps | |
| 174 | + | // on use), so nothing is dropped here. Warn instead, since it means the | |
| 175 | + | // producer is writing something wam itself did not intend. | |
| 176 | + | for t in &body.data { | |
| 177 | + | if !(1..=5).contains(&t.pain) || !(1..=5).contains(&t.scale) { | |
| 178 | + | warn!( | |
| 179 | + | ticket = %t.id, | |
| 180 | + | pain = t.pain, | |
| 181 | + | scale = t.scale, | |
| 182 | + | "wam ticket has out-of-range painhours factors; clamping" | |
| 183 | + | ); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | Ok(body | |
| 188 | + | .data | |
| 189 | + | .into_iter() | |
| 190 | + | .map(Ticket::into_new_problem) | |
| 191 | + | .collect()) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[cfg(test)] | |
| 196 | + | mod tests { | |
| 197 | + | use super::*; | |
| 198 | + | ||
| 199 | + | fn ticket(status: &str) -> Ticket { | |
| 200 | + | Ticket { | |
| 201 | + | id: "abc123".into(), | |
| 202 | + | title: "sync drops attachments".into(), | |
| 203 | + | body: Some("seen on two devices".into()), | |
| 204 | + | pain: 4, | |
| 205 | + | scale: 3, | |
| 206 | + | status: status.into(), | |
| 207 | + | channel: Some("system".into()), | |
| 208 | + | created_at: Utc::now() - chrono::Duration::days(30), | |
| 209 | + | updated_at: Utc::now(), | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[test] | |
| 214 | + | fn both_terminal_states_settle_the_mirror() { | |
| 215 | + | assert!(!ticket("open").resolved_upstream()); | |
| 216 | + | assert!(!ticket("in-progress").resolved_upstream()); | |
| 217 | + | assert!(ticket("resolved").resolved_upstream()); | |
| 218 | + | assert!(ticket("closed").resolved_upstream()); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn mapping_preserves_the_source_timestamps_and_factors() { | |
| 223 | + | let t = ticket("open"); | |
| 224 | + | let created = t.created_at; | |
| 225 | + | let p = t.into_new_problem(); | |
| 226 | + | ||
| 227 | + | assert_eq!(p.source, "wam"); | |
| 228 | + | assert_eq!(p.source_ref, "abc123"); | |
| 229 | + | assert_eq!(p.pain, 4); | |
| 230 | + | assert_eq!(p.scale, 3); | |
| 231 | + | // The ticket's own age, not the pull time: a month-old ticket must | |
| 232 | + | // arrive already a month old, or it would restart its climb on import. | |
| 233 | + | assert_eq!(p.created_at, created); | |
| 234 | + | assert!(p.tags.contains(&"wam".to_string())); | |
| 235 | + | assert!(p.tags.contains(&"channel:system".to_string())); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn a_channelless_ticket_still_maps() { | |
| 240 | + | let mut t = ticket("open"); | |
| 241 | + | t.channel = None; | |
| 242 | + | let p = t.into_new_problem(); | |
| 243 | + | assert_eq!(p.tags, vec!["wam".to_string()]); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[test] | |
| 247 | + | fn base_url_tolerates_a_trailing_slash() { | |
| 248 | + | super::super::init_crypto_for_tests(); | |
| 249 | + | let a = WamSource::new("http://fw13:7890/", None); | |
| 250 | + | let b = WamSource::new("http://fw13:7890", None); | |
| 251 | + | assert_eq!(a.base_url, b.base_url); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[tokio::test] | |
| 255 | + | async fn an_empty_url_is_unconfigured_not_an_error() { | |
| 256 | + | super::super::init_crypto_for_tests(); | |
| 257 | + | let source = WamSource::new("", None); | |
| 258 | + | match source.pull().await { | |
| 259 | + | Err(PullError::NotConfigured("wam")) => {} | |
| 260 | + | other => panic!("expected NotConfigured, got {other:?}"), | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn list_response_parses_wam_json() { | |
| 266 | + | // The exact shape wam's GET /tickets returns. | |
| 267 | + | let raw = r#"{ | |
| 268 | + | "data": [{ | |
| 269 | + | "id": "t-1", | |
| 270 | + | "title": "disk filling", | |
| 271 | + | "body": null, | |
| 272 | + | "pain": 5, | |
| 273 | + | "scale": 5, | |
| 274 | + | "status": "open", | |
| 275 | + | "channel": "system", | |
| 276 | + | "node_id": "node-a", | |
| 277 | + | "source": "pom", | |
| 278 | + | "source_ref": "alert-9", | |
| 279 | + | "created_at": "2026-07-01T12:00:00Z", | |
| 280 | + | "updated_at": "2026-07-02T12:00:00Z", | |
| 281 | + | "resolved_at": null | |
| 282 | + | }], | |
| 283 | + | "count": 1 | |
| 284 | + | }"#; | |
| 285 | + | ||
| 286 | + | let parsed: ListResponse = serde_json::from_str(raw).expect("wam JSON must parse"); | |
| 287 | + | assert_eq!(parsed.data.len(), 1); | |
| 288 | + | ||
| 289 | + | let p = parsed.data.into_iter().next().unwrap().into_new_problem(); | |
| 290 | + | assert_eq!(p.source_ref, "t-1"); | |
| 291 | + | assert_eq!( | |
| 292 | + | p.body, "", | |
| 293 | + | "a null body maps to empty, not the string 'null'" | |
| 294 | + | ); | |
| 295 | + | // wam's own source/source_ref describe where WAM got it; they must not | |
| 296 | + | // leak into GoingsOn's provenance. | |
| 297 | + | assert_eq!(p.source, "wam"); | |
| 298 | + | } | |
| 299 | + | } |