Skip to main content

max / goingson

16.5 KB · 501 lines History Blame Raw
1 //! Problems inbox commands.
2 //!
3 //! A problem is a candidate for work pulled from an external source (wam
4 //! tickets, `/audit` and `/fuzz` findings). GoingsOn is the list of solutions;
5 //! these commands are the triage surface that turns a candidate into one.
6 //!
7 //! There is deliberately no `create_problem` here. Problems arrive from
8 //! adapters and from go-mcp's `report_problems`, never by hand in the UI: a
9 //! problem the user typed would have no source to reconcile against, so a
10 //! re-pull could not update or settle it.
11
12 use chrono::{DateTime, Utc};
13 use serde::{Deserialize, Serialize};
14 use std::collections::HashMap;
15 use std::sync::Arc;
16 use tauri::State;
17 use tracing::instrument;
18
19 use goingson_core::{
20 DbValue, NewTask, Priority, Problem, ProblemFilter, ProblemId, ProblemStatus, ProjectId, TaskId,
21 };
22
23 use super::{ApiError, OptionNotFound};
24 use crate::state::{AppState, DESKTOP_USER_ID};
25
26 // Types
27
28 #[derive(Debug, Default, Deserialize)]
29 #[serde(rename_all = "camelCase")]
30 pub struct ProblemFilterInput {
31 pub source: Option<String>,
32 /// `Open` | `Promoted` | `Dismissed` | `Resolved`, or `all` for every state.
33 /// Absent means Open, since the inbox question is what is untriaged.
34 pub status: Option<String>,
35 pub project_id: Option<ProjectId>,
36 }
37
38 #[derive(Debug, Serialize)]
39 #[serde(rename_all = "camelCase")]
40 pub struct ProblemResponse {
41 pub id: ProblemId,
42 pub source: String,
43 pub source_ref: String,
44 pub title: String,
45 pub body: String,
46 pub pain: u8,
47 pub scale: u8,
48 /// The 0-100 urgency score. Computed on read, never stored: an untriaged
49 /// problem climbs on its own, so this moves between one fetch and the next.
50 pub painhours: u32,
51 /// Color band derived from the score: low | medium | high | critical.
52 pub band: String,
53 /// Human-readable age, e.g. `3d`.
54 pub age: String,
55 pub status: String,
56 pub project_id: Option<ProjectId>,
57 pub project_name: Option<String>,
58 pub tags: Vec<String>,
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,
64 }
65
66 #[derive(Debug, Deserialize)]
67 #[serde(rename_all = "camelCase")]
68 pub struct PromoteProblemInput {
69 /// Task description. Defaults to the problem's title, with its body
70 /// appended when there is one.
71 pub description: Option<String>,
72 /// `High` | `Medium` | `Low`. Defaults from the painhours band.
73 pub priority: Option<String>,
74 }
75
76 #[derive(Debug, Serialize)]
77 #[serde(rename_all = "camelCase")]
78 pub struct PromoteProblemResponse {
79 pub task_id: TaskId,
80 pub problem: ProblemResponse,
81 /// False when the problem was already promoted, in which case `task_id` is
82 /// the task it already has.
83 pub created: bool,
84 }
85
86 /// Project the domain type onto the wire shape, resolving the project name and
87 /// staleness the frontend cannot compute for itself.
88 ///
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));
97 ProblemResponse {
98 painhours: p.painhours(),
99 band: p.band().to_string(),
100 age: p.age(),
101 status: p.status.db_value().to_string(),
102 id: p.id,
103 source: p.source,
104 source_ref: p.source_ref,
105 title: p.title,
106 body: p.body,
107 pain: p.pain,
108 scale: p.scale,
109 project_id: p.project_id,
110 project_name,
111 tags: p.tags,
112 promoted_task_id: p.promoted_task_id,
113 stale,
114 }
115 }
116
117 /// Parse the wire status word. `all` (or absent-as-`all`) is the caller's job to
118 /// map to `None`; anything else must be a real variant, since silently falling
119 /// back to `Open` would show a different list than the one asked for.
120 fn parse_status(raw: &str) -> Result<ProblemStatus, ApiError> {
121 raw.parse::<ProblemStatus>().map_err(|_| {
122 ApiError::validation("status", "Expected Open, Promoted, Dismissed, or Resolved")
123 })
124 }
125
126 // Commands
127
128 /// Lists problems, most urgent first.
129 #[tauri::command]
130 #[instrument(skip_all)]
131 pub async fn list_problems(
132 state: State<'_, Arc<AppState>>,
133 filter: Option<ProblemFilterInput>,
134 ) -> Result<Vec<ProblemResponse>, ApiError> {
135 let input = filter.unwrap_or_default();
136
137 let status = match input.status.as_deref().map(str::trim) {
138 Some(s) if s.eq_ignore_ascii_case("all") => None,
139 Some("") => Some(ProblemStatus::Open),
140 Some(s) => Some(parse_status(s)?),
141 None => Some(ProblemStatus::Open),
142 };
143
144 let problems = state
145 .problems
146 .list(
147 DESKTOP_USER_ID,
148 &ProblemFilter {
149 source: input.source,
150 status,
151 project_id: input.project_id,
152 },
153 )
154 .await?;
155
156 // One project lookup for the whole page rather than per row.
157 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
158 let name_of = |id: Option<ProjectId>| {
159 id.and_then(|id| projects.iter().find(|p| p.id == id).map(|p| p.name.clone()))
160 };
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
176 Ok(problems
177 .into_iter()
178 .map(|p| {
179 let name = name_of(p.project_id);
180 let last_pull = last_pulls.get(&p.source).copied().flatten();
181 to_response(p, name, last_pull)
182 })
183 .collect())
184 }
185
186 /// Promotes a problem into a task, linking the two.
187 ///
188 /// The task is created before the backlink is written. The reverse order could
189 /// leave a problem marked Promoted pointing at nothing, which nothing recovers
190 /// from; this way a failure leaves a stray task and an Open problem, and
191 /// retrying is safe.
192 #[tauri::command]
193 #[instrument(skip_all)]
194 pub async fn promote_problem(
195 state: State<'_, Arc<AppState>>,
196 id: ProblemId,
197 input: Option<PromoteProblemInput>,
198 ) -> Result<PromoteProblemResponse, ApiError> {
199 let input = input.unwrap_or(PromoteProblemInput {
200 description: None,
201 priority: None,
202 });
203
204 let problem = state
205 .problems
206 .get_by_id(id, DESKTOP_USER_ID)
207 .await?
208 .or_not_found("Problem", id)?;
209
210 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
211 let project_name = |pid: Option<ProjectId>| {
212 pid.and_then(|pid| {
213 projects
214 .iter()
215 .find(|p| p.id == pid)
216 .map(|p| p.name.clone())
217 })
218 };
219
220 // Idempotent: a retried promote reports the task it already made.
221 if let Some(existing) = problem.promoted_task_id {
222 let name = project_name(problem.project_id);
223 return Ok(PromoteProblemResponse {
224 task_id: existing,
225 problem: to_response(problem, name, None),
226 created: false,
227 });
228 }
229
230 let description = match input.description.as_deref().map(str::trim) {
231 Some(d) if !d.is_empty() => d.to_string(),
232 _ if problem.body.trim().is_empty() => problem.title.clone(),
233 _ => format!("{}\n\n{}", problem.title, problem.body),
234 };
235
236 // The band is the problem's own read on urgency, so it is the sensible
237 // default. Critical and High both land on High: tasks have no fourth level.
238 let priority = match input.priority.as_deref() {
239 Some(p) if !p.trim().is_empty() => Priority::from_str_or_default(p),
240 _ => match problem.band() {
241 painhours::Priority::Critical | painhours::Priority::High => Priority::High,
242 painhours::Priority::Medium => Priority::Medium,
243 painhours::Priority::Low => Priority::Low,
244 },
245 };
246
247 let mut tags = problem.tags.clone();
248 tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
249
250 let mut builder = NewTask::builder(description).priority(priority).tags(tags);
251 if let Some(pid) = problem.project_id {
252 builder = builder.project_id(pid);
253 }
254
255 let task = state.tasks.create(DESKTOP_USER_ID, builder.build()).await?;
256
257 let promoted = state
258 .problems
259 .promote(id, DESKTOP_USER_ID, task.id)
260 .await?
261 .or_not_found("Problem", id)?;
262
263 let name = project_name(promoted.project_id);
264 Ok(PromoteProblemResponse {
265 task_id: task.id,
266 problem: to_response(promoted, name, None),
267 created: true,
268 })
269 }
270
271 /// Sets a problem's triage state.
272 ///
273 /// `Dismissed` is the "seen it, not acting" verdict and is the right way to
274 /// clear something from the inbox: a deleted problem comes straight back on the
275 /// next pull, while a dismissed one stays down. `Open` sends a settled problem
276 /// back to triage, clearing any task backlink.
277 #[tauri::command]
278 #[instrument(skip_all)]
279 pub async fn set_problem_status(
280 state: State<'_, Arc<AppState>>,
281 id: ProblemId,
282 status: String,
283 ) -> Result<ProblemResponse, ApiError> {
284 let status = parse_status(&status)?;
285
286 let updated = state
287 .problems
288 .set_status(id, DESKTOP_USER_ID, status)
289 .await?
290 .or_not_found("Problem", id)?;
291
292 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
293 let name = updated.project_id.and_then(|pid| {
294 projects
295 .iter()
296 .find(|p| p.id == pid)
297 .map(|p| p.name.clone())
298 });
299
300 Ok(to_response(updated, name, None))
301 }
302
303 /// Attributes a problem to a project, or clears the attribution when
304 /// `project_id` is absent.
305 #[tauri::command]
306 #[instrument(skip_all)]
307 pub async fn set_problem_project(
308 state: State<'_, Arc<AppState>>,
309 id: ProblemId,
310 project_id: Option<ProjectId>,
311 ) -> Result<ProblemResponse, ApiError> {
312 let updated = state
313 .problems
314 .set_project(id, DESKTOP_USER_ID, project_id)
315 .await?
316 .or_not_found("Problem", id)?;
317
318 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
319 let name = updated.project_id.and_then(|pid| {
320 projects
321 .iter()
322 .find(|p| p.id == pid)
323 .map(|p| p.name.clone())
324 });
325
326 Ok(to_response(updated, name, None))
327 }
328
329 /// Counts the problems awaiting triage, for the badge on the Problems pill.
330 #[tauri::command]
331 #[instrument(skip_all)]
332 pub async fn count_open_problems(state: State<'_, Arc<AppState>>) -> Result<usize, ApiError> {
333 let problems = state
334 .problems
335 .list(
336 DESKTOP_USER_ID,
337 &ProblemFilter {
338 status: Some(ProblemStatus::Open),
339 ..Default::default()
340 },
341 )
342 .await?;
343 Ok(problems.len())
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 }
501