code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/dao.py ADDED
@@ -0,0 +1,483 @@
1
+ """Storage-agnostic data-access layer for the coordinator board (#584/#589).
2
+
3
+ ``CoordStore`` is the seam that lets the ``coord serve`` daemon front the board
4
+ without callers knowing the storage engine — and makes a future Postgres backend
5
+ (#282) a contained swap rather than a rewrite. This module implements the
6
+ **read** side fully (the portable control center's read path) and declares the
7
+ **write** side for #590.
8
+
9
+ ``SqliteStore`` is the concrete SQLite implementation. It owns its *own*
10
+ read-only connection (NOT ``coord.db``'s read/write singleton): it opens the DB
11
+ with ``mode=ro`` + ``PRAGMA query_only`` and never runs schema/migration DDL, so
12
+ it is safe to point at a live ``coord.db`` that the coordinator process is
13
+ writing in WAL mode.
14
+
15
+ All SQLite idioms (``sqlite3.Row``, JSON-encoded TEXT columns, the ``mode=ro``
16
+ URI) are encapsulated here: read methods return plain Python dicts with JSON
17
+ columns decoded to native lists/objects, so neither the wire format nor a future
18
+ non-SQLite backend inherits any SQLite-only idiom.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sqlite3
26
+ import time
27
+ from contextlib import closing
28
+ from pathlib import Path
29
+ from typing import Any, Protocol, runtime_checkable
30
+
31
+ from coord.db import DB_PATH
32
+
33
+ # Bump when the /board payload shape changes incompatibly. Clients may branch
34
+ # on this; today everything is additive.
35
+ SCHEMA_VERSION = 1
36
+
37
+ # #762: terminal assignment statuses. Anything NOT in this set (running /
38
+ # pending) is "in-flight" and always kept on the board projection.
39
+ TERMINAL_STATUSES = frozenset({"done", "merged", "failed", "cancelled", "advisory"})
40
+
41
+ # #762: how many days of *terminal* assignment history the /board projection
42
+ # carries. The board grew unbounded (1209 assignments / 4.37 MB) and overran
43
+ # the TUI's fetch timeout, blanking the whole board. Cap the wire to active +
44
+ # pipeline-referenced + this many days of terminal rows. 0 disables the cap
45
+ # (serve everything — the pre-#762 behaviour).
46
+ _DEFAULT_BOARD_RETENTION_DAYS = 14
47
+
48
+
49
+ def _board_retention_days() -> int:
50
+ try:
51
+ return int(os.environ.get("COORD_BOARD_RETENTION_DAYS", _DEFAULT_BOARD_RETENTION_DAYS))
52
+ except (TypeError, ValueError):
53
+ return _DEFAULT_BOARD_RETENTION_DAYS
54
+
55
+
56
+ # #1037: recency window (seconds) behind the /board `audit_recent_count`
57
+ # summary field — see SqliteStore._audit_recent_count.
58
+ _AUDIT_RECENT_WINDOW_SECONDS = 900
59
+
60
+
61
+ def _board_retention_cutoff(now: float | None = None) -> float | None:
62
+ """Unix-timestamp floor for terminal rows on the board, or None when the
63
+ cap is disabled (``COORD_BOARD_RETENTION_DAYS=0``)."""
64
+ days = _board_retention_days()
65
+ if days <= 0:
66
+ return None
67
+ return (time.time() if now is None else now) - days * 86400.0
68
+
69
+ # JSON-encoded columns per table — decoded to native objects on read so no
70
+ # SQLite idiom (JSON-in-TEXT) leaks past the DAO. Columns added by later ALTER
71
+ # migrations are picked up automatically via ``SELECT *``; only JSON ones need
72
+ # listing here.
73
+ _JSON_COLUMNS: dict[str, set[str]] = {
74
+ "assignments": {
75
+ "files_allowed",
76
+ "files_forbidden",
77
+ "required_gates",
78
+ "plan",
79
+ "smoke_tests",
80
+ "test_plan",
81
+ # NOTE: review_findings is deliberately NOT decoded — the coord-tui
82
+ # client consumes it as a raw JSON string (Option<String>), so it must
83
+ # stay a string on the wire.
84
+ },
85
+ "proposals": {"files_likely", "required_gates"},
86
+ "merge_queue": {"required_gates"},
87
+ "issues": {"labels"},
88
+ "machines": {"capabilities", "repos"},
89
+ # #1753: the drive queue's pre-req list — ["repo#N", ...] on the wire, a
90
+ # JSON string in SQLite.
91
+ "drive_queue": {"after_json"},
92
+ }
93
+
94
+ # Columns omitted from the board projection. ``assignments.briefing`` is ~8 MB
95
+ # of an ~12 MB live payload and is NOT part of the board view (the TUI's board
96
+ # query never selects it; the Python mapper defaults it to ""), so dropping it
97
+ # keeps refreshes fast over Tailscale. A per-assignment endpoint can serve full
98
+ # briefings later if a detail view needs them.
99
+ _DROP_COLUMNS: dict[str, set[str]] = {
100
+ "assignments": {"briefing"},
101
+ }
102
+
103
+
104
+ @runtime_checkable
105
+ class CoordStore(Protocol):
106
+ """Read/write interface over coordinator board state.
107
+
108
+ The read methods are implemented today. The write methods are declared so
109
+ #590 (route writes through the daemon) lands as an implementation, not an
110
+ interface change.
111
+ """
112
+
113
+ # ── reads (implemented now) ──────────────────────────────────────────────
114
+ def list_assignments(self) -> list[dict]: ...
115
+ def list_machines(self) -> list[dict]: ...
116
+ def list_merge_queue(self) -> list[dict]: ...
117
+ def list_drive_escalations(self) -> list[dict]: ...
118
+ def list_drive_queue(self) -> list[dict]: ...
119
+ def list_proposals(self) -> list[dict]: ...
120
+ def list_issues(self) -> list[dict]: ...
121
+ def list_plans(self) -> dict[str, Any]: ...
122
+ def list_notifications(self) -> list[dict]: ...
123
+ def board_meta(self) -> dict[str, str]: ...
124
+ def round_number(self) -> int: ...
125
+ def board_projection(self) -> dict: ...
126
+
127
+ # ── point reads (#1336/#1337: detail endpoints) ──────────────────────────
128
+ def get_assignment(self, assignment_id: str) -> dict | None: ...
129
+ def get_issue(self, repo_name: str, number: int) -> dict | None: ...
130
+
131
+ # ── writes (declared; implemented in #590) ───────────────────────────────
132
+ def record_result(self, record: Any) -> Any: ...
133
+ def record_completion(self, record: Any) -> Any: ...
134
+ def record_dispatched(self, assignment: Any) -> None: ...
135
+
136
+
137
+ def compute_board_keep_ids(
138
+ assignment_index: list[dict],
139
+ merge_queue_ids: set[str],
140
+ open_issue_keys: set[tuple[str, int]],
141
+ cutoff: float | None,
142
+ ) -> set[str]:
143
+ """Return the set of ``assignment_id``s the /board projection must carry.
144
+
145
+ Pure function (no DB) so the read path (:meth:`SqliteStore.board_projection`)
146
+ and the #762 archival sweep apply *identical* rules.
147
+
148
+ ``assignment_index`` rows need only: ``assignment_id``, ``repo_name``,
149
+ ``issue_number``, ``status``, ``dispatched_at``, ``finished_at``,
150
+ ``review_of_assignment_id``.
151
+
152
+ A row is kept when it is **active** (status not terminal), **recent** (its
153
+ finish — or dispatch when never finished — is within the retention window),
154
+ **queued for merge**, or the **latest assignment of a still-open issue**.
155
+ The kept set is then closed over ``review_of_assignment_id`` in both
156
+ directions, so an in-flight item never loses its work↔review pairing.
157
+
158
+ ``cutoff=None`` (cap disabled) keeps everything.
159
+ """
160
+ by_id: dict[str, dict] = {
161
+ r["assignment_id"]: r for r in assignment_index if r.get("assignment_id")
162
+ }
163
+ if cutoff is None:
164
+ return set(by_id)
165
+
166
+ keep: set[str] = set()
167
+ latest_open: dict[tuple, tuple[float, str]] = {}
168
+ for r in assignment_index:
169
+ aid = r.get("assignment_id")
170
+ if not aid:
171
+ continue
172
+ status = (r.get("status") or "").lower()
173
+ if status not in TERMINAL_STATUSES:
174
+ keep.add(aid)
175
+ else:
176
+ ts = r.get("finished_at")
177
+ if ts is None:
178
+ ts = r.get("dispatched_at")
179
+ # Conservative: only drop a terminal row we can POSITIVELY date as
180
+ # old. An undatable terminal row (no finish/dispatch timestamp) is
181
+ # kept rather than risk dropping something we can't age.
182
+ if ts is None or ts >= cutoff:
183
+ keep.add(aid)
184
+ elif aid in merge_queue_ids:
185
+ keep.add(aid)
186
+ # Track the most-recently-dispatched assignment per open issue so an
187
+ # open pipeline card never loses its latest state to the age cap.
188
+ key = (r.get("repo_name"), r.get("issue_number"))
189
+ if key in open_issue_keys:
190
+ disp = r.get("dispatched_at") or 0.0
191
+ cur = latest_open.get(key)
192
+ if cur is None or disp >= cur[0]:
193
+ latest_open[key] = (disp, aid)
194
+ for _, aid in latest_open.values():
195
+ keep.add(aid)
196
+
197
+ # Closure over review links (work → its reviews, review → its work).
198
+ reviews_of: dict[str, list[str]] = {}
199
+ for aid, r in by_id.items():
200
+ tgt = r.get("review_of_assignment_id")
201
+ if tgt:
202
+ reviews_of.setdefault(tgt, []).append(aid)
203
+ frontier = list(keep)
204
+ while frontier:
205
+ aid = frontier.pop()
206
+ tgt = by_id.get(aid, {}).get("review_of_assignment_id")
207
+ if tgt and tgt in by_id and tgt not in keep:
208
+ keep.add(tgt)
209
+ frontier.append(tgt)
210
+ for rev in reviews_of.get(aid, ()):
211
+ if rev not in keep:
212
+ keep.add(rev)
213
+ frontier.append(rev)
214
+ return keep
215
+
216
+
217
+ _KEEP_INDEX_COLUMNS = (
218
+ "assignment_id, repo_name, issue_number, status, dispatched_at, "
219
+ "finished_at, review_of_assignment_id"
220
+ )
221
+
222
+
223
+ def _decode_row(table: str, row: sqlite3.Row, *, full: bool = False) -> dict:
224
+ """sqlite3.Row → plain dict with that table's JSON columns decoded.
225
+
226
+ ``full=True`` keeps the :data:`_DROP_COLUMNS` fields (e.g.
227
+ ``assignments.briefing``) — used by the single-resource *detail* reads
228
+ (#1336/#1337), which serve the complete row; the collection projection
229
+ stays slim.
230
+ """
231
+ d = dict(row)
232
+ if not full:
233
+ for col in _DROP_COLUMNS.get(table, ()):
234
+ d.pop(col, None)
235
+ for col in _JSON_COLUMNS.get(table, ()):
236
+ val = d.get(col)
237
+ if isinstance(val, (str, bytes, bytearray)):
238
+ try:
239
+ d[col] = json.loads(val) if val else None
240
+ except (json.JSONDecodeError, TypeError):
241
+ d[col] = None
242
+ return d
243
+
244
+
245
+ class SqliteStore:
246
+ """Read-only SQLite-backed :class:`CoordStore`.
247
+
248
+ Opens a fresh ``mode=ro`` connection per call (cheap for SQLite, thread-safe
249
+ under the daemon's request handling, and never migrates the DB).
250
+ ``board_projection`` opens a single connection so the whole payload is one
251
+ consistent snapshot.
252
+ """
253
+
254
+ def __init__(self, db_path: Path | str | None = None) -> None:
255
+ self._path = Path(db_path) if db_path is not None else DB_PATH
256
+
257
+ # ── connection ────────────────────────────────────────────────────────────
258
+ def _connect(self) -> sqlite3.Connection:
259
+ uri = f"file:{self._path}?mode=ro"
260
+ conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
261
+ conn.row_factory = sqlite3.Row
262
+ conn.execute("PRAGMA query_only=ON")
263
+ return conn
264
+
265
+ # ── internal builders (take an open connection) ────────────────────────────
266
+ def _table(self, conn: sqlite3.Connection, table: str, order: str | None = None) -> list[dict]:
267
+ sql = f"SELECT * FROM {table}" # noqa: S608 — table names are literals, not user input
268
+ if order:
269
+ sql += f" ORDER BY {order}"
270
+ return [_decode_row(table, r) for r in conn.execute(sql).fetchall()]
271
+
272
+ def _plans(self, conn: sqlite3.Connection) -> dict[str, Any]:
273
+ out: dict[str, Any] = {}
274
+ for r in conn.execute("SELECT assignment_id, plan_data FROM plans").fetchall():
275
+ try:
276
+ out[r["assignment_id"]] = json.loads(r["plan_data"])
277
+ except (json.JSONDecodeError, TypeError):
278
+ continue
279
+ return out
280
+
281
+ def _board_meta(self, conn: sqlite3.Connection) -> dict[str, str]:
282
+ # Served as raw strings; each client parses the keys it knows (the TUI
283
+ # JSON-decodes pipeline_* keys, mirroring its local-SQLite behaviour).
284
+ return {r["key"]: r["value"] for r in conn.execute("SELECT key, value FROM board_meta").fetchall()}
285
+
286
+ def _round_number(self, conn: sqlite3.Connection) -> int:
287
+ row = conn.execute("SELECT value FROM board_meta WHERE key = 'round_number'").fetchone()
288
+ try:
289
+ return int(row["value"]) if row else 0
290
+ except (TypeError, ValueError):
291
+ return 0
292
+
293
+ def _audit_recent_count(self, conn: sqlite3.Connection) -> int:
294
+ """#1037: single-integer summary for the TUI activity-bar badge —
295
+ rows written to ``audit_log`` in the last :data:`_AUDIT_RECENT_WINDOW_SECONDS`.
296
+ A real "unseen since I last looked" count needs per-client read-cursor
297
+ state that doesn't exist yet; a rolling recency window is the
298
+ forward-compatible stand-in the issue asks for (#1039 can replace the
299
+ semantics without a wire-shape change — it's still one integer).
300
+ Fail-open to 0 so a missing/pre-migration table never 503s the board.
301
+ """
302
+ try:
303
+ cutoff = time.time() - _AUDIT_RECENT_WINDOW_SECONDS
304
+ row = conn.execute(
305
+ "SELECT COUNT(*) AS n FROM audit_log WHERE ts >= ?", (cutoff,)
306
+ ).fetchone()
307
+ return int(row["n"]) if row else 0
308
+ except sqlite3.Error:
309
+ return 0
310
+
311
+ # ── public reads ────────────────────────────────────────────────────────────
312
+ def list_assignments(self) -> list[dict]:
313
+ with closing(self._connect()) as conn:
314
+ return self._table(conn, "assignments", order="dispatched_at DESC")
315
+
316
+ def list_machines(self) -> list[dict]:
317
+ with closing(self._connect()) as conn:
318
+ return self._table(conn, "machines", order="name")
319
+
320
+ def list_merge_queue(self) -> list[dict]:
321
+ with closing(self._connect()) as conn:
322
+ return self._table(conn, "merge_queue", order="id")
323
+
324
+ def list_drive_escalations(self) -> list[dict]:
325
+ with closing(self._connect()) as conn:
326
+ return self._table(conn, "drive_escalations", order="id")
327
+
328
+ def list_drive_queue(self) -> list[dict]:
329
+ with closing(self._connect()) as conn:
330
+ return self._table(conn, "drive_queue", order="position")
331
+
332
+ def list_proposals(self) -> list[dict]:
333
+ with closing(self._connect()) as conn:
334
+ return self._table(conn, "proposals", order="id")
335
+
336
+ def list_issues(self) -> list[dict]:
337
+ with closing(self._connect()) as conn:
338
+ return self._table(conn, "issues", order="repo_name, number")
339
+
340
+ def list_plans(self) -> dict[str, Any]:
341
+ with closing(self._connect()) as conn:
342
+ return self._plans(conn)
343
+
344
+ def list_notifications(self) -> list[dict]:
345
+ with closing(self._connect()) as conn:
346
+ return self._table(conn, "notifications")
347
+
348
+ def board_meta(self) -> dict[str, str]:
349
+ with closing(self._connect()) as conn:
350
+ return self._board_meta(conn)
351
+
352
+ def round_number(self) -> int:
353
+ with closing(self._connect()) as conn:
354
+ return self._round_number(conn)
355
+
356
+ # ── point reads (#1336/#1337) ────────────────────────────────────────────
357
+ def get_assignment(self, assignment_id: str) -> dict | None:
358
+ """One full assignment row (JSON columns decoded), or ``None``.
359
+
360
+ Unlike the collection projection this keeps every column — including
361
+ ``briefing`` and the unbounded free-text fields the collection wire
362
+ bounds/previews — because a detail read is one row by definition,
363
+ so it can never grow with board size. Zero third-party I/O.
364
+ """
365
+ with closing(self._connect()) as conn:
366
+ row = conn.execute(
367
+ "SELECT * FROM assignments WHERE assignment_id = ?",
368
+ (assignment_id,),
369
+ ).fetchone()
370
+ return _decode_row("assignments", row, full=True) if row is not None else None
371
+
372
+ def get_issue(self, repo_name: str, number: int) -> dict | None:
373
+ """One full issue row (labels decoded, full ``body``), or ``None``."""
374
+ with closing(self._connect()) as conn:
375
+ row = conn.execute(
376
+ "SELECT * FROM issues WHERE repo_name = ? AND number = ?",
377
+ (repo_name, number),
378
+ ).fetchone()
379
+ return _decode_row("issues", row, full=True) if row is not None else None
380
+
381
+ def _board_keep_ids(self, conn: sqlite3.Connection, cutoff: float | None) -> set[str]:
382
+ """#762: assignment_ids the projection must carry (see
383
+ :func:`compute_board_keep_ids`)."""
384
+ index = [
385
+ dict(r)
386
+ for r in conn.execute(f"SELECT {_KEEP_INDEX_COLUMNS} FROM assignments").fetchall()
387
+ ]
388
+ mq_ids = {
389
+ r["assignment_id"]
390
+ for r in conn.execute("SELECT assignment_id FROM merge_queue").fetchall()
391
+ if r["assignment_id"]
392
+ }
393
+ open_keys = {
394
+ (r["repo_name"], r["number"])
395
+ for r in conn.execute(
396
+ "SELECT repo_name, number FROM issues WHERE LOWER(state) != 'closed'"
397
+ ).fetchall()
398
+ }
399
+ return compute_board_keep_ids(index, mq_ids, open_keys, cutoff)
400
+
401
+ def _capped_assignments(self, conn: sqlite3.Connection, keep: set[str] | None) -> list[dict]:
402
+ rows = conn.execute(
403
+ "SELECT * FROM assignments ORDER BY dispatched_at DESC"
404
+ ).fetchall()
405
+ if keep is None:
406
+ return [_decode_row("assignments", r) for r in rows]
407
+ return [
408
+ _decode_row("assignments", r)
409
+ for r in rows
410
+ if r["assignment_id"] in keep
411
+ ]
412
+
413
+ def _capped_notifications(
414
+ self, conn: sqlite3.Connection, keep: set[str] | None, cutoff: float | None
415
+ ) -> list[dict]:
416
+ rows = conn.execute("SELECT * FROM notifications").fetchall()
417
+ if cutoff is None:
418
+ return [_decode_row("notifications", r) for r in rows]
419
+ out: list[dict] = []
420
+ for r in rows:
421
+ posted = r["posted_at"]
422
+ if (posted is not None and posted >= cutoff) or (
423
+ keep is not None and r["assignment_id"] in keep
424
+ ):
425
+ out.append(_decode_row("notifications", r))
426
+ return out
427
+
428
+ def board_projection(self) -> dict:
429
+ """The full ``GET /board`` payload — one consistent snapshot.
430
+
431
+ A superset of what the Rust TUI's ``load_data()`` reads from SQLite
432
+ today, minus the live machine-reachability probes (the client keeps
433
+ doing those itself over the tailnet).
434
+
435
+ #762: assignments + notifications are capped to active + pipeline-
436
+ referenced + the last ``COORD_BOARD_RETENTION_DAYS`` (default 14) of
437
+ terminal rows, so the wire can't grow unbounded and overrun the TUI's
438
+ fetch timeout (which blanks the whole board on any error).
439
+ """
440
+ with closing(self._connect()) as conn:
441
+ cutoff = _board_retention_cutoff()
442
+ keep = self._board_keep_ids(conn, cutoff) if cutoff is not None else None
443
+ return {
444
+ "schema_version": SCHEMA_VERSION,
445
+ "round_number": self._round_number(conn),
446
+ "assignments": self._capped_assignments(conn, keep),
447
+ "machines": self._table(conn, "machines", order="name"),
448
+ "merge_queue": self._table(conn, "merge_queue", order="id"),
449
+ "proposals": self._table(conn, "proposals", order="id"),
450
+ "issues": self._table(conn, "issues", order="repo_name, number"),
451
+ "plans": self._plans(conn),
452
+ "notifications": self._capped_notifications(conn, keep, cutoff),
453
+ "board_meta": self._board_meta(conn),
454
+ "audit_recent_count": self._audit_recent_count(conn),
455
+ # #1505: board-visible driver-escalation records — see
456
+ # coord/db.py's drive_escalations table docstring. Small,
457
+ # unbounded-but-tiny table (one row per stuck issue, cleared
458
+ # on dismiss), so no retention cap like assignments/
459
+ # notifications above.
460
+ "escalations": self._table(conn, "drive_escalations", order="id"),
461
+ # #1753: the operator-declared `coord drive` queue, in the
462
+ # order it will run (dense, 0-based `position`). Same
463
+ # rationale as `escalations` above — bounded by hand, so no
464
+ # retention cap; `after_json` arrives decoded as a real list.
465
+ "drive_queue": self._table(conn, "drive_queue", order="position"),
466
+ }
467
+
468
+ # ── writes (deferred to #590) ────────────────────────────────────────────────
469
+ def _not_yet(self, name: str) -> NotImplementedError:
470
+ return NotImplementedError(
471
+ f"{name}: writing through the daemon is part of #590 (route writes "
472
+ "through the daemon). The read-path slice (#594) serves reads only; "
473
+ "dispatch/record still run on the host."
474
+ )
475
+
476
+ def record_result(self, record: Any) -> Any: # noqa: ARG002
477
+ raise self._not_yet("record_result")
478
+
479
+ def record_completion(self, record: Any) -> Any: # noqa: ARG002
480
+ raise self._not_yet("record_completion")
481
+
482
+ def record_dispatched(self, assignment: Any) -> None: # noqa: ARG002
483
+ raise self._not_yet("record_dispatched")
File without changes