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/openapi.py ADDED
@@ -0,0 +1,404 @@
1
+ """Shared OpenAPI 3 builder for the three hand-rolled Starlette apps (#757).
2
+
3
+ ``coord/agent_app.py`` (7433), ``coord/serve_app.py`` (7435), and
4
+ ``coord/dashboard/server.py`` (7434) each declare a plain list of Starlette
5
+ ``Route`` objects with no machine-readable contract. This module gives every
6
+ app a way to serve ``GET /openapi.json`` + a browsable ``GET /docs`` page
7
+ (Swagger UI) built from the *same* Python types that already define the wire
8
+ contract, mirroring the #750 codegen approach (introspect
9
+ ``dataclasses.fields()`` / ``typing.get_type_hints()`` rather than
10
+ hand-writing a parallel schema):
11
+
12
+ - :func:`dataclass_schema` walks a dataclass (recursively, for nested
13
+ dataclass fields) into a JSON Schema, registering it under
14
+ ``components/schemas`` and returning a ``$ref``. This is what fully
15
+ specifies ``POST /assign`` (``AssignmentSpec`` request / ``AgentAssignment``
16
+ response) on the agent app.
17
+ - :func:`sqlite_table_schema` builds a JSON Schema straight from
18
+ ``PRAGMA table_info`` for a live (migrated) SQLite connection — because, per
19
+ ``scripts/gen_board_fixture.py``, the daemon's ``/board`` wire schema *is*
20
+ the SQLite DDL (``coord/db.py``), not a dataclass. This fully specifies
21
+ ``GET /board`` on the daemon app.
22
+ - :func:`build_spec` assembles the OpenAPI 3.0.3 document.
23
+ - :func:`openapi_and_docs_routes` returns the two ``Route`` objects
24
+ (``/openapi.json`` serving the spec, ``/docs`` serving a Swagger UI page)
25
+ every ``build_app()`` appends to its route list.
26
+ - :func:`declared_routes` / :func:`spec_routes` extract ``(method, path)``
27
+ sets from, respectively, the real Starlette route table and the generated
28
+ spec, so a test can assert they're identical and the spec can't silently
29
+ drift from the actual routes (the #757 acceptance criterion).
30
+
31
+ This is the intended input for #750's codegen: once a surface's OpenAPI
32
+ ``components/schemas`` are populated here, the TS/Rust generators can point
33
+ at ``GET /openapi.json`` instead of (or in addition to) introspecting the
34
+ Python dataclasses directly.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import dataclasses
40
+ import sqlite3
41
+ import types as _types
42
+ import typing
43
+ from typing import Any
44
+
45
+ from starlette.requests import Request
46
+ from starlette.responses import HTMLResponse, JSONResponse
47
+ from starlette.routing import BaseRoute, Route
48
+
49
+ OPENAPI_VERSION = "3.0.3"
50
+
51
+
52
+ # ── dataclass → JSON Schema ──────────────────────────────────────────────────
53
+
54
+ def _scalar_schema(tp: object) -> dict[str, Any] | None:
55
+ if tp is str:
56
+ return {"type": "string"}
57
+ if tp is bool:
58
+ return {"type": "boolean"}
59
+ if tp is int:
60
+ return {"type": "integer"}
61
+ if tp is float:
62
+ return {"type": "number"}
63
+ return None
64
+
65
+
66
+ def json_schema_for(tp: object, components: dict[str, Any]) -> dict[str, Any]:
67
+ """Map a resolved Python type (``typing.get_type_hints`` output) to a
68
+ JSON Schema fragment, registering any nested dataclass into
69
+ ``components`` and returning a ``$ref`` for it.
70
+
71
+ Mirrors ``scripts/codegen.py``'s ``ts_type()`` structurally, just
72
+ targeting JSON Schema (OpenAPI 3.0's dialect — ``nullable: true`` rather
73
+ than a ``"null"`` member of a ``type`` array) instead of TypeScript.
74
+ """
75
+ if tp is type(None):
76
+ return {"type": "null"}
77
+ if tp is typing.Any:
78
+ return {}
79
+ if isinstance(tp, type):
80
+ scalar = _scalar_schema(tp)
81
+ if scalar is not None:
82
+ return scalar
83
+ if dataclasses.is_dataclass(tp):
84
+ return dataclass_schema(tp, components)
85
+ if tp is dict:
86
+ return {"type": "object"}
87
+ if tp is list:
88
+ return {"type": "array", "items": {}}
89
+
90
+ origin = typing.get_origin(tp)
91
+ args = typing.get_args(tp)
92
+
93
+ if origin in (list, typing.List): # noqa: UP006
94
+ (inner,) = args
95
+ return {"type": "array", "items": json_schema_for(inner, components)}
96
+ if origin in (dict, typing.Dict): # noqa: UP006
97
+ if len(args) == 2:
98
+ return {
99
+ "type": "object",
100
+ "additionalProperties": json_schema_for(args[1], components),
101
+ }
102
+ return {"type": "object"}
103
+ if origin is typing.Union or origin is _types.UnionType:
104
+ non_none = [a for a in args if a is not type(None)]
105
+ nullable = len(non_none) != len(args)
106
+ if len(non_none) == 1:
107
+ schema = dict(json_schema_for(non_none[0], components))
108
+ else:
109
+ schema = {"anyOf": [json_schema_for(a, components) for a in non_none]}
110
+ if nullable:
111
+ schema["nullable"] = True
112
+ return schema
113
+
114
+ raise TypeError(
115
+ f"coord/openapi.py: no JSON Schema mapping for Python type {tp!r} — "
116
+ "add one to json_schema_for()."
117
+ )
118
+
119
+
120
+ def dataclass_schema(cls: type, components: dict[str, Any]) -> dict[str, Any]:
121
+ """Register *cls* (a dataclass) into ``components`` and return a ``$ref``.
122
+
123
+ Idempotent — a dataclass already registered is not re-walked, so cyclic /
124
+ repeated references (e.g. several endpoints sharing ``Assignment``) are
125
+ safe.
126
+ """
127
+ name = cls.__name__
128
+ ref = {"$ref": f"#/components/schemas/{name}"}
129
+ if name in components:
130
+ return ref
131
+
132
+ # Reserve the slot before recursing so a self-referential dataclass
133
+ # doesn't recurse forever.
134
+ components[name] = {}
135
+ hints = typing.get_type_hints(cls)
136
+ properties: dict[str, Any] = {}
137
+ required: list[str] = []
138
+ for f in dataclasses.fields(cls):
139
+ properties[f.name] = json_schema_for(hints[f.name], components)
140
+ if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING: # type: ignore[misc]
141
+ required.append(f.name)
142
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
143
+ if required:
144
+ schema["required"] = required
145
+ components[name] = schema
146
+ return ref
147
+
148
+
149
+ # ── SQLite table → JSON Schema (the /board wire schema IS the DDL) ──────────
150
+
151
+ # Per-column overrides for JSON-encoded TEXT columns (decoded to a native
152
+ # object/array before hitting the wire — see coord/dao.py:_JSON_COLUMNS).
153
+ # Anything JSON-decoded but not listed here still gets a properties entry,
154
+ # just typed as "any" ({}) rather than a precise array/object shape.
155
+ _JSON_COLUMN_SHAPES: dict[tuple[str, str], dict[str, Any]] = {
156
+ ("assignments", "files_allowed"): {"type": "array", "items": {"type": "string"}},
157
+ ("assignments", "files_forbidden"): {"type": "array", "items": {"type": "string"}},
158
+ ("assignments", "required_gates"): {"type": "array", "items": {"type": "string"}},
159
+ ("assignments", "smoke_tests"): {"type": "array", "items": {"type": "string"}},
160
+ ("assignments", "plan"): {"type": "object"},
161
+ ("assignments", "test_plan"): {"type": "object"},
162
+ ("proposals", "files_likely"): {"type": "array", "items": {"type": "string"}},
163
+ ("proposals", "required_gates"): {"type": "array", "items": {"type": "string"}},
164
+ ("merge_queue", "required_gates"): {"type": "array", "items": {"type": "string"}},
165
+ ("issues", "labels"): {"type": "array", "items": {"type": "string"}},
166
+ ("machines", "capabilities"): {"type": "array", "items": {"type": "string"}},
167
+ ("machines", "repos"): {"type": "array", "items": {"type": "string"}},
168
+ }
169
+
170
+ _SQLITE_AFFINITY_TO_SCHEMA: dict[str, dict[str, Any]] = {
171
+ "INTEGER": {"type": "integer"},
172
+ "REAL": {"type": "number"},
173
+ "TEXT": {"type": "string"},
174
+ "BLOB": {"type": "string"},
175
+ }
176
+
177
+
178
+ def sqlite_table_schema(
179
+ conn: sqlite3.Connection,
180
+ table: str,
181
+ *,
182
+ drop: frozenset[str] = frozenset(),
183
+ json_columns: frozenset[str] = frozenset(),
184
+ ) -> dict[str, Any]:
185
+ """Build an ``object`` JSON Schema straight from ``PRAGMA table_info``.
186
+
187
+ *drop* — columns the projection omits (e.g. ``assignments.briefing``,
188
+ see ``coord/dao.py:_DROP_COLUMNS``).
189
+ *json_columns* — columns decoded from a JSON-TEXT column to a native
190
+ value (see ``coord/dao.py:_JSON_COLUMNS``); typed via
191
+ :data:`_JSON_COLUMN_SHAPES` when known, else left as "any".
192
+
193
+ Every column is nullable unless SQLite's ``PRAGMA table_info`` reports
194
+ ``notnull=1`` — SQLite's own affinity typing is loose, and several
195
+ columns (e.g. ``finished_at`` while an assignment is running) are
196
+ legitimately absent until a later write.
197
+ """
198
+ properties: dict[str, Any] = {}
199
+ required: list[str] = []
200
+ for _cid, name, decl_type, notnull, _dflt, _pk in conn.execute(
201
+ f"PRAGMA table_info({table})" # noqa: S608 — table name is a literal, not user input
202
+ ):
203
+ if name in drop:
204
+ continue
205
+ if name in json_columns:
206
+ schema = dict(_JSON_COLUMN_SHAPES.get((table, name), {}))
207
+ else:
208
+ affinity = (decl_type or "TEXT").split("(")[0].upper()
209
+ schema = dict(_SQLITE_AFFINITY_TO_SCHEMA.get(affinity, {"type": "string"}))
210
+ if notnull:
211
+ required.append(name)
212
+ elif schema:
213
+ schema["nullable"] = True
214
+ properties[name] = schema
215
+ out: dict[str, Any] = {"type": "object", "properties": properties}
216
+ if required:
217
+ out["required"] = required
218
+ return out
219
+
220
+
221
+ # ── spec assembly ─────────────────────────────────────────────────────────
222
+
223
+ def build_spec(
224
+ *,
225
+ title: str,
226
+ version: str,
227
+ description: str = "",
228
+ paths: dict[str, Any],
229
+ components: dict[str, Any] | None = None,
230
+ servers: list[dict[str, str]] | None = None,
231
+ ) -> dict[str, Any]:
232
+ """Assemble a minimal OpenAPI 3.0.3 document."""
233
+ spec: dict[str, Any] = {
234
+ "openapi": OPENAPI_VERSION,
235
+ "info": {"title": title, "version": version, "description": description},
236
+ "paths": paths,
237
+ }
238
+ if servers:
239
+ spec["servers"] = servers
240
+ if components:
241
+ spec["components"] = {"schemas": components}
242
+ return spec
243
+
244
+
245
+ def openapi_and_docs_routes(
246
+ spec: dict[str, Any],
247
+ *,
248
+ openapi_path: str = "/openapi.json",
249
+ docs_path: str = "/docs",
250
+ ) -> list[BaseRoute]:
251
+ """Return the ``[GET /openapi.json, GET /docs]`` routes every app appends.
252
+
253
+ ``/docs`` is a small Swagger UI page (CDN-hosted ``swagger-ui-dist``,
254
+ pinned version) pointed at ``/openapi.json`` — no new Python dependency,
255
+ consistent with how e.g. FastAPI's default docs page works.
256
+ """
257
+
258
+ async def openapi_json(_request: Request) -> JSONResponse:
259
+ return JSONResponse(spec)
260
+
261
+ title = spec.get("info", {}).get("title", "API")
262
+ docs_html = f"""<!DOCTYPE html>
263
+ <html>
264
+ <head>
265
+ <title>{title} — docs</title>
266
+ <meta charset="utf-8" />
267
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
268
+ </head>
269
+ <body>
270
+ <div id="swagger-ui"></div>
271
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
272
+ <script>
273
+ window.onload = () => {{
274
+ window.ui = SwaggerUIBundle({{
275
+ url: "{openapi_path}",
276
+ dom_id: "#swagger-ui",
277
+ }});
278
+ }};
279
+ </script>
280
+ </body>
281
+ </html>"""
282
+
283
+ async def docs(_request: Request) -> HTMLResponse:
284
+ return HTMLResponse(docs_html)
285
+
286
+ return [
287
+ Route(openapi_path, openapi_json, methods=["GET"], include_in_schema=False),
288
+ Route(docs_path, docs, methods=["GET"], include_in_schema=False),
289
+ ]
290
+
291
+
292
+ # ── validate a JSON value against a generated schema (#748 tie-in) ─────────
293
+
294
+ _JSON_TYPE_CHECK: dict[str, type | tuple[type, ...]] = {
295
+ "string": str,
296
+ # bool is an int subclass in Python — exclude it from the integer/number
297
+ # check so a JSON `true` doesn't pass as a number.
298
+ "integer": (int,),
299
+ "number": (int, float),
300
+ "boolean": bool,
301
+ "array": list,
302
+ "object": dict,
303
+ }
304
+
305
+
306
+ def validate_json_schema(
307
+ instance: Any, schema: dict[str, Any], components: dict[str, Any], *, path: str = "$"
308
+ ) -> list[str]:
309
+ """Validate *instance* against a schema produced by this module.
310
+
311
+ Not a general-purpose JSON Schema validator (no external dependency is
312
+ pulled in for it) — just enough of the dialect this module itself emits
313
+ (``type``, ``nullable``, ``properties``/``required``, ``items``,
314
+ ``additionalProperties``, ``$ref``, ``anyOf``) to prove the #757 specs
315
+ actually describe a real payload, e.g. the #748 golden ``/board``
316
+ fixture. Returns a list of human-readable error strings; empty means
317
+ valid.
318
+ """
319
+ if "$ref" in schema:
320
+ name = schema["$ref"].rsplit("/", 1)[-1]
321
+ target = components.get(name)
322
+ if target is None:
323
+ return [f"{path}: unresolvable $ref {schema['$ref']!r}"]
324
+ return validate_json_schema(instance, target, components, path=path)
325
+
326
+ if "anyOf" in schema:
327
+ errors_per_branch = [
328
+ validate_json_schema(instance, branch, components, path=path)
329
+ for branch in schema["anyOf"]
330
+ ]
331
+ if any(not errs for errs in errors_per_branch):
332
+ return []
333
+ return [f"{path}: matched none of {len(schema['anyOf'])} anyOf branches"]
334
+
335
+ if instance is None:
336
+ if schema.get("nullable") or schema.get("type") == "null" or not schema:
337
+ return []
338
+ return [f"{path}: null but schema is not nullable ({schema.get('type')!r})"]
339
+
340
+ json_type = schema.get("type")
341
+ if json_type is None:
342
+ return [] # untyped ("any") schema — nothing to check
343
+
344
+ py_type = _JSON_TYPE_CHECK.get(json_type)
345
+ if py_type is not None and not isinstance(instance, py_type):
346
+ return [f"{path}: expected {json_type}, got {type(instance).__name__}"]
347
+
348
+ errors: list[str] = []
349
+ if json_type == "object" and isinstance(instance, dict):
350
+ properties = schema.get("properties", {})
351
+ for req in schema.get("required", ()):
352
+ if req not in instance:
353
+ errors.append(f"{path}: missing required property {req!r}")
354
+ for key, value in instance.items():
355
+ prop_schema = properties.get(key)
356
+ if prop_schema is not None:
357
+ errors.extend(
358
+ validate_json_schema(value, prop_schema, components, path=f"{path}.{key}")
359
+ )
360
+ else:
361
+ addl = schema.get("additionalProperties")
362
+ if isinstance(addl, dict):
363
+ errors.extend(
364
+ validate_json_schema(value, addl, components, path=f"{path}.{key}")
365
+ )
366
+ elif json_type == "array" and isinstance(instance, list):
367
+ items_schema = schema.get("items")
368
+ if items_schema:
369
+ for i, item in enumerate(instance):
370
+ errors.extend(
371
+ validate_json_schema(item, items_schema, components, path=f"{path}[{i}]")
372
+ )
373
+ return errors
374
+
375
+
376
+ # ── route-inventory drift check ──────────────────────────────────────────────
377
+
378
+ def declared_routes(routes: list[BaseRoute]) -> set[tuple[str, str]]:
379
+ """``{(METHOD, path), ...}`` for every plain ``Route`` in *routes*.
380
+
381
+ Skips non-``Route`` entries (``Mount``/``StaticFiles`` — not a single
382
+ documentable JSON endpoint) and anything with ``include_in_schema=False``
383
+ (the meta ``/openapi.json`` and ``/docs`` routes themselves), and drops
384
+ the implicit ``HEAD``/``OPTIONS`` methods Starlette adds to every
385
+ ``GET``/any route.
386
+ """
387
+ out: set[tuple[str, str]] = set()
388
+ for route in routes:
389
+ if not isinstance(route, Route) or not route.include_in_schema:
390
+ continue
391
+ for method in route.methods or ["GET"]:
392
+ if method in ("HEAD", "OPTIONS"):
393
+ continue
394
+ out.add((method, route.path))
395
+ return out
396
+
397
+
398
+ def spec_routes(spec: dict[str, Any]) -> set[tuple[str, str]]:
399
+ """``{(METHOD, path), ...}`` declared in an OpenAPI document's ``paths``."""
400
+ out: set[tuple[str, str]] = set()
401
+ for path, methods in spec.get("paths", {}).items():
402
+ for method in methods:
403
+ out.add((method.upper(), path))
404
+ return out
coord/overlap_fence.py ADDED
@@ -0,0 +1,133 @@
1
+ """#1720: dispatch-time file-overlap fence, derived from live branch
2
+ footprints instead of the prompt-only heuristic in ``coord/brain.py``.
3
+
4
+ ``coord/brain.py``'s ``SYSTEM_PROMPT`` tells the planning LLM "if two issues
5
+ would touch overlapping files in the same repo, do NOT assign them
6
+ simultaneously" — but that only applies to ``coord plan``, and it works by
7
+ GUESSING an issue's future footprint from its body text before any code
8
+ exists. ``coord assign``/``coord drive`` (and the auto-loop's fix dispatch)
9
+ have nothing: the only thing standing between two concurrent workers and a
10
+ collision has been an operator hand-writing a `coord context add --pin` note.
11
+
12
+ This module makes the rule factual and puts it on the paths that actually
13
+ dispatch. At dispatch time each *currently running* work-like assignment in
14
+ the target repo already has a branch — its real footprint is one GitHub
15
+ compare call away (``git diff --name-only`` semantics via the compare API,
16
+ no local checkout required — see :func:`coord.github_ops.get_compare_files`).
17
+ That is measured, not inferred, and — critically — recomputed on every call,
18
+ never pinned: a hand-written pin note keeps warning about files long after
19
+ the other work merged and freed them; this has no such staleness because it
20
+ is thrown away and rebuilt from live board state on every dispatch.
21
+
22
+ Advisory only. This module never raises to its caller and never blocks a
23
+ dispatch — see :func:`compute_overlap_fence`'s docstring.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Callable
29
+
30
+ from coord.models import WORK_LIKE_TYPES, Assignment, Board
31
+
32
+ # Type of the optional fetcher callers may inject (tests; also the seam a
33
+ # future local-checkout fallback could use). Returns the changed file paths
34
+ # for `base...head`, or None if the diff couldn't be computed.
35
+ DiffFilesFetcher = Callable[[str, str, str], "list[str] | None"]
36
+
37
+
38
+ def _default_fetcher(repo_github: str, base: str, head: str) -> list[str] | None:
39
+ from coord import github_ops # noqa: PLC0415
40
+
41
+ return github_ops.get_compare_files(repo_github, base, head)
42
+
43
+
44
+ def _running_work_like(
45
+ board: Board, repo_name: str, *, exclude_issue_number: int | None,
46
+ ) -> list[Assignment]:
47
+ """Running WORK_LIKE_TYPES assignments in *repo_name* with a pushed
48
+ branch, excluding *exclude_issue_number* (the issue about to be
49
+ dispatched — relevant on a redispatch of an issue that already has a
50
+ running row, so it never fences against itself)."""
51
+ return [
52
+ a
53
+ for a in board.active
54
+ if a.status == "running"
55
+ and a.repo_name == repo_name
56
+ and a.type in WORK_LIKE_TYPES
57
+ and a.branch
58
+ and a.issue_number != exclude_issue_number
59
+ ]
60
+
61
+
62
+ def compute_overlap_fence(
63
+ repo_name: str,
64
+ repo_github: str,
65
+ base_branch: str,
66
+ *,
67
+ exclude_issue_number: int | None = None,
68
+ board: Board | None = None,
69
+ diff_files_fetcher: DiffFilesFetcher | None = None,
70
+ ) -> str:
71
+ """Return a markdown fence block naming files other *running* work-like
72
+ assignments in *repo_name* are touching, or ``""`` when there is nothing
73
+ to report (no running assignments, none with a pushed branch, or every
74
+ branch diff failed) — the caller prepends this with no special-casing,
75
+ same as the #603 issue-context digest.
76
+
77
+ Advisory, not a gate: this NEVER refuses or blocks — it only informs the
78
+ new worker so it can stop and report a genuine collision itself, mirroring
79
+ the wording an operator already hand-writes via
80
+ ``coord context add --pin`` (see docs/OPERATING_GOTCHAS.md).
81
+
82
+ Fails open at every layer, by construction:
83
+ - the whole board read is wrapped in `except Exception` — an unreachable
84
+ daemon/board must not block a dispatch;
85
+ - each per-assignment diff fetch is wrapped individually — one
86
+ unreadable/deleted/force-pushed branch is skipped, not fatal to the
87
+ others (fulfils "a branch whose diff cannot be computed is skipped,
88
+ dispatch proceeds");
89
+ - a branch with no pushed commits yet resolves to an empty (or ``None``)
90
+ file list either way and contributes nothing, never an error.
91
+
92
+ Never persisted — recomputed from live board state on every call, so
93
+ redispatching the same issue twice can't stack fences (there is nothing
94
+ to stack; the caller just gets a fresh string each time).
95
+ """
96
+ try:
97
+ if board is None:
98
+ from coord.board_service import read_board # noqa: PLC0415
99
+
100
+ board = read_board()
101
+ running = _running_work_like(
102
+ board, repo_name, exclude_issue_number=exclude_issue_number
103
+ )
104
+ except Exception: # noqa: BLE001 — advisory only; never block a dispatch
105
+ return ""
106
+ if not running:
107
+ return ""
108
+
109
+ fetcher = diff_files_fetcher or _default_fetcher
110
+ lines: list[str] = []
111
+ for a in running:
112
+ try:
113
+ files = fetcher(repo_github, base_branch, a.branch) # type: ignore[arg-type]
114
+ except Exception: # noqa: BLE001 — one bad branch must not sink the rest
115
+ files = None
116
+ if not files:
117
+ continue
118
+ file_list = ", ".join(f"`{f}`" for f in sorted(files))
119
+ lines.append(f"- Issue #{a.issue_number} (branch `{a.branch}`): {file_list}")
120
+
121
+ if not lines:
122
+ return ""
123
+
124
+ return (
125
+ "## ⚠️ Live file-overlap fence (#1720)\n"
126
+ "Other work is RUNNING RIGHT NOW in this repo and touching the files "
127
+ "listed below. This is informational, not a rule against parallel "
128
+ "work — but if your own changes would touch the SAME file(s), STOP "
129
+ "and report the conflict instead of editing around it or racing to "
130
+ "commit first:\n"
131
+ + "\n".join(lines)
132
+ + "\n"
133
+ )