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/audit.py ADDED
@@ -0,0 +1,301 @@
1
+ """Durable, append-only audit trail (#1036 — Audit Trail epic, issue A).
2
+
3
+ ``record_audit()`` is called from the ``state._*_local`` / ``issue_store``
4
+ write choke points — the handful of functions every board mutation, whether
5
+ it arrives from a thin client or lands directly on the daemon, funnels
6
+ through (canonical example: ``state._record_test_verdict_local``). Hooking
7
+ there guarantees one row per real transition regardless of topology, without
8
+ touching the ~30 CLI call sites that ultimately reach those writers.
9
+
10
+ This module is deliberately dumb: one table, one INSERT, best-effort. It
11
+ does not define an event taxonomy beyond the ``tier``/``category`` columns
12
+ described in the issue — callers pick their own ``event_type`` strings,
13
+ reusing the ``coord:event=`` names from :mod:`coord.comments` where they
14
+ already exist (``EVENT_COMPLETION`` etc.) so the audit log and the GitHub
15
+ message bus agree on vocabulary.
16
+
17
+ **Never raises into the caller.** A board mutation must always succeed even
18
+ if the audit write fails (disk full, locked DB, schema drift on an old
19
+ checkout) — the write that rode in on is the one that matters; the audit
20
+ row is best-effort observability on top of it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import time
28
+ from typing import Any
29
+
30
+ from coord.db import get_connection
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+ __all__ = ["record_audit", "query_audit_log"]
35
+
36
+ # Valid values are documented in the issue but not enforced here — callers
37
+ # are all internal (coord.state / coord.issue_store), and rejecting an
38
+ # unrecognized value would defeat "never raises into the caller".
39
+ _VALID_TIERS = ("business", "operational")
40
+
41
+ # #1037: read-side defaults for the paginated `/audit` endpoint / `coord
42
+ # audit` CLI. Hard-capped so a client can't request the whole table in one
43
+ # shot (the endpoint is explicitly NOT the /board "everything" snapshot).
44
+ DEFAULT_LIMIT = 200
45
+ MAX_LIMIT = 500
46
+
47
+
48
+ def record_audit(
49
+ *,
50
+ tier: str,
51
+ category: str,
52
+ event_type: str,
53
+ actor: str,
54
+ summary: str,
55
+ ts: float | None = None,
56
+ repo: str | None = None,
57
+ issue: int | None = None,
58
+ assignment_id: str | None = None,
59
+ machine: str | None = None,
60
+ details: dict[str, Any] | None = None,
61
+ ) -> None:
62
+ """Append one row to ``audit_log``. Best-effort — swallows all failures.
63
+
64
+ ``tier`` is ``"business"`` (a real board transition — dispatch, test
65
+ verdict, review verdict, merge, ...) or ``"operational"`` (daemon-tick
66
+ housekeeping; out of scope for this issue, reserved for a later one).
67
+ ``details`` is JSON-serialized into ``details_json``; pass only
68
+ JSON-safe values (str/int/float/bool/None/dict/list).
69
+
70
+ Also performs the opportunistic ``audit.max_rows`` trim (#1036
71
+ deliverable 4) after a successful insert, when the config knob is set
72
+ above its default of ``0`` (unlimited).
73
+
74
+ #1038: when ``tier="operational"`` and ``audit.level`` is set to
75
+ ``"business"`` (default ``"operational"``), the row is dropped here —
76
+ the single choke point every operational-tier caller funnels through —
77
+ so callers (the daemon-tick hooks) stay unconditional. Business-tier
78
+ rows are never gated by this check.
79
+ """
80
+ try:
81
+ if tier == "operational" and _resolve_level() == "business":
82
+ return
83
+ _record_audit_unsafe(
84
+ tier=tier,
85
+ category=category,
86
+ event_type=event_type,
87
+ actor=actor,
88
+ summary=summary,
89
+ ts=ts,
90
+ repo=repo,
91
+ issue=issue,
92
+ assignment_id=assignment_id,
93
+ machine=machine,
94
+ details=details,
95
+ )
96
+ except Exception as exc: # noqa: BLE001 — audit logging must never break the caller
97
+ _log.warning("record_audit: best-effort write failed: %s", exc)
98
+
99
+
100
+ def _record_audit_unsafe(
101
+ *,
102
+ tier: str,
103
+ category: str,
104
+ event_type: str,
105
+ actor: str,
106
+ summary: str,
107
+ ts: float | None,
108
+ repo: str | None,
109
+ issue: int | None,
110
+ assignment_id: str | None,
111
+ machine: str | None,
112
+ details: dict[str, Any] | None,
113
+ ) -> None:
114
+ """The actual write. Split out from :func:`record_audit` so the
115
+ try/except wrapper is the ONLY thing between this and the caller —
116
+ keeps the swallow-and-log behavior in one obvious place."""
117
+ conn = get_connection()
118
+ conn.execute(
119
+ """INSERT INTO audit_log (
120
+ ts, tier, category, event_type, actor,
121
+ repo, issue, assignment_id, machine, summary, details_json
122
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
123
+ (
124
+ ts if ts is not None else time.time(),
125
+ tier,
126
+ category,
127
+ event_type,
128
+ actor,
129
+ repo,
130
+ issue,
131
+ assignment_id,
132
+ machine,
133
+ summary,
134
+ json.dumps(details) if details is not None else None,
135
+ ),
136
+ )
137
+ conn.commit()
138
+ _maybe_trim(conn)
139
+
140
+
141
+ def _maybe_trim(conn) -> None:
142
+ """Opportunistic cap: when ``audit.max_rows`` is set (> 0), delete the
143
+ oldest rows past that count after every insert.
144
+
145
+ Default (``max_rows=0``) is unlimited — this is a no-op in the common
146
+ case. Config is re-loaded on every call rather than cached: audit
147
+ writes are not hot-loop-frequency (one per board transition, not per
148
+ daemon tick), so the extra YAML parse is cheap relative to the SQLite
149
+ round-trip it accompanies, and it means a config edit takes effect on
150
+ the next write without a process restart.
151
+ """
152
+ max_rows = _resolve_max_rows()
153
+ if max_rows <= 0:
154
+ return
155
+ conn.execute(
156
+ "DELETE FROM audit_log WHERE id NOT IN "
157
+ "(SELECT id FROM audit_log ORDER BY id DESC LIMIT ?)",
158
+ (max_rows,),
159
+ )
160
+ conn.commit()
161
+
162
+
163
+ def _resolve_max_rows() -> int:
164
+ """Read ``audit.max_rows`` from coordinator.yml. Returns 0 (unlimited)
165
+ on any failure — a missing/invalid config must not block audit writes,
166
+ let alone the board mutation that triggered them."""
167
+ try:
168
+ from coord.config import load as _load_config # noqa: PLC0415
169
+
170
+ cfg = _load_config()
171
+ return max(0, int(cfg.audit.max_rows))
172
+ except Exception: # noqa: BLE001 — best-effort; unlimited is the safe default
173
+ return 0
174
+
175
+
176
+ def _resolve_level() -> str:
177
+ """Read ``audit.level`` from coordinator.yml. Returns ``"operational"``
178
+ (the default — capture everything) on any failure, so a missing/invalid
179
+ config never silently suppresses audit rows."""
180
+ try:
181
+ from coord.config import load as _load_config # noqa: PLC0415
182
+
183
+ cfg = _load_config()
184
+ level = cfg.audit.level
185
+ return level if level in _VALID_TIERS else "operational"
186
+ except Exception: # noqa: BLE001 — best-effort; capture-everything is the safe default
187
+ return "operational"
188
+
189
+
190
+ # ── Read side (#1037): paginated query over audit_log ──────────────────────
191
+
192
+ _AUDIT_COLUMNS = (
193
+ "id", "ts", "tier", "category", "event_type", "actor",
194
+ "repo", "issue", "assignment_id", "machine", "summary", "details_json",
195
+ )
196
+
197
+
198
+ def _encode_cursor(ts: float, row_id: int) -> str:
199
+ """Opaque-ish keyset cursor over ``(ts, id)``. Not meant to be parsed by
200
+ callers — just round-tripped through ``cursor`` on the next request."""
201
+ return f"{ts!r}:{row_id}"
202
+
203
+
204
+ def _decode_cursor(cursor: str | None) -> tuple[float, int] | None:
205
+ """Parse a cursor produced by :func:`_encode_cursor`. Returns ``None``
206
+ for a blank/malformed cursor — callers treat that as "first page" rather
207
+ than raising, since a stale/garbled cursor should degrade gracefully
208
+ (start over), not 400 the whole request."""
209
+ if not cursor:
210
+ return None
211
+ try:
212
+ ts_part, id_part = cursor.rsplit(":", 1)
213
+ return float(ts_part), int(id_part)
214
+ except (ValueError, TypeError):
215
+ return None
216
+
217
+
218
+ def _row_to_entry(row: Any) -> dict[str, Any]:
219
+ entry = {col: row[col] for col in _AUDIT_COLUMNS}
220
+ details_raw = entry.pop("details_json")
221
+ entry["details"] = json.loads(details_raw) if details_raw else None
222
+ return entry
223
+
224
+
225
+ def query_audit_log(
226
+ *,
227
+ since: float | None = None,
228
+ until: float | None = None,
229
+ event_type: str | None = None,
230
+ category: str | None = None,
231
+ repo: str | None = None,
232
+ issue: int | None = None,
233
+ assignment_id: str | None = None,
234
+ tier: str | None = None,
235
+ limit: int = DEFAULT_LIMIT,
236
+ cursor: str | None = None,
237
+ ) -> dict[str, Any]:
238
+ """Keyset-paginated, newest-first read over ``audit_log``.
239
+
240
+ Ordered by ``(ts, id) DESC`` — a cursor (not ``OFFSET``) carries the last
241
+ row of the previous page, so pagination stays O(page size) as the table
242
+ grows. All filters are optional and AND together. ``limit`` is clamped
243
+ to ``(1, MAX_LIMIT]``, defaulting to ``DEFAULT_LIMIT``.
244
+
245
+ Returns ``{"entries": [...], "next_cursor": str | None, "has_more": bool}``.
246
+ Each entry has ``details_json`` decoded into a ``details`` dict (``None``
247
+ when absent), matching the shape callers actually want on the wire.
248
+ """
249
+ try:
250
+ limit = int(limit)
251
+ except (TypeError, ValueError):
252
+ limit = DEFAULT_LIMIT
253
+ limit = max(1, min(limit, MAX_LIMIT))
254
+
255
+ clauses: list[str] = []
256
+ params: list[Any] = []
257
+ if since is not None:
258
+ clauses.append("ts >= ?")
259
+ params.append(since)
260
+ if until is not None:
261
+ clauses.append("ts <= ?")
262
+ params.append(until)
263
+ if event_type:
264
+ clauses.append("event_type = ?")
265
+ params.append(event_type)
266
+ if category:
267
+ clauses.append("category = ?")
268
+ params.append(category)
269
+ if repo:
270
+ clauses.append("repo = ?")
271
+ params.append(repo)
272
+ if issue is not None:
273
+ clauses.append("issue = ?")
274
+ params.append(issue)
275
+ if assignment_id:
276
+ clauses.append("assignment_id = ?")
277
+ params.append(assignment_id)
278
+ if tier:
279
+ clauses.append("tier = ?")
280
+ params.append(tier)
281
+
282
+ decoded_cursor = _decode_cursor(cursor)
283
+ if decoded_cursor is not None:
284
+ cur_ts, cur_id = decoded_cursor
285
+ clauses.append("(ts < ? OR (ts = ? AND id < ?))")
286
+ params.extend([cur_ts, cur_ts, cur_id])
287
+
288
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
289
+ conn = get_connection()
290
+ # Fetch one extra row to detect has_more without a second COUNT query.
291
+ rows = conn.execute(
292
+ f"SELECT {', '.join(_AUDIT_COLUMNS)} FROM audit_log {where} "
293
+ "ORDER BY ts DESC, id DESC LIMIT ?",
294
+ (*params, limit + 1),
295
+ ).fetchall()
296
+
297
+ has_more = len(rows) > limit
298
+ rows = rows[:limit]
299
+ entries = [_row_to_entry(r) for r in rows]
300
+ next_cursor = _encode_cursor(rows[-1]["ts"], rows[-1]["id"]) if has_more and rows else None
301
+ return {"entries": entries, "next_cursor": next_cursor, "has_more": has_more}