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
@@ -0,0 +1,72 @@
1
+ """Producer-side guard against the #632 blank-board class (#748).
2
+
3
+ SQLite has no native boolean type — a column declared ``INTEGER DEFAULT 0``
4
+ and used as a flag (e.g. ``is_interactive``) serializes on the `/board` wire
5
+ as a raw JSON ``0``/``1``, not ``true``/``false``. The Rust side
6
+ (``tui/src/app/types.rs``) hand-mirrors DB columns into typed structs; if a
7
+ *new* such column is ever typed as a plain ``bool`` there (instead of going
8
+ through ``de_bool_from_int_or_bool`` or an equivalent coercing
9
+ deserializer), that ONE field fails the parse of the **entire**
10
+ ``BoardPayload`` and blanks the whole TUI board (#632/#546/#628).
11
+
12
+ This module is the producer-side (Python) half of the seam check: it reads
13
+ the Rust struct definitions as text, finds `bool`-typed fields with no
14
+ custom deserializer, and cross-references them against the live SQLite
15
+ schema. ``tests/test_board_fixture.py`` wires this up against the real
16
+ ``tui/src/app/types.rs`` + a freshly-migrated DB so CI goes red the moment
17
+ someone adds an unguarded INTEGER-backed bool field.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ # Matches `#[attr] #[attr] ... pub(crate) <name>: bool,` — i.e. a struct
25
+ # field typed as a plain `bool` (not `Option<bool>`, not some wrapper type).
26
+ # `attrs` captures every `#[...]` attribute line directly above the field so
27
+ # we can check it for a `deserialize_with` guard and/or a `rename`.
28
+ _BOOL_FIELD_RE = re.compile(
29
+ r"(?P<attrs>(?:[ \t]*#\[[^\]]*\]\s*\n)*)"
30
+ r"[ \t]*pub\(crate\)\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*):\s*bool,",
31
+ )
32
+ _RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"')
33
+
34
+
35
+ def find_unguarded_bool_fields(rust_src: str) -> dict[str, str]:
36
+ """Return ``{wire_key: field_name}`` for `bool` struct fields in *rust_src*
37
+ that have no `deserialize_with` attribute.
38
+
39
+ Such fields require the wire to send a literal JSON `true`/`false` — a
40
+ SQLite-style `0`/`1` integer fails the whole containing struct's parse.
41
+ """
42
+ out: dict[str, str] = {}
43
+ for m in _BOOL_FIELD_RE.finditer(rust_src):
44
+ attrs = m.group("attrs")
45
+ if "deserialize_with" in attrs:
46
+ continue # guarded — e.g. de_bool_from_int_or_bool
47
+ name = m.group("name")
48
+ rename = _RENAME_RE.search(attrs)
49
+ wire_key = rename.group(1) if rename else name
50
+ out[wire_key] = name
51
+ return out
52
+
53
+
54
+ def find_integer_bool_mismatches(
55
+ rust_src: str, schema_columns: dict[str, dict[str, str]]
56
+ ) -> list[str]:
57
+ """Cross-reference unguarded Rust `bool` fields against SQLite column types.
58
+
59
+ ``schema_columns`` maps table name -> ``{column_name: declared_type}``
60
+ (as ``PRAGMA table_info`` reports it, e.g. ``"INTEGER"``, ``"TEXT"``).
61
+
62
+ Returns ``"<table>.<column>"`` for every unguarded `bool` field whose
63
+ wire key matches a column declared ``INTEGER`` — the exact #632 trigger.
64
+ Empty list means the seam is safe.
65
+ """
66
+ unguarded = find_unguarded_bool_fields(rust_src)
67
+ mismatches: list[str] = []
68
+ for table, cols in sorted(schema_columns.items()):
69
+ for col, decl_type in sorted(cols.items()):
70
+ if col in unguarded and decl_type.upper().startswith("INTEGER"):
71
+ mismatches.append(f"{table}.{col}")
72
+ return mismatches
coord/board_service.py ADDED
@@ -0,0 +1,141 @@
1
+ """``BoardService`` facade (#749): decide local-vs-daemon ONCE, for both reads
2
+ and writes, instead of re-implementing the ``resolve_board_service()`` +
3
+ local-fallback dance at every call site.
4
+
5
+ Before this module, ~30 call sites across ``coord/commands/*.py``,
6
+ ``coord/dashboard/server.py`` and ``coord/auto_loop.py`` each hand-rolled:
7
+
8
+ svc = resolve_board_service()
9
+ board = fetch_remote_board(svc) if svc else (load_board() or build_board())
10
+ ...mutate board...
11
+ save_board(board) # silently a no-op on a thin client — the #749 bug
12
+
13
+ ``read_board()`` / ``write_board()`` below are the one place that decision is
14
+ made. ``write_board()`` POSTs to the daemon's generic ``/board`` upsert
15
+ endpoint when ``board_service`` is configured, so a thin client's mutation
16
+ actually reaches the shared DB instead of vanishing into an empty local one.
17
+
18
+ This module also hosts ``route_write()``, which centralizes the
19
+ ``resolve_board_service()`` + ``coord.client.post_record`` dance duplicated
20
+ ~13x across ``coord.state``'s per-mutation routing wrappers
21
+ (``record_dispatched``, ``record_test_verdict``, ``update_issue_labels``,
22
+ …) — so ``coord.state`` depends on this thin facade rather than importing
23
+ ``coord.client`` directly at over a dozen sites (#749's "reduce state.py's
24
+ outward coupling toward a pure DAO").
25
+
26
+ Whole-command re-routing (``coord merge``, ``coord reconcile-merges``,
27
+ ``coord diagnose``, ``coord housekeeping`` — where the ENTIRE command runs on
28
+ the daemon and streams back its own textual/structured output, rather than a
29
+ generic board upsert) is a different shape and stays in its own commands;
30
+ ``daemon_reroute_target()`` here only DRYs up the repeated
31
+ "resolve + check the re-entrancy env guard" preamble those four call sites
32
+ share.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+
39
+ from coord.models import Board
40
+
41
+
42
+ def resolve(): # -> coord.client.ServiceConfig | None
43
+ """Resolve the configured board service, or ``None`` for local/host mode."""
44
+ from coord.client import resolve_board_service # noqa: PLC0415
45
+
46
+ return resolve_board_service()
47
+
48
+
49
+ def is_remote() -> bool:
50
+ """Whether this process is a thin client (``board_service`` configured)."""
51
+ return resolve() is not None
52
+
53
+
54
+ def read_board() -> Board:
55
+ """Return the current board.
56
+
57
+ Daemon-configured → GET /board and reconstruct it. Otherwise → the local
58
+ DB (``load_board()``, falling back to ``build_board()`` when the board has
59
+ never been saved). This is the single place that decision is made; every
60
+ call site that used to duplicate
61
+ ``fetch_remote_board(svc) if svc else (load_board() or build_board())``
62
+ should call this instead.
63
+ """
64
+ svc = resolve()
65
+ if svc is not None:
66
+ from coord.client import fetch_remote_board # noqa: PLC0415
67
+
68
+ return fetch_remote_board(svc)
69
+ from coord.state import build_board, load_board # noqa: PLC0415
70
+
71
+ return load_board() or build_board()
72
+
73
+
74
+ def write_board(board: Board) -> None:
75
+ """Persist *board*.
76
+
77
+ Daemon-configured → POST the full board to the daemon's ``/board`` upsert
78
+ endpoint (safe: upsert-only, never deletes — see
79
+ ``coord.client.serialize_board``). Otherwise → the local
80
+ ``coord.state.save_board``. Every call site that used to call
81
+ ``save_board(board)`` directly (and silently no-op on a thin client)
82
+ should call this instead.
83
+ """
84
+ svc = resolve()
85
+ if svc is not None:
86
+ from coord.client import post_board # noqa: PLC0415
87
+
88
+ post_board(svc, board)
89
+ return
90
+ from coord.state import save_board # noqa: PLC0415
91
+
92
+ save_board(board)
93
+
94
+
95
+ def route_write(
96
+ svc,
97
+ endpoint: str,
98
+ payload: dict,
99
+ *,
100
+ timeout: float | None = None,
101
+ ) -> dict | None:
102
+ """POST *payload* to *endpoint* via *svc*, or ``None`` if *svc* is unset.
103
+
104
+ Centralizes the ``from coord.client import post_record`` + call dance
105
+ duplicated across ``coord.state``'s ``record_*`` / ``update_*`` routing
106
+ wrappers, so ``coord.state`` no longer needs its own deferred
107
+ ``coord.client`` import at each of those ~13 sites. Callers keep their own
108
+ ``svc = board_service.resolve()`` (they need the value regardless, to
109
+ decide whether to fall through to the local ``_*_local`` path) — this just
110
+ does the "POST it, or tell the caller there's nothing to POST to" part.
111
+
112
+ Returns ``None`` when *svc* is ``None`` (caller should run the local
113
+ path); otherwise returns the daemon's JSON response (never ``None``,
114
+ even for an empty ``{}`` body) so callers can distinguish "routed" from
115
+ "not routed" with a plain ``is not None`` check.
116
+ """
117
+ if svc is None:
118
+ return None
119
+ from coord.client import post_record # noqa: PLC0415
120
+
121
+ if timeout is None:
122
+ return post_record(svc, endpoint, payload)
123
+ return post_record(svc, endpoint, payload, timeout=timeout)
124
+
125
+
126
+ def daemon_reroute_target(env_var: str): # -> coord.client.ServiceConfig | None
127
+ """Resolve the board service for a whole-command daemon re-route, or
128
+ ``None`` if the command should run locally.
129
+
130
+ Shared preamble for the four commands (``merge``, ``reconcile-merges``,
131
+ ``diagnose``, ``housekeeping``) that route their ENTIRE execution to the
132
+ daemon rather than doing a generic board upsert: each sets *env_var*
133
+ (e.g. ``COORD_MERGE_ON_DAEMON``) before re-invoking itself on the daemon
134
+ side, so the daemon's own execution doesn't try to re-route back out over
135
+ HTTP. Returns ``None`` (meaning "run locally") both when no board service
136
+ is configured AND when *env_var* is set (i.e. we ARE the daemon running
137
+ the re-routed-to invocation).
138
+ """
139
+ if os.environ.get(env_var):
140
+ return None
141
+ return resolve()
coord/board_wire.py ADDED
@@ -0,0 +1,309 @@
1
+ """Wire-bounding policy for the ``/board`` collection payload (#1337).
2
+
3
+ **Invariant 2 of the /board read path: no collection endpoint returns
4
+ unbounded text.** The #762 fix bounded the board's *row count* (retention
5
+ cap) — and the payload kept growing anyway, because the growth was in
6
+ per-row text size: ``assignments.review_findings`` + ``issues.body`` alone
7
+ were ~46 % of a 5.5 MB payload polled every few seconds. Bounding rows
8
+ while text grows per-row is how this failure class (#762 → #715 → #1336)
9
+ kept coming back.
10
+
11
+ This module is the single place the per-field wire policy lives:
12
+
13
+ * **Preview fields** — ``review_findings`` (body inside the JSON envelope),
14
+ ``test_reason``, ``smoke_test_reason`` — are cut to a preview and flagged
15
+ with ``<field>_truncated: true`` (+ ``<field>_len``). Consumers that need
16
+ the full text (fix-worker briefings, the TUI findings pane) fetch the
17
+ single-resource detail endpoint ``GET /assignment/{id}``.
18
+ * **Bounded documents** — ``issues.body``, ``test_plan`` — get a *high* hard
19
+ cap that today truncates nothing real (issue-body p99 ≈ 9 KB, cap 16 KB)
20
+ but bounds the pathological row, because clients parse these semantically
21
+ (work orders, ``## Files`` globs) and an aggressive prefix cut would break
22
+ those parses. **Tracking (epic) issue bodies are exempt entirely** — the
23
+ TUI's Milestone DAG parses ``## Work order`` client-side out of the full
24
+ body (see :func:`bound_issue_row`); GitHub's own 64 KB issue-body limit ×
25
+ the handful of epics per board keeps the exemption bounded. Full body:
26
+ ``GET /issue/{repo}/{number}``.
27
+
28
+ Everything here is wire-only: the DB row, the detail endpoints, and local
29
+ (non-daemon) reads are untouched. The bounded fields are also excluded from
30
+ the whole-board upsert's UPDATE clause (``coord.state._UPSERT_SQL``), so a
31
+ bounded preview can never round-trip over the full stored text via
32
+ ``POST /board``.
33
+
34
+ Enforced by tests/test_board_read_path.py — a payload-budget test fails the
35
+ suite if a seeded board's wire exceeds its budget, so instance #4 of this
36
+ class shows up as a red test, not a fleet incident.
37
+
38
+ **#1791 (instance #4): bounding row WIDTH was not enough.** #762 added a
39
+ day-based retention cutoff at the DAO layer (``COORD_BOARD_RETENTION_DAYS``,
40
+ default 14 — see ``coord/dao.py``), but a normal ~2-week stretch of fleet
41
+ throughput still produced 904 terminal (done/merged/failed/advisory)
42
+ assignment rows inside that window — none of them "old" yet — and 904 small,
43
+ individually-width-bounded rows landed at the same 5.30 MB payload 90 large
44
+ ones did pre-#1337. ``bound_board_payload`` below adds the collection-
45
+ CARDINALITY bound this class of fix has been missing: it caps the
46
+ *count* of terminal assignment rows on the wire
47
+ (:data:`MAX_TERMINAL_ASSIGNMENTS`) and drops the body of closed (terminal)
48
+ issues outright, on top of the existing per-field width caps. Both cuts are
49
+ flagged on the payload (``board_truncated`` + counts) so a client can tell
50
+ it received a trimmed board rather than the whole history — see
51
+ :func:`bound_board_payload`.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import json
57
+
58
+ from coord.dao import TERMINAL_STATUSES
59
+
60
+ # Preview size for operator-facing free text. Large enough that a short
61
+ # review / test reason arrives whole; everything longer is a preview + flag.
62
+ PREVIEW_CHARS = 2000
63
+ # Hard cap for semantically-parsed documents (issue bodies, test plans).
64
+ DOCUMENT_CHARS = 16384
65
+
66
+ # #1791: how many *terminal* assignment rows the /board wire carries. This is
67
+ # a SECOND, tighter bound than #762's day-based DAO cutoff — that cap bounds
68
+ # board AGE, not board THROUGHPUT, so a busy fortnight still puts every one
69
+ # of its terminal rows on the wire because none of them are "old" yet. Only
70
+ # the most recent MAX_TERMINAL_ASSIGNMENTS terminal rows (by finished_at,
71
+ # falling back to dispatched_at) ride the wire; active (non-terminal) rows
72
+ # and the latest assignment of a still-open issue are NEVER subject to this
73
+ # cap, regardless of how many terminal rows exist. Full history stays
74
+ # reachable via GET /assignment/{id}.
75
+ MAX_TERMINAL_ASSIGNMENTS = 200
76
+
77
+ # #1791: named byte budget for the WHOLE /board payload. #1337 bounded
78
+ # per-row WIDTH only (``BOARD payload budget`` in tests/test_board_read_path.py
79
+ # guards that), so row CARDINALITY could still blow the same ceiling that
80
+ # broke `coord report-result`'s 5s prefetch timeout in #1336 — exactly what
81
+ # recurred in #1791 (5.30 MB, 904 of 906 assignment rows terminal). Enforced
82
+ # by tests/test_board_wire.py::test_board_payload_budget_holds_at_terminal_row_scale,
83
+ # seeded with THOUSANDS of terminal rows, so a fifth recurrence fails a
84
+ # test, not a fleet check.
85
+ BOARD_PAYLOAD_BYTE_BUDGET = 2_500_000
86
+
87
+ # Appended to truncated *plain-text* fields so a human reading the preview
88
+ # (TUI pane, dialog) knows it is one — machine consumers use the flags.
89
+ TRUNCATION_NOTICE = "\n… [truncated on the /board wire — full text: detail endpoint]"
90
+
91
+
92
+ def _preview(text: str, cap: int) -> str:
93
+ return text[:cap] + TRUNCATION_NOTICE
94
+
95
+
96
+ def _bound_text_field(row: dict, field: str, cap: int) -> None:
97
+ """Truncate ``row[field]`` to *cap* chars, stamping ``<field>_truncated``
98
+ and ``<field>_len`` when it was cut. Flags are additive-only (absent when
99
+ nothing was cut) so old clients see an unchanged shape."""
100
+ val = row.get(field)
101
+ if not isinstance(val, str) or len(val) <= cap:
102
+ return
103
+ row[f"{field}_len"] = len(val)
104
+ row[f"{field}_truncated"] = True
105
+ row[field] = _preview(val, cap)
106
+
107
+
108
+ def _bound_review_findings(row: dict, cap: int) -> None:
109
+ """Envelope-aware preview for ``review_findings``.
110
+
111
+ The column is a JSON envelope ``{"verdict": ..., "body": ...}`` kept as a
112
+ *raw string* on the wire (the TUI parses it). Truncating the raw string
113
+ would corrupt the JSON, so parse, preview the body inside, and
114
+ re-serialize — the verdict always survives intact. A legacy/unparseable
115
+ blob falls back to plain-text truncation.
116
+ """
117
+ raw = row.get("review_findings")
118
+ if not isinstance(raw, str) or len(raw) <= cap:
119
+ return
120
+ row["review_findings_len"] = len(raw)
121
+ row["review_findings_truncated"] = True
122
+ try:
123
+ env = json.loads(raw)
124
+ except (json.JSONDecodeError, TypeError):
125
+ env = None
126
+ if isinstance(env, dict) and isinstance(env.get("body"), str):
127
+ env["body"] = _preview(env["body"], cap)
128
+ env["truncated"] = True
129
+ row["review_findings"] = json.dumps(env)
130
+ else:
131
+ row["review_findings"] = raw[:cap]
132
+
133
+
134
+ def _bound_test_plan(row: dict, cap: int) -> None:
135
+ """``test_plan`` is a decoded ``{"steps": [...]}`` object on the wire —
136
+ a prefix cut is meaningless, so a pathological plan is dropped whole
137
+ (flagged); the TUI's existing ``None`` handling shows its placeholder and
138
+ the detail endpoint serves the full plan."""
139
+ val = row.get("test_plan")
140
+ if val is None:
141
+ return
142
+ try:
143
+ size = len(json.dumps(val))
144
+ except (TypeError, ValueError):
145
+ return
146
+ if size <= cap:
147
+ return
148
+ row["test_plan_len"] = size
149
+ row["test_plan_truncated"] = True
150
+ row["test_plan"] = None
151
+
152
+
153
+ def bound_assignment_row(row: dict) -> None:
154
+ """Apply the wire policy to one ``/board`` assignment row (mutates)."""
155
+ _bound_review_findings(row, PREVIEW_CHARS)
156
+ _bound_text_field(row, "test_reason", PREVIEW_CHARS)
157
+ _bound_text_field(row, "smoke_test_reason", PREVIEW_CHARS)
158
+ _bound_text_field(row, "failure_reason", PREVIEW_CHARS)
159
+ _bound_test_plan(row, DOCUMENT_CHARS)
160
+
161
+
162
+ def _is_tracking_issue(row: dict) -> bool:
163
+ """True when the issue carries the milestone-tracking (epic) label."""
164
+ from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
165
+
166
+ labels = row.get("labels")
167
+ return isinstance(labels, list) and TRACKING_ISSUE_LABEL in labels
168
+
169
+
170
+ def _is_closed_issue(row: dict) -> bool:
171
+ state = row.get("state")
172
+ return isinstance(state, str) and state.strip().lower() == "closed"
173
+
174
+
175
+ def bound_issue_row(row: dict) -> None:
176
+ """Apply the wire policy to one ``/board`` issue row (mutates).
177
+
178
+ **Tracking (epic) issues are exempt from the body cap.** The TUI's
179
+ Milestone DAG parses ``## Work order`` out of the tracking issue's body
180
+ *client-side* (`milestone_dag.rs::milestones_with_work_orders` — it does
181
+ NOT consume the server-computed ``milestone_work_orders``, which drops
182
+ terminal nodes and carries no ``after``-edges), so a cap that cuts
183
+ work-order items past DOCUMENT_CHARS would silently drop DAG nodes on
184
+ thin clients — a regression in exactly the failure class #1337 exists to
185
+ close. The exemption stays bounded in practice: an epic body is capped
186
+ at 65,536 chars by GitHub itself and boards carry few epics (~31 today,
187
+ 0.19 MB total).
188
+
189
+ **Closed (non-epic) issues drop the body entirely** (#1791): a closed
190
+ issue is terminal — no pipeline decision, client-side or server-side,
191
+ reads its body once it's closed — and #1337's DOCUMENT_CHARS cap alone
192
+ still left 110 closed issues carrying 0.57 MB on every /board GET. Full
193
+ text stays on ``GET /issue/{repo}/{number}``. Open (non-epic) issue
194
+ bodies — the ones ``pipeline.rs``'s ``acceptance_for_path_arg`` and
195
+ ``dialogs.rs`` parse client-side — keep the DOCUMENT_CHARS cap
196
+ unchanged.
197
+ """
198
+ if _is_tracking_issue(row):
199
+ return
200
+ if _is_closed_issue(row):
201
+ _bound_text_field(row, "body", 0)
202
+ return
203
+ _bound_text_field(row, "body", DOCUMENT_CHARS)
204
+
205
+
206
+ def _open_issue_keys(issues) -> set[tuple[str, int]]:
207
+ """``(repo_name, number)`` of every non-closed issue in the projection."""
208
+ keys: set[tuple[str, int]] = set()
209
+ for row in issues:
210
+ if not isinstance(row, dict) or _is_closed_issue(row):
211
+ continue
212
+ repo_name, number = row.get("repo_name"), row.get("number")
213
+ if repo_name is not None and number is not None:
214
+ keys.add((repo_name, number))
215
+ return keys
216
+
217
+
218
+ def cap_terminal_assignments(
219
+ assignments: list[dict], open_issue_keys: set[tuple[str, int]]
220
+ ) -> int:
221
+ """Cap ``assignments`` (mutated in place) to :data:`MAX_TERMINAL_ASSIGNMENTS`
222
+ terminal rows (#1791). Returns the number of rows dropped.
223
+
224
+ A row is PROTECTED — never dropped, regardless of how many terminal rows
225
+ exist — when it is active (status not in ``TERMINAL_STATUSES``) or is
226
+ tied to a still-open issue. That mirrors
227
+ ``coord.dao.compute_board_keep_ids``'s "latest assignment of an open
228
+ issue" rule, so this second, tighter cut can never undo that guarantee.
229
+ Among the remaining terminal rows, the most recent
230
+ ``MAX_TERMINAL_ASSIGNMENTS`` (by ``finished_at``, falling back to
231
+ ``dispatched_at``) are kept; the cut is then closed over
232
+ ``review_of_assignment_id`` in both directions — same closure rule as
233
+ ``compute_board_keep_ids`` — so an in-flight review never loses its
234
+ target and a kept row never loses an in-flight review.
235
+ """
236
+ original_count = len(assignments)
237
+ protected: list[dict] = []
238
+ terminal: list[dict] = []
239
+ for row in assignments:
240
+ status = (row.get("status") or "").lower()
241
+ key = (row.get("repo_name"), row.get("issue_number"))
242
+ if status not in TERMINAL_STATUSES or key in open_issue_keys:
243
+ protected.append(row)
244
+ else:
245
+ terminal.append(row)
246
+
247
+ if len(terminal) <= MAX_TERMINAL_ASSIGNMENTS:
248
+ return 0
249
+
250
+ terminal.sort(
251
+ key=lambda r: r.get("finished_at") or r.get("dispatched_at") or 0.0,
252
+ reverse=True,
253
+ )
254
+ keep_ids = {
255
+ r.get("assignment_id")
256
+ for r in protected + terminal[:MAX_TERMINAL_ASSIGNMENTS]
257
+ if r.get("assignment_id")
258
+ }
259
+
260
+ by_id = {r.get("assignment_id"): r for r in assignments if r.get("assignment_id")}
261
+ reviews_of: dict[str, list[str]] = {}
262
+ for aid, r in by_id.items():
263
+ tgt = r.get("review_of_assignment_id")
264
+ if tgt:
265
+ reviews_of.setdefault(tgt, []).append(aid)
266
+ frontier = list(keep_ids)
267
+ while frontier:
268
+ aid = frontier.pop()
269
+ tgt = by_id.get(aid, {}).get("review_of_assignment_id")
270
+ if tgt and tgt in by_id and tgt not in keep_ids:
271
+ keep_ids.add(tgt)
272
+ frontier.append(tgt)
273
+ for rev in reviews_of.get(aid, ()):
274
+ if rev not in keep_ids:
275
+ keep_ids.add(rev)
276
+ frontier.append(rev)
277
+
278
+ assignments[:] = [r for r in assignments if r.get("assignment_id") in keep_ids]
279
+ return original_count - len(assignments)
280
+
281
+
282
+ def bound_board_payload(projection: dict) -> None:
283
+ """Bound every unbounded free-text field AND the collection's row count
284
+ in a ``/board`` projection (#1337 bounded width; #1791 adds cardinality).
285
+
286
+ Called by the daemon's board builder AFTER the derived sections
287
+ (milestone work orders, plan roster, epic children) are computed — those
288
+ parse full issue bodies server-side and must see them unbounded.
289
+ """
290
+ issues = projection.get("issues")
291
+ assignments = projection.get("assignments")
292
+
293
+ dropped = 0
294
+ if isinstance(assignments, list) and isinstance(issues, (list, tuple)):
295
+ dropped = cap_terminal_assignments(assignments, _open_issue_keys(issues))
296
+
297
+ for row in projection.get("assignments", ()):
298
+ if isinstance(row, dict):
299
+ bound_assignment_row(row)
300
+ for row in projection.get("issues", ()):
301
+ if isinstance(row, dict):
302
+ bound_issue_row(row)
303
+
304
+ # #1791: truncation must be visible to clients — additive-only, same
305
+ # convention as the per-field ``<field>_truncated`` flags above, so a
306
+ # client that never received a trimmed board sees an unchanged shape.
307
+ if dropped:
308
+ projection["board_truncated"] = True
309
+ projection["board_truncated_assignments"] = dropped