Skip to main content

max / goingson

Add the wam problem source The pull side of the Problems inbox: a ProblemSource trait and its first implementation, reading wam's GET /tickets. GoingsOn pulls; wam never writes goingson.db, which the repo layout forces and which is the right shape anyway — task creation stays with the side that owns tasks. The trait lives in the desktop crate rather than core because pulling is integration: HTTP, config, and the keychain, none of which core knows about. Core owns the model and the repository. A pull never deletes. A source that goes quiet leaves its problems in place, flagged stale via last_seen_at, because losing triage history to a flaky tailnet would be far worse than showing an old row. That is also what makes Problem::is_stale meaningful, so the list surfaces it now. Tickets carry their own timestamps across the bridge, not the pull time, so a month-old ticket arrives a month old instead of restarting its climb. wam's own source/source_ref are dropped: they record where wam got a ticket, and re-exporting them would confuse its provenance with ours. The endpoint is device-local config (a tailnet address differs per machine); the bearer token is in the OS keychain and is never returned to the webview. Refresh is manual — a poll would add failure modes for a list whose score already moves on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 02:47 UTC
Commit: c93bc6d6051c5e0cc0d9d99ae11ad01b2238363d
Parent: 52a7375
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 + }