|
| 1 | +# Task Projection Table Design |
| 2 | + |
| 3 | +Status: approved synthesis for implementation. The thread file record remains |
| 4 | +the only source of truth for task data; `task_projection` is a read-only derived |
| 5 | +SQLite projection. |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Garyx tasks are stored as a `task` overlay on normal thread records. Today, |
| 10 | +task listing, number lookup, source/status/assignee filtering, and running |
| 11 | +subtask checks depend on the process-local `TASK_INDEX`. After a gateway |
| 12 | +restart, that index is rebuilt by scanning all thread files. |
| 13 | + |
| 14 | +Add an independent `task_projection` table that stores queryable task business |
| 15 | +fields and allows: |
| 16 | + |
| 17 | +- SQL-backed list, status, assignee, creator, and source filtering. |
| 18 | +- SQL-backed recursive parent/child traversal. |
| 19 | +- Cold-start `TASK_INDEX` bootstrap from SQLite instead of a full file scan. |
| 20 | +- Running-subtask checks that work immediately after restart. |
| 21 | + |
| 22 | +The projection must never become canonical task storage. If projection writes |
| 23 | +fail, the file write still succeeds and backfill/reconcile repairs the derived |
| 24 | +table later. |
| 25 | + |
| 26 | +## Decisions |
| 27 | + |
| 28 | +Use an independent `task_projection` table rather than adding sparse task-only |
| 29 | +columns to `thread_meta`. |
| 30 | + |
| 31 | +Use the existing `RecentThreadProjectingStore` write path. TaskService CRUD and |
| 32 | +bridge task-status transitions already write through the same gateway |
| 33 | +`ThreadStore`; projection writes belong in `project_thread()` and `delete()`, |
| 34 | +not in a new wrapper store or router-to-gateway dependency. |
| 35 | + |
| 36 | +Use versioned backfill plus realtime single-row upsert. Backfill is the only |
| 37 | +production path allowed to scan all thread files, and it runs from gateway |
| 38 | +warmup and reader `is_current` gating. |
| 39 | + |
| 40 | +Keep `TASK_INDEX` for now as a process-local cache and number-allocation guard, |
| 41 | +but bootstrap it from the projection when current. Non-gateway tests and stores |
| 42 | +without a reader continue to use the existing file-scan fallback. |
| 43 | + |
| 44 | +Use router-owned reader traits and a gateway implementation. `garyx-router` |
| 45 | +must not depend on gateway SQLite code. |
| 46 | + |
| 47 | +## Schema |
| 48 | + |
| 49 | +The table keeps flattened filter columns plus canonical JSON strings for |
| 50 | +structured task values that must be reconstructed without loss. |
| 51 | + |
| 52 | +```sql |
| 53 | +CREATE TABLE IF NOT EXISTS task_projection ( |
| 54 | + thread_id TEXT PRIMARY KEY, |
| 55 | + number INTEGER NOT NULL CHECK (number > 0), |
| 56 | + status TEXT NOT NULL CHECK ( |
| 57 | + status IN ('todo', 'in_progress', 'in_review', 'done') |
| 58 | + ), |
| 59 | + title TEXT NOT NULL, |
| 60 | + |
| 61 | + creator_json TEXT NOT NULL, |
| 62 | + creator_id TEXT NOT NULL, |
| 63 | + assignee_json TEXT, |
| 64 | + assignee_id TEXT, |
| 65 | + updated_by_json TEXT NOT NULL, |
| 66 | + executor_json TEXT, |
| 67 | + |
| 68 | + source_json TEXT, |
| 69 | + source_thread_id TEXT, |
| 70 | + source_task_thread_id TEXT, |
| 71 | + source_task_id TEXT COLLATE NOCASE, |
| 72 | + parent_task_number INTEGER CHECK ( |
| 73 | + parent_task_number IS NULL OR parent_task_number > 0 |
| 74 | + ), |
| 75 | + source_bot_id TEXT, |
| 76 | + |
| 77 | + notification_thread_id TEXT, |
| 78 | + |
| 79 | + created_at TEXT NOT NULL, |
| 80 | + updated_at TEXT NOT NULL, |
| 81 | + source_updated_at TEXT NOT NULL, |
| 82 | + source_events_len INTEGER NOT NULL CHECK (source_events_len >= 0), |
| 83 | + |
| 84 | + projection_version INTEGER NOT NULL DEFAULT 1, |
| 85 | + projected_at TEXT NOT NULL |
| 86 | +) STRICT; |
| 87 | +``` |
| 88 | + |
| 89 | +Indexes: |
| 90 | + |
| 91 | +```sql |
| 92 | +CREATE INDEX IF NOT EXISTS idx_task_projection_number |
| 93 | + ON task_projection(number); |
| 94 | + |
| 95 | +CREATE INDEX IF NOT EXISTS idx_task_projection_updated |
| 96 | + ON task_projection(updated_at DESC, thread_id ASC); |
| 97 | + |
| 98 | +CREATE INDEX IF NOT EXISTS idx_task_projection_open_updated |
| 99 | + ON task_projection(updated_at DESC, thread_id ASC) |
| 100 | + WHERE status <> 'done'; |
| 101 | + |
| 102 | +CREATE INDEX IF NOT EXISTS idx_task_projection_status_updated |
| 103 | + ON task_projection(status, updated_at DESC, thread_id ASC); |
| 104 | + |
| 105 | +CREATE INDEX IF NOT EXISTS idx_task_projection_assignee_status_updated |
| 106 | + ON task_projection(assignee_id, status, updated_at DESC, thread_id ASC); |
| 107 | + |
| 108 | +CREATE INDEX IF NOT EXISTS idx_task_projection_creator_status_updated |
| 109 | + ON task_projection(creator_id, status, updated_at DESC, thread_id ASC); |
| 110 | + |
| 111 | +CREATE INDEX IF NOT EXISTS idx_task_projection_source_thread_updated |
| 112 | + ON task_projection(source_thread_id, updated_at DESC, thread_id ASC); |
| 113 | + |
| 114 | +CREATE INDEX IF NOT EXISTS idx_task_projection_source_task_thread_updated |
| 115 | + ON task_projection(source_task_thread_id, updated_at DESC, thread_id ASC); |
| 116 | + |
| 117 | +CREATE INDEX IF NOT EXISTS idx_task_projection_source_task_updated |
| 118 | + ON task_projection(source_task_id, updated_at DESC, thread_id ASC); |
| 119 | + |
| 120 | +CREATE INDEX IF NOT EXISTS idx_task_projection_source_bot_updated |
| 121 | + ON task_projection(source_bot_id, updated_at DESC, thread_id ASC); |
| 122 | + |
| 123 | +CREATE INDEX IF NOT EXISTS idx_task_projection_notification_thread_status |
| 124 | + ON task_projection(notification_thread_id, status, updated_at DESC) |
| 125 | + WHERE status = 'in_progress'; |
| 126 | + |
| 127 | +CREATE INDEX IF NOT EXISTS idx_task_projection_parent_thread_updated |
| 128 | + ON task_projection(source_task_thread_id, updated_at DESC, thread_id ASC); |
| 129 | + |
| 130 | +CREATE INDEX IF NOT EXISTS idx_task_projection_parent_number_updated |
| 131 | + ON task_projection(parent_task_number, updated_at DESC, thread_id ASC); |
| 132 | +``` |
| 133 | + |
| 134 | +`number` is intentionally not unique. The projection is derived data and must |
| 135 | +not let historical drift or concurrent repair prevent `ON CONFLICT(thread_id)` |
| 136 | +upserts. Readers dedupe by `number` and warn on duplicates. |
| 137 | + |
| 138 | +## Draft Construction |
| 139 | + |
| 140 | +Create `garyx-gateway/src/task_projection.rs` with: |
| 141 | + |
| 142 | +```rust |
| 143 | +task_projection_draft_from_thread_data(thread_id, data) -> Option<TaskProjectionDraft> |
| 144 | +backfill_task_projection_if_incomplete(thread_store, garyx_db) -> usize |
| 145 | +``` |
| 146 | + |
| 147 | +Draft construction must parse the record through the typed `ThreadTask` path and |
| 148 | +then serialize structured fields with `serde_json::to_string`. Do not store raw |
| 149 | +`serde_json::Value` subtrees, because legacy aliases and map ordering would |
| 150 | +break canonical equality. |
| 151 | + |
| 152 | +Field mapping: |
| 153 | + |
| 154 | +- `thread_id`: canonical thread key. |
| 155 | +- `number`, `status`, `title`, `created_at`, `updated_at`: from `ThreadTask`. |
| 156 | +- `creator_json`, `assignee_json`, `updated_by_json`, `executor_json`, |
| 157 | + `source_json`: canonical typed JSON. |
| 158 | +- `creator_id`, `assignee_id`: principal id helper columns. |
| 159 | +- `source_thread_id`, `source_task_thread_id`, `source_task_id`: from |
| 160 | + `TaskSource`. |
| 161 | +- `parent_task_number`: parse `source.task_id` with the existing task-id parser. |
| 162 | +- `source_bot_id`: `source.bot_id`, or the existing `channel:account` fallback. |
| 163 | +- `notification_thread_id`: populated only for thread notification targets. |
| 164 | +- `source_updated_at`: task `updated_at`. |
| 165 | +- `source_events_len`: `task.events.len()`. |
| 166 | + |
| 167 | +## Write Semantics |
| 168 | + |
| 169 | +`GaryxDbService` owns: |
| 170 | + |
| 171 | +- `CURRENT_TASK_PROJECTION_VERSION = 1`. |
| 172 | +- `TASK_PROJECTION_NAME = "task_projection"`. |
| 173 | +- `TaskProjectionDraft`. |
| 174 | +- `replace_task_projection`. |
| 175 | +- `remove_task_projection`. |
| 176 | +- `sync_task_projection_snapshot`. |
| 177 | +- `task_projection_needs_backfill`. |
| 178 | +- `count_task_projection`. |
| 179 | +- SQL read methods for index rows, number lookup, list, running-subtask checks, |
| 180 | + subtree, and ancestor traversal. |
| 181 | + |
| 182 | +All upserts, from realtime writes, backfill snapshots, and reconcile, must use |
| 183 | +the same revision guard: |
| 184 | + |
| 185 | +```sql |
| 186 | +ON CONFLICT(thread_id) DO UPDATE SET ... |
| 187 | +WHERE (excluded.source_events_len, excluded.source_updated_at) |
| 188 | + > (task_projection.source_events_len, task_projection.source_updated_at) |
| 189 | +``` |
| 190 | + |
| 191 | +`source_events_len` is the primary monotonic revision. `updated_at` is only a |
| 192 | +tie-break. All mutations that can change a projected field must append a task |
| 193 | +event, including create, assign, unassign, update status, stop, set title, |
| 194 | +mark-in-review, and mark-in-progress-on-wake. Deletion/removal uses tombstone |
| 195 | +compensation and does not rely on events length. |
| 196 | + |
| 197 | +Projection writes run after the file write and must warn, not fail the business |
| 198 | +operation, if SQLite projection fails. |
| 199 | + |
| 200 | +`RecentThreadProjectingStore` must: |
| 201 | + |
| 202 | +- Upsert task projection when `project_thread()` sees a task. |
| 203 | +- Remove task projection when `project_thread()` sees no task. |
| 204 | +- Remove task projection from `delete()`. |
| 205 | +- Remove task projection from archived/deleted projection cleanup helpers. |
| 206 | + |
| 207 | +## Backfill And Reconcile |
| 208 | + |
| 209 | +Backfill state uses `projection_states`: |
| 210 | + |
| 211 | +- Missing state means backfill is required. |
| 212 | +- Version mismatch means backfill is required. |
| 213 | +- Previous `source_row_count > 0` and current table count `0` means backfill is |
| 214 | + required. |
| 215 | +- A zero-task install records `(version=1, source_row_count=0)` and must not |
| 216 | + rescan on every startup. |
| 217 | + |
| 218 | +`source_row_count` records the number of task rows written by the task |
| 219 | +projection backfill, not the number of thread files. |
| 220 | + |
| 221 | +Backfill must be single-flight. The background warmup path and the first request |
| 222 | +that observes `reader.is_current() == false` share the same lock, recheck |
| 223 | +`needs_backfill`, and ensure only one scan runs. |
| 224 | + |
| 225 | +Snapshot writes must not clear the table. They upsert each scanned draft with |
| 226 | +the revision guard and clean only rows whose `projection_version` is not current. |
| 227 | +This prevents a snapshot from deleting current realtime writes. |
| 228 | + |
| 229 | +Realtime deletes during backfill must record tombstones in process memory. |
| 230 | +After the snapshot writes, backfill applies tombstone compensation deletes so |
| 231 | +that a scanned stale row is not resurrected after the file truth removed it. |
| 232 | + |
| 233 | +Task projection reconcile is mandatory: |
| 234 | + |
| 235 | +- It rides the existing `reconcile_active_recent_thread_projection` periodic |
| 236 | + loop and does not introduce a separate cadence. |
| 237 | +- It removes stale rows whose file truth no longer contains a task. |
| 238 | +- It fills missing rows and updates rows whose source revision is behind. |
| 239 | +- It runs immediately once after successful backfill. |
| 240 | + |
| 241 | +## Router Reader |
| 242 | + |
| 243 | +Define in `garyx-router`: |
| 244 | + |
| 245 | +```rust |
| 246 | +#[async_trait] |
| 247 | +pub trait TaskProjectionReader: Send + Sync { |
| 248 | + async fn is_current(&self) -> bool; |
| 249 | + async fn task_index_rows(&self) -> Vec<(u64, String)>; |
| 250 | + async fn thread_id_for_number(&self, number: u64) -> Option<String>; |
| 251 | + async fn has_running_subtask_targeting(&self, thread_id: &str) -> bool; |
| 252 | + async fn list_task_summaries( |
| 253 | + &self, |
| 254 | + filter: &TaskListFilter, |
| 255 | + ) -> Option<(Vec<TaskSummary>, usize, bool)>; |
| 256 | + async fn max_number(&self) -> Option<u64>; |
| 257 | +} |
| 258 | +``` |
| 259 | + |
| 260 | +Reader registration is store-scoped, not process-global: |
| 261 | + |
| 262 | +```rust |
| 263 | +static TASK_PROJECTION_READERS: |
| 264 | + OnceLock<StdMutex<HashMap<usize, Arc<dyn TaskProjectionReader>>>>; |
| 265 | +``` |
| 266 | + |
| 267 | +The key is the same `store_id` used by `TASK_INDEX` (`Arc::as_ptr` partitioning). |
| 268 | +This keeps multiple AppState instances, memory databases, and tests isolated, |
| 269 | +and lets free functions find the reader for their current `ThreadStore`. |
| 270 | + |
| 271 | +`TaskService` keeps a `with_projection_reader` builder for explicit injection, |
| 272 | +but free functions and service methods also look up the store-scoped registry. |
| 273 | +If no reader exists, behavior falls back to the existing file-backed path. |
| 274 | + |
| 275 | +`ensure_task_index` rules: |
| 276 | + |
| 277 | +- Reader exists and `is_current()` is true: load `task_index_rows()` into the |
| 278 | + memory index and mark bootstrapped. |
| 279 | +- Reader exists and is not current: synchronously trigger single-flight |
| 280 | + backfill, then load from projection only if it succeeds and is current. |
| 281 | +- Backfill failure must not mark the memory index bootstrapped. |
| 282 | +- No reader: use the existing full-file scan fallback. |
| 283 | + |
| 284 | +`find_task_by_number` may use `thread_id_for_number`, but it must still read the |
| 285 | +file truth and verify the task number. If the file is stale or mismatched, remove |
| 286 | +the projection row and fall back according to projection state. |
| 287 | + |
| 288 | +`thread_task_has_running_subtasks` must use SQL when a reader exists: |
| 289 | + |
| 290 | +```sql |
| 291 | +status = 'in_progress' |
| 292 | +AND notification_thread_id = ? |
| 293 | +AND thread_id <> ? |
| 294 | +LIMIT 1 |
| 295 | +``` |
| 296 | + |
| 297 | +This is required for bridge/workflow parent-task gating after restart. |
| 298 | + |
| 299 | +## SQL List Semantics |
| 300 | + |
| 301 | +`list_task_summaries` pushes down all existing filters: |
| 302 | + |
| 303 | +- `status`. |
| 304 | +- `include_done=false` as `status <> 'done'`. |
| 305 | +- `assignee` by canonical `assignee_json`. |
| 306 | +- `creator` by canonical `creator_json`. |
| 307 | +- `source_thread_id` matching `source_thread_id OR source_task_thread_id`. |
| 308 | +- `source_task_id` with `COLLATE NOCASE`. |
| 309 | +- `source_bot_id`, including the existing `channel:account` fallback. |
| 310 | + |
| 311 | +List must dedupe duplicate task numbers: |
| 312 | + |
| 313 | +```sql |
| 314 | +ROW_NUMBER() OVER ( |
| 315 | + PARTITION BY number |
| 316 | + ORDER BY updated_at DESC, thread_id ASC |
| 317 | +) = 1 |
| 318 | +``` |
| 319 | + |
| 320 | +`total` and `has_more` are computed after dedupe. Duplicate numbers should log a |
| 321 | +warning. List is intentionally zero-file-read and therefore eventually |
| 322 | +consistent: stale rows may be briefly visible until delete projection, |
| 323 | +single-read cleanup, or periodic reconcile removes them. |
| 324 | + |
| 325 | +`TaskSummary.runtime_agent_id` and `reply_count` come from a `LEFT JOIN` to |
| 326 | +`thread_meta(agent_id, message_count)` and are not duplicated into |
| 327 | +`task_projection`. |
| 328 | + |
| 329 | +## Parent And Ancestor Traversal |
| 330 | + |
| 331 | +Recursive CTEs use `thread_id` as the recursive identity. `source_task_thread_id` |
| 332 | +is the preferred parent pointer; `source_task_id` and `parent_task_number` are |
| 333 | +fallback filters only. CTEs must include depth or visited-path guards, with a |
| 334 | +maximum depth of 64, so malformed self-references cannot loop forever. |
| 335 | + |
| 336 | +Subtree traversal starts at the root task thread id, then follows children whose |
| 337 | +`source_task_thread_id` is the current node thread id, or whose |
| 338 | +`source_task_id` matches the current node task id case-insensitively. |
| 339 | + |
| 340 | +Ancestor traversal starts at the leaf task thread id, then follows |
| 341 | +`source_task_thread_id` or `source_task_id` back to the parent projection row. |
| 342 | + |
| 343 | +## Gateway Integration |
| 344 | + |
| 345 | +Gateway registers a store-scoped reader for each AppState/ThreadStore using the |
| 346 | +same store identity as `TASK_INDEX`. |
| 347 | + |
| 348 | +Reader coverage must include: |
| 349 | + |
| 350 | +- `TaskService` used by task routes. |
| 351 | +- Automation task service creation. |
| 352 | +- Bridge `run_management` free functions. |
| 353 | +- Workflow lifecycle free functions. |
| 354 | + |
| 355 | +`app_state.rs` must run task projection backfill in sync warmup and run one |
| 356 | +task projection reconcile pass immediately after successful backfill. The |
| 357 | +periodic active recent-thread reconcile loop also runs task projection |
| 358 | +reconcile. |
| 359 | + |
| 360 | +## Validation Requirements |
| 361 | + |
| 362 | +Required tests: |
| 363 | + |
| 364 | +- Gateway memory DB DDL, upsert, remove, count, and version backfill predicate. |
| 365 | +- Conditional upsert read-then-write interleaving where a stale snapshot cannot |
| 366 | + overwrite a newer realtime write. |
| 367 | +- Tombstone compensation where a backfill snapshot cannot resurrect a deleted |
| 368 | + task row. |
| 369 | +- SQL list filter pushdown, duplicate-number dedupe, total/has_more after |
| 370 | + dedupe, and stale-row cleanup. |
| 371 | +- Recursive subtree and ancestor CTEs with thread-id identity and cycle defense. |
| 372 | +- Router tests with no reader still pass through the existing file-backed path. |
| 373 | +- First request before warmup triggers single-flight backfill and does not mark |
| 374 | + `TASK_INDEX` bootstrapped on failure. |
| 375 | +- Warmup/write race. |
| 376 | +- Headless cold-start list uses SQL and does not scan all task files after |
| 377 | + clearing the in-memory index. |
| 378 | +- Bridge running-subtask gating works after restart without a warmed |
| 379 | + `TASK_INDEX`. |
| 380 | +- R4 invariant: every mutation that changes projected task fields increments |
| 381 | + `events.len()`. |
| 382 | + |
| 383 | +Before handoff, run focused tests for the touched crates plus `cargo build` and |
| 384 | +`cargo clippy`. |
0 commit comments