Skip to main content

max / goingson

21.7 KB · 538 lines History Blame Raw
1 //! Problem tools: `list_problems` (read), `report_problems` (write, batch
2 //! ingest), `promote_problem` (write, problem to task), and `update_problem`
3 //! (write, triage state and project attribution).
4 //!
5 //! These are the surface `/audit`, `/fuzz`, and `deepaudit` write findings
6 //! through. Before this existed, each run left a markdown file under
7 //! `_private/docs/<project>/`, which is the scatter the 2026-07-24 cleanup
8 //! deleted; a finding now lands as a row that ranks alongside every other
9 //! problem instead of a file nobody re-reads.
10 //!
11 //! The split between reporting and promoting is the whole model: a problem is a
12 //! candidate, and only [`PromoteProblem`] turns one into a task. A skill can
13 //! report freely without filling the task list with work nobody chose.
14 //!
15 //! Ingest is idempotent on `(source, source_ref)`, so re-running an audit
16 //! refreshes its findings rather than duplicating them, and re-reporting
17 //! something already promoted or dismissed does not undo that decision.
18
19 use std::collections::HashMap;
20 use std::sync::Arc;
21
22 use async_trait::async_trait;
23 use chrono::Utc;
24 use goingson_core::models::DbValue;
25 use goingson_core::repository::{ProblemRepository, ProjectRepository, TaskCrud};
26 use goingson_core::{
27 NewProblem, NewTask, Priority, ProblemFilter, ProblemId, ProblemStatus, ProjectId,
28 };
29 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
30 use serde_json::{Value, json};
31 use uuid::Uuid;
32
33 use crate::caps;
34 use crate::context::Ctx;
35 use crate::convert::{parse_enum, parse_tags, req_str};
36
37 /// The accepted wire words for a problem's triage state.
38 const PROBLEM_STATUSES: &[&str] = &["Open", "Promoted", "Dismissed", "Resolved"];
39
40 /// The source name a tool reports under when it does not say. Skills should
41 /// always pass their own (`audit`, `fuzz`, `deepaudit`) so findings can be
42 /// filtered and re-pulled per lens.
43 const DEFAULT_SOURCE: &str = "mcp";
44
45 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
46 Error::ToolFailed {
47 tool: tool.to_string(),
48 message: e.to_string(),
49 }
50 }
51
52 /// Parse a 1-5 painhours factor, defaulting to 3 when absent.
53 ///
54 /// Out-of-range values are an error rather than a clamp: a caller that wrote 8
55 /// meant something by it, and silently scoring it as 5 would misrank the
56 /// finding without saying so. (The storage layer still clamps, because a row
57 /// already on disk must never fail a read.)
58 fn parse_factor(tool: &str, args: &Value, field: &str) -> std::result::Result<u8, Error> {
59 let Some(raw) = args.get(field) else {
60 return Ok(3);
61 };
62 if raw.is_null() {
63 return Ok(3);
64 }
65 raw.as_u64()
66 .filter(|n| (1..=5).contains(n))
67 .map(|n| n as u8)
68 .ok_or_else(|| Error::InvalidArgs {
69 tool: tool.to_string(),
70 message: format!("`{field}` must be an integer 1-5 (got `{raw}`)"),
71 })
72 }
73
74 /// Parse a problem id string into a [`ProblemId`], or an `InvalidArgs`.
75 fn parse_problem_id(tool: &str, s: &str) -> std::result::Result<ProblemId, Error> {
76 Uuid::parse_str(s.trim())
77 .map(ProblemId::from_uuid)
78 .map_err(|_| Error::InvalidArgs {
79 tool: tool.to_string(),
80 message: format!("`{s}` is not a valid problem id (expected a UUID)"),
81 })
82 }
83
84 /// Compact JSON projection of a problem for the read tools.
85 ///
86 /// `painhours` and `band` are computed at projection time, not stored, so a row
87 /// read twice a month apart reports two different scores. That is the model
88 /// working: an untouched problem gets more urgent.
89 fn problem_row(p: &goingson_core::Problem) -> Value {
90 json!({
91 "id": p.id.to_string(),
92 "source": p.source,
93 "source_ref": p.source_ref,
94 "title": p.title,
95 "body": p.body,
96 "pain": p.pain,
97 "scale": p.scale,
98 "painhours": p.painhours(),
99 "band": p.band().to_string(),
100 "age": p.age(),
101 "status": p.status.db_value(),
102 "project_id": p.project_id.map(|id: ProjectId| id.to_string()),
103 "tags": p.tags,
104 "promoted_task_id": p.promoted_task_id.map(|id| id.to_string()),
105 })
106 }
107
108 pub struct ListProblems(pub Arc<Ctx>);
109
110 #[async_trait]
111 impl Tool for ListProblems {
112 fn name(&self) -> &str {
113 "list_problems"
114 }
115 fn description(&self) -> &str {
116 "List problems, most urgent first. A problem is a candidate for work pulled from a source (wam tickets, audit and fuzz findings); it is not a task until promoted. Ranked by `painhours`, a 0-100 score computed from pain, scale, and age, so an untriaged problem climbs on its own. Filters: `source`, `status` (Open|Promoted|Dismissed|Resolved), `project_id`. Defaults to Open only; pass status explicitly to see settled ones."
117 }
118 fn kind(&self) -> ToolKind {
119 ToolKind::Read
120 }
121 fn small_model_safe(&self) -> bool {
122 true
123 }
124 fn input_schema(&self) -> Value {
125 json!({
126 "type": "object",
127 "properties": {
128 "source": { "type": "string", "description": "Restrict to one source, e.g. wam, audit, fuzz." },
129 "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved. Omit for Open only; pass `all` for every status." },
130 "project_id": { "type": "string" }
131 }
132 })
133 }
134 async fn call(&self, args: Value) -> Result<ToolCallResult> {
135 // Default to Open: the inbox question is "what is untriaged", and a
136 // settled problem is answered work. `all` is the explicit escape.
137 let status = match args.get("status").and_then(Value::as_str) {
138 Some(s) if s.trim().eq_ignore_ascii_case("all") => None,
139 Some(s) => Some(parse_enum::<ProblemStatus>(
140 self.name(),
141 "status",
142 s,
143 PROBLEM_STATUSES,
144 )?),
145 None => Some(ProblemStatus::Open),
146 };
147
148 let project_id = match args.get("project_id").and_then(Value::as_str) {
149 Some(s) => Some(
150 Uuid::parse_str(s.trim())
151 .map(ProjectId::from_uuid)
152 .map_err(|_| Error::InvalidArgs {
153 tool: self.name().to_string(),
154 message: format!("`{s}` is not a valid project id (expected a UUID)"),
155 })?,
156 ),
157 None => None,
158 };
159
160 let filter = ProblemFilter {
161 source: args
162 .get("source")
163 .and_then(Value::as_str)
164 .map(str::to_string),
165 status,
166 project_id,
167 };
168
169 let problems = self
170 .0
171 .problems()
172 .list(self.0.user_id, &filter)
173 .await
174 .map_err(|e| fail(self.name(), e))?;
175
176 let rows: Vec<Value> = problems.iter().map(problem_row).collect();
177 Ok(ToolCallResult::text(
178 serde_json::to_string(&json!({ "count": rows.len(), "problems": rows })).unwrap(),
179 ))
180 }
181 }
182
183 pub struct ReportProblems(pub Arc<Ctx>);
184
185 #[async_trait]
186 impl Tool for ReportProblems {
187 fn name(&self) -> &str {
188 "report_problems"
189 }
190 fn description(&self) -> &str {
191 "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working."
192 }
193 fn kind(&self) -> ToolKind {
194 ToolKind::Write(caps::problem_report())
195 }
196 fn input_schema(&self) -> Value {
197 json!({
198 "type": "object",
199 "properties": {
200 "source": { "type": "string", "description": "Which lens is reporting: audit, fuzz, deepaudit. Defaults to `mcp`." },
201 "problems": {
202 "type": "array",
203 "items": {
204 "type": "object",
205 "properties": {
206 "title": { "type": "string", "description": "One line stating the problem." },
207 "body": { "type": "string", "description": "Detail: where it is, why it matters, how it fails." },
208 "pain": { "type": "integer", "description": "1-5, how much it hurts a hit user. Default 3." },
209 "scale": { "type": "integer", "description": "1-5, how broadly it hits. Default 3." },
210 "source_ref": { "type": "string", "description": "Stable id for this finding within the source (e.g. module:check). Defaults to the title, so a re-run with the same title updates in place." },
211 "project": { "type": "string", "description": "Project name; created if absent." },
212 "tags": { "type": "array", "items": { "type": "string" } },
213 "resolved": { "type": "boolean", "description": "The source considers this fixed. Settles an untriaged problem; leaves a promoted or dismissed one alone." }
214 },
215 "required": ["title"]
216 }
217 }
218 },
219 "required": ["problems"]
220 })
221 }
222 async fn call(&self, args: Value) -> Result<ToolCallResult> {
223 let items = args
224 .get("problems")
225 .and_then(Value::as_array)
226 .ok_or_else(|| Error::InvalidArgs {
227 tool: self.name().to_string(),
228 message: "missing array field `problems`".into(),
229 })?;
230
231 let source = args
232 .get("source")
233 .and_then(Value::as_str)
234 .map(str::trim)
235 .filter(|s| !s.is_empty())
236 .unwrap_or(DEFAULT_SOURCE)
237 .to_string();
238
239 let repo = self.0.problems();
240 let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
241 let mut reported = Vec::new();
242
243 for (idx, item) in items.iter().enumerate() {
244 let title = item
245 .get("title")
246 .and_then(Value::as_str)
247 .map(str::trim)
248 .filter(|s| !s.is_empty())
249 .ok_or_else(|| Error::InvalidArgs {
250 tool: self.name().to_string(),
251 message: format!("problems[{idx}] is missing a non-empty `title`"),
252 })?
253 .to_string();
254
255 // Falling back to the title keeps a skill that did not think about
256 // ids idempotent anyway: report the same finding twice and it
257 // updates in place.
258 let source_ref = item
259 .get("source_ref")
260 .and_then(Value::as_str)
261 .map(str::trim)
262 .filter(|s| !s.is_empty())
263 .unwrap_or(&title)
264 .to_string();
265
266 let project_id = match item.get("project").and_then(Value::as_str) {
267 Some(name) if !name.trim().is_empty() => Some(
268 resolve_project_cached(&self.0, name, &mut project_cache)
269 .await
270 .map_err(|e| fail(self.name(), e))?,
271 ),
272 _ => None,
273 };
274
275 let now = Utc::now();
276 let problem = repo
277 .ingest(
278 self.0.user_id,
279 NewProblem {
280 source: source.clone(),
281 source_ref,
282 title,
283 body: item
284 .get("body")
285 .and_then(Value::as_str)
286 .unwrap_or_default()
287 .to_string(),
288 pain: parse_factor(self.name(), item, "pain")?,
289 scale: parse_factor(self.name(), item, "scale")?,
290 project_id,
291 tags: parse_tags(item.get("tags")),
292 created_at: now,
293 updated_at: now,
294 resolved_upstream: item
295 .get("resolved")
296 .and_then(Value::as_bool)
297 .unwrap_or(false),
298 },
299 )
300 .await
301 .map_err(|e| fail(self.name(), e))?;
302
303 reported.push(problem);
304 }
305
306 // `ingest` upserts, so "new" is what the caller cares about: how much of
307 // this run was actually new versus a refresh of what was already known.
308 let new_count = reported
309 .iter()
310 .filter(|p| p.status == ProblemStatus::Open && p.promoted_task_id.is_none())
311 .count();
312
313 Ok(ToolCallResult::text(
314 serde_json::to_string(&json!({
315 "reported": reported.len(),
316 "open": new_count,
317 "problems": reported.iter().map(problem_row).collect::<Vec<_>>(),
318 }))
319 .unwrap(),
320 ))
321 }
322 }
323
324 pub struct PromoteProblem(pub Arc<Ctx>);
325
326 #[async_trait]
327 impl Tool for PromoteProblem {
328 fn name(&self) -> &str {
329 "promote_problem"
330 }
331 fn description(&self) -> &str {
332 "Turn a problem into a task: creates the task, links it back to the problem, and marks the problem Promoted so it stops climbing the ranking. `description` defaults to the problem's title and body. The task inherits the problem's project and tags plus a `problem:<source>:<ref>` provenance tag. This is the only path from a problem to a task; reporting one never creates work on its own."
333 }
334 fn kind(&self) -> ToolKind {
335 ToolKind::Write(caps::problem_promote())
336 }
337 fn input_schema(&self) -> Value {
338 json!({
339 "type": "object",
340 "properties": {
341 "id": { "type": "string", "description": "The problem's id, from list_problems." },
342 "description": { "type": "string", "description": "Task description. Defaults to the problem's title, with its body appended." },
343 "priority": { "type": "string", "description": "High | Medium | Low. Defaults from the problem's painhours band." }
344 },
345 "required": ["id"]
346 })
347 }
348 async fn call(&self, args: Value) -> Result<ToolCallResult> {
349 let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?;
350 let repo = self.0.problems();
351
352 let problem = repo
353 .get_by_id(id, self.0.user_id)
354 .await
355 .map_err(|e| fail(self.name(), e))?
356 .ok_or_else(|| Error::ToolFailed {
357 tool: self.name().to_string(),
358 message: format!("no problem with id `{id}`"),
359 })?;
360
361 if let Some(existing) = problem.promoted_task_id {
362 // Idempotent rather than an error: a retried promote should report
363 // the task it already made, not make a second one.
364 return Ok(ToolCallResult::text(
365 serde_json::to_string(&json!({
366 "task_id": existing.to_string(),
367 "problem_id": problem.id.to_string(),
368 "created": false,
369 }))
370 .unwrap(),
371 ));
372 }
373
374 let description = match args.get("description").and_then(Value::as_str) {
375 Some(d) if !d.trim().is_empty() => d.trim().to_string(),
376 _ if problem.body.trim().is_empty() => problem.title.clone(),
377 _ => format!("{}\n\n{}", problem.title, problem.body),
378 };
379
380 // The band is the problem's own judgement of urgency, so it is the
381 // sensible default priority. Critical and High both map to High:
382 // GoingsOn tasks have no fourth level.
383 let priority = match args.get("priority").and_then(Value::as_str) {
384 Some(p) => Priority::from_str_or_default(p),
385 None => match problem.band() {
386 painhours::Priority::Critical | painhours::Priority::High => Priority::High,
387 painhours::Priority::Medium => Priority::Medium,
388 painhours::Priority::Low => Priority::Low,
389 },
390 };
391
392 let mut tags = problem.tags.clone();
393 tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
394
395 let mut builder = NewTask::builder(description).priority(priority).tags(tags);
396 if let Some(pid) = problem.project_id {
397 builder = builder.project_id(pid);
398 }
399
400 let task = self
401 .0
402 .tasks()
403 .create(self.0.user_id, builder.build())
404 .await
405 .map_err(|e| fail(self.name(), e))?;
406
407 // Link second: if this fails the task still exists and the problem
408 // stays Open, so a retry finds no backlink and is safe to run again.
409 // The reverse order could mark a problem promoted with no task behind
410 // it, which is the state nothing recovers from.
411 repo.promote(problem.id, self.0.user_id, task.id)
412 .await
413 .map_err(|e| fail(self.name(), e))?;
414
415 Ok(ToolCallResult::text(
416 serde_json::to_string(&json!({
417 "task_id": task.id.to_string(),
418 "problem_id": problem.id.to_string(),
419 "created": true,
420 }))
421 .unwrap(),
422 ))
423 }
424 }
425
426 /// Named `...Tool` to match `UpdateProjectTool` and `UpdateTaskTool`.
427 pub struct UpdateProblemTool(pub Arc<Ctx>);
428
429 #[async_trait]
430 impl Tool for UpdateProblemTool {
431 fn name(&self) -> &str {
432 "update_problem"
433 }
434 fn description(&self) -> &str {
435 "Update a problem's triage state or project. `status` is Open|Promoted|Dismissed|Resolved: Dismissed is the 'seen it, not acting' verdict, and Open sends a settled problem back to the inbox (clearing any task backlink). Prefer dismissing over deleting, so a re-pull does not resurrect something already ruled on. Use promote_problem to create a task; setting status Promoted here only marks it, without one."
436 }
437 fn kind(&self) -> ToolKind {
438 ToolKind::Write(caps::problem_update())
439 }
440 fn input_schema(&self) -> Value {
441 json!({
442 "type": "object",
443 "properties": {
444 "id": { "type": "string" },
445 "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved" },
446 "project": { "type": "string", "description": "Project name to attribute this problem to; created if absent. Pass an empty string to clear." }
447 },
448 "required": ["id"]
449 })
450 }
451 async fn call(&self, args: Value) -> Result<ToolCallResult> {
452 let id = parse_problem_id(self.name(), req_str(self.name(), &args, "id")?)?;
453 let repo = self.0.problems();
454
455 let mut current = repo
456 .get_by_id(id, self.0.user_id)
457 .await
458 .map_err(|e| fail(self.name(), e))?
459 .ok_or_else(|| Error::ToolFailed {
460 tool: self.name().to_string(),
461 message: format!("no problem with id `{id}`"),
462 })?;
463
464 if let Some(raw) = args.get("project").and_then(Value::as_str) {
465 let project_id = if raw.trim().is_empty() {
466 None
467 } else {
468 let mut cache = HashMap::new();
469 Some(
470 resolve_project_cached(&self.0, raw, &mut cache)
471 .await
472 .map_err(|e| fail(self.name(), e))?,
473 )
474 };
475 current = repo
476 .set_project(id, self.0.user_id, project_id)
477 .await
478 .map_err(|e| fail(self.name(), e))?
479 .ok_or_else(|| Error::ToolFailed {
480 tool: self.name().to_string(),
481 message: format!("problem `{id}` vanished during update"),
482 })?;
483 }
484
485 if let Some(raw) = args.get("status").and_then(Value::as_str) {
486 let status = parse_enum(self.name(), "status", raw, PROBLEM_STATUSES)?;
487 current = repo
488 .set_status(id, self.0.user_id, status)
489 .await
490 .map_err(|e| fail(self.name(), e))?
491 .ok_or_else(|| Error::ToolFailed {
492 tool: self.name().to_string(),
493 message: format!("problem `{id}` vanished during update"),
494 })?;
495 }
496
497 Ok(ToolCallResult::text(
498 serde_json::to_string(&problem_row(&current)).unwrap(),
499 ))
500 }
501 }
502
503 /// Resolve a project name to an id, creating it if absent, with a within-call
504 /// cache so a batch referencing one project repeatedly hits the database once.
505 ///
506 /// Mirrors `task.rs`'s helper of the same shape. Kept separate rather than
507 /// shared because the task module's version is private to it and a cross-module
508 /// `pub(crate)` here would make two unrelated tool families share a seam neither
509 /// needs.
510 async fn resolve_project_cached(
511 ctx: &Ctx,
512 name: &str,
513 cache: &mut HashMap<String, ProjectId>,
514 ) -> goingson_core::repository::Result<ProjectId> {
515 if let Some(id) = cache.get(name) {
516 return Ok(*id);
517 }
518 let repo = ctx.projects();
519 let id = match repo.find_by_name(ctx.user_id, name).await? {
520 Some(existing) => existing.id,
521 None => {
522 repo.create(
523 ctx.user_id,
524 goingson_core::NewProject {
525 name: name.to_string(),
526 description: String::new(),
527 project_type: goingson_core::ProjectType::SideProject,
528 status: goingson_core::ProjectStatus::default(),
529 },
530 )
531 .await?
532 .id
533 }
534 };
535 cache.insert(name.to_string(), id);
536 Ok(id)
537 }
538