Skip to main content

max / goingson

Add the problems table and repository The storage half of the Problems inbox. Problems are a mirror of external sources (wam tickets, /audit and /fuzz findings) that a human promotes into tasks; GoingsOn stays the list of solutions. Ingest upserts on (source, source_ref) and refreshes only what the source owns, so re-pulling never undoes a triage decision. The one exception is a source reporting a fix, which settles an untriaged row but leaves an already promoted or dismissed one alone. Ranking is painhours from the shared crate, computed rather than stored because it moves with the clock, so list() sorts in Rust as wam does. Settling freezes the age anchor at settled_at, which stops promoted and dismissed problems from climbing. The table is backed up rather than excluded: the mirrored fields re-pull on their own, but dismissals, promotions, and the task backlink do not. That bumps the export format to 1.4. It stays out of the SyncKit manifest, where replicating a regenerable mirror would buy nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 01:15 UTC
Signed with PGP, not checked
Commit: 86cc60dee711468e3f89be898bdb88f901f74309
Parent: b8e66fb
25 files changed, +1299 insertions, -26 deletions
M Cargo.lock +9
@@ -2224,6 +2224,7 @@
2224 2224 "async-trait",
2225 2225 "chrono",
2226 2226 "chrono-tz",
2227 + "painhours",
2227 2228 "serde",
2228 2229 "serde_json",
2229 2230 "sha2 0.11.0",
@@ -4074,6 +4075,14 @@
4074 4075 "thiserror 2.0.18",
4075 4076 ]
4076 4077
4078 + [[package]]
4079 + name = "painhours"
4080 + version = "0.1.0"
4081 + dependencies = [
4082 + "chrono",
4083 + "serde",
4084 + ]
4085 +
4077 4086 [[package]]
4078 4087 name = "palette"
4079 4088 version = "0.7.6"
M Cargo.toml +3
@@ -112,6 +112,9 @@
112 112 # Tag standard
113 113 tagtree = { path = "../../MNW/shared/tagtree" }
114 114
115 + # Shared urgency model (also used by MNW/wam), so problems and tickets rank alike
116 + painhours = { path = "../../MNW/shared/painhours" }
117 +
115 118 # MCP bridge (go-mcp)
116 119 kberg = { path = "../../MNW/shared/kberg", default-features = false, features = ["server"] }
117 120
@@ -18,6 +18,7 @@
18 18 strum_macros = { workspace = true }
19 19 sqlx = { workspace = true, features = ["sqlite", "uuid"], optional = true }
20 20 tagtree = { workspace = true }
21 + painhours = { workspace = true }
21 22 sha2 = { workspace = true }
22 23
23 24 [lints]
@@ -61,3 +61,4 @@
61 61 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512
62 62 059 32140c4ee5bb75b9d6530a0b7c60cef872acc5fa50bb969eb9368036131d4f84931cddbcf6f8b41038fb0d5fc175af7b
63 63 060 e5b833cff6c768710b0daf88fc9cf087938278ec158c99c0789242878832e802ae00e82bef2cc45efe21644de8d9fa57
64 + 061 8a37aa99045c49df8e54d36227702e68f570abb870114da14b31760cd0a1433cdd8f0d234808ec0d0da22672bc7fcb11
@@ -55,6 +55,12 @@
55 55 let weekly_reviews = state.weekly_reviews.list_all(user_id).await?;
56 56 let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?;
57 57 let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?;
58 + // Unfiltered: a backup captures settled problems too, since dismissals and
59 + // promotions are the part that cannot be re-pulled.
60 + let problems = state
61 + .problems
62 + .list(user_id, &goingson_core::ProblemFilter::default())
63 + .await?;
58 64 Ok(FullExport::new(
59 65 projects,
60 66 tasks,
@@ -70,6 +76,7 @@
70 76 weekly_reviews,
71 77 monthly_goals,
72 78 monthly_reflections,
79 + problems,
73 80 ))
74 81 }
75 82
@@ -3,16 +3,17 @@
3 3 use goingson_core::{
4 4 AttachmentRepository, BackupSettingsRepository, ContactRepository, DailyNoteRepository,
5 5 EmailAccountRepository, EmailRepository, EventRepository, MilestoneRepository,
6 - MonthlyReviewRepository, ProjectRepository, SavedViewRepository, SearchRepository,
7 - StatsRepository, SyncAccountRepository, TaskRepository, WeeklyReviewRepository,
6 + MonthlyReviewRepository, ProblemRepository, ProjectRepository, SavedViewRepository,
7 + SearchRepository, StatsRepository, SyncAccountRepository, TaskRepository,
8 + WeeklyReviewRepository,
8 9 };
9 10 use goingson_db_sqlite::{
10 11 SqliteAttachmentRepository, SqliteBackupSettingsRepository, SqliteContactRepository,
11 12 SqliteDailyNoteRepository, SqliteEmailAccountRepository, SqliteEmailRepository,
12 13 SqliteEventRepository, SqliteMilestoneRepository, SqliteMonthlyReviewRepository,
13 - SqliteProjectRepository, SqliteSavedViewRepository, SqliteSearchRepository,
14 - SqliteStatsRepository, SqliteSyncAccountRepository, SqliteTaskRepository,
15 - SqliteWeeklyReviewRepository,
14 + SqliteProblemRepository, SqliteProjectRepository, SqliteSavedViewRepository,
15 + SqliteSearchRepository, SqliteStatsRepository, SqliteSyncAccountRepository,
16 + SqliteTaskRepository, SqliteWeeklyReviewRepository,
16 17 };
17 18 use sqlx::SqlitePool;
18 19 use std::path::PathBuf;
@@ -43,6 +44,7 @@
43 44 pub stats: Arc<dyn StatsRepository>,
44 45 pub search: Arc<dyn SearchRepository>,
45 46 pub milestones: Arc<dyn MilestoneRepository>,
47 + pub problems: Arc<dyn ProblemRepository>,
46 48 pub saved_views: Arc<dyn SavedViewRepository>,
47 49 pub weekly_reviews: Arc<dyn WeeklyReviewRepository>,
48 50 pub monthly_reviews: Arc<dyn MonthlyReviewRepository>,
@@ -143,6 +145,7 @@
143 145 let stats = Arc::new(SqliteStatsRepository::new(pool.clone()));
144 146 let search = Arc::new(SqliteSearchRepository::new(pool.clone()));
145 147 let milestones = Arc::new(SqliteMilestoneRepository::new(pool.clone()));
148 + let problems = Arc::new(SqliteProblemRepository::new(pool.clone()));
146 149 let saved_views = Arc::new(SqliteSavedViewRepository::new(pool.clone()));
147 150 let weekly_reviews = Arc::new(SqliteWeeklyReviewRepository::new(pool.clone()));
148 151 let monthly_reviews = Arc::new(SqliteMonthlyReviewRepository::new(pool.clone()));
@@ -195,6 +198,7 @@
195 198 stats,
196 199 search,
197 200 milestones,
201 + problems,
198 202 saved_views,
199 203 weekly_reviews,
200 204 monthly_reviews,
@@ -55,6 +55,9 @@
55 55
56 56 let state = AppState {
57 57 pool: pool.clone(),
58 + problems: Arc::new(goingson_db_sqlite::SqliteProblemRepository::new(
59 + pool.clone(),
60 + )),
58 61 projects: Arc::new(SqliteProjectRepository::new(pool.clone())),
59 62 tasks: Arc::new(SqliteTaskRepository::new(pool.clone())),
60 63 events: Arc::new(SqliteEventRepository::new(pool.clone())),
@@ -8,8 +8,8 @@
8 8
9 9 use crate::Contact;
10 10 use crate::models::{
11 - Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project,
12 - SavedView, SyncAccount, Task, TimeSession, WeeklyReview,
11 + Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Problem,
12 + Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview,
13 13 };
14 14
15 15 /// Result of a restore operation.
@@ -49,6 +49,10 @@
49 49 pub monthly_goals_restored: usize,
50 50 /// Number of monthly reflections restored.
51 51 pub monthly_reflections_restored: usize,
52 + /// Number of problems restored. The mirrored rows re-pull on their own, but
53 + /// the triage state on them (dismissed, promoted, and the task backlink)
54 + /// does not, so problems are backed up rather than excluded.
55 + pub problems_restored: usize,
52 56 }
53 57
54 58 /// Pre-parsed backup data for restoration.
@@ -67,6 +71,7 @@
67 71 pub weekly_reviews: Vec<WeeklyReview>,
68 72 pub monthly_goals: Vec<MonthlyGoal>,
69 73 pub monthly_reflections: Vec<MonthlyReflection>,
74 + pub problems: Vec<Problem>,
70 75 }
71 76
72 77 #[cfg(test)]
@@ -99,6 +104,7 @@
99 104 weekly_reviews: vec![],
100 105 monthly_goals: vec![],
101 106 monthly_reflections: vec![],
107 + problems: vec![],
102 108 };
103 109 assert!(input.projects.is_empty());
104 110 assert!(input.tasks.is_empty());