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,376 @@
1
+ """Deterministic seeded-board fixture mode for the web dashboard (#1538).
2
+
3
+ ``coord web --fixture tests/fixtures/board-pipeline-basic.json`` boots the
4
+ **real** dashboard app — same routes, same handlers, same
5
+ ``compute_pipeline``/``dataclasses.asdict`` serialization — but sourced from a
6
+ JSON fixture instead of ``~/.coord/coord.db``. It is the web twin of
7
+ ``coord-tui``'s ``make_test_app(BoardData)``: an acceptance suite can assert
8
+ against a board that does not move under it, with no live fleet, no network
9
+ and no daemon.
10
+
11
+ Two rules shape this module:
12
+
13
+ 1. **No parallel fake read path.** The fixture only replaces the *data
14
+ source*. ``/api/board`` and ``/api/pipeline`` still run
15
+ ``coord.client.board_from_payload`` → ``coord.pipeline.compute_pipeline`` →
16
+ ``asdict``, so the tests keep testing the real contract. The fixture's
17
+ board block is literally the daemon's ``/board`` wire payload, reconstructed
18
+ by the same function a thin client uses.
19
+ 2. **Writes are recorded, never executed.** ``POST /api/pipeline/action``,
20
+ ``/api/approve``, ``/api/reject`` and ``/api/chat`` return their normal
21
+ success shape and append to an in-memory :class:`RecordedAction` log.
22
+ Nothing is dispatched, no subprocess is spawned, no money is ever spent.
23
+
24
+ Fixture schema (every key optional except ``board``)::
25
+
26
+ {
27
+ "now": 1750000000.0, # frozen clock — keeps /api/pipeline byte-stable
28
+ "board": { # the daemon GET /board payload shape
29
+ "round_number": 7,
30
+ "assignments": [ {...assignment row...} ],
31
+ "plans": {}, # assignment_id -> plan object
32
+ "notifications": [] # [{"assignment_id": ...}]
33
+ },
34
+ "merge_queue": [ {...coord.merge_queue.QueuedMerge fields...} ],
35
+ "proposals": [ {...coord.models.Proposal fields...} ],
36
+ "machines": [ {...GET /api/machines entry...} ],
37
+ "sessions": [ {...GET /api/sessions entry...} ],
38
+ "review_findings": {"rev-1": {"verdict": "approve", "body": "..."}},
39
+ "diffs": {"work-1": "diff --git ..."},
40
+ "chat_reply": "canned /api/chat response text",
41
+ "events": [ {"after": 0.25, "type": "board_updated", "data": {}} ],
42
+ "autoplay_events": false, # play the script at startup too (default: no)
43
+ "config": { ...coordinator.yml mapping... }
44
+ }
45
+
46
+ The event script does **not** play on startup by default: a timeline racing the
47
+ client's connect is precisely the nondeterminism this whole mode exists to
48
+ remove. Drive it explicitly with ``POST /api/fixture/events/replay`` once the
49
+ client is subscribed (a late subscriber can still catch up via
50
+ ``Last-Event-ID``). Set ``autoplay_events: true`` for a hands-off demo board.
51
+
52
+ ``board`` may also be spelled at the top level (``assignments`` /
53
+ ``round_number`` as siblings of ``merge_queue``), which is exactly what
54
+ ``scripts/gen_board_fixture.py`` emits — so a golden ``/board`` capture drops
55
+ in unchanged.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import copy
61
+ import json
62
+ from dataclasses import MISSING, asdict, dataclass, field, fields
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ from coord.config import Config
67
+ from coord.models import Board, Proposal
68
+
69
+ __all__ = [
70
+ "FixtureError",
71
+ "RecordedAction",
72
+ "ScriptedEvent",
73
+ "FixtureServer",
74
+ "load_fixture",
75
+ "parse_fixture",
76
+ ]
77
+
78
+
79
+ class FixtureError(ValueError):
80
+ """A fixture file is missing, unreadable, or structurally invalid."""
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ScriptedEvent:
85
+ """One entry of the fixture's SSE script.
86
+
87
+ ``after`` is the delay in seconds *before* this event is published,
88
+ relative to the previous scripted event (so a script reads as a timeline,
89
+ not a set of absolute offsets to keep in sync by hand).
90
+ """
91
+
92
+ type: str
93
+ data: Any = None
94
+ after: float = 0.0
95
+
96
+
97
+ @dataclass
98
+ class RecordedAction:
99
+ """A write the dashboard would have executed, captured instead.
100
+
101
+ ``seq`` is a 1-based monotonic counter — the stable ordering key for
102
+ assertions. ``at`` is the fixture's frozen clock, so two runs against the
103
+ same fixture produce an identical log.
104
+ """
105
+
106
+ seq: int
107
+ endpoint: str
108
+ method: str
109
+ action: str | None
110
+ payload: dict
111
+ at: float | None = None
112
+
113
+ def to_dict(self) -> dict:
114
+ return asdict(self)
115
+
116
+
117
+ def _filter_kwargs(cls, raw: dict, *, what: str) -> dict:
118
+ """Keep only *cls*'s dataclass fields — tolerant like ``row_to_assignment``.
119
+
120
+ Unknown keys are dropped rather than raising, so a fixture captured from a
121
+ newer/older schema still loads; missing *required* fields raise a
122
+ :class:`FixtureError` naming them, which is the failure worth being loud
123
+ about.
124
+ """
125
+ if not isinstance(raw, dict):
126
+ raise FixtureError(f"{what} entries must be mappings, got {type(raw).__name__}")
127
+ names = {f.name for f in fields(cls)}
128
+ kwargs = {k: v for k, v in raw.items() if k in names}
129
+ missing = [
130
+ f.name
131
+ for f in fields(cls)
132
+ if f.default is MISSING
133
+ and f.default_factory is MISSING
134
+ and f.name not in kwargs
135
+ ]
136
+ if missing:
137
+ raise FixtureError(f"{what} entry is missing required field(s): {', '.join(missing)}")
138
+ return kwargs
139
+
140
+
141
+ @dataclass
142
+ class FixtureServer:
143
+ """The seeded board + the recorded-write log behind ``coord web --fixture``.
144
+
145
+ Every accessor rebuilds its objects from the raw payload, so a handler that
146
+ mutates the board it was handed (``unstick`` calls
147
+ ``board.mark_failed_by_id``) cannot leak that mutation into the next
148
+ request. That is what makes "two runs produce byte-identical output" hold
149
+ *within* a process as well as across processes.
150
+ """
151
+
152
+ board_payload: dict = field(default_factory=lambda: {"assignments": [], "round_number": 0})
153
+ merge_queue_raw: list = field(default_factory=list)
154
+ proposals_raw: list = field(default_factory=list)
155
+ machines_raw: list = field(default_factory=list)
156
+ sessions_raw: list = field(default_factory=list)
157
+ review_findings_raw: dict = field(default_factory=dict)
158
+ diffs: dict = field(default_factory=dict)
159
+ chat_reply: str = "fixture mode: the coordinator assistant is not wired up."
160
+ events: list[ScriptedEvent] = field(default_factory=list)
161
+ autoplay_events: bool = False
162
+ now: float | None = None
163
+ config_raw: dict | None = None
164
+ path: Path | None = None
165
+
166
+ _actions: list[RecordedAction] = field(default_factory=list, repr=False)
167
+
168
+ # ── Read side (the real handlers pull from here) ────────────────────────
169
+
170
+ def board(self) -> Board:
171
+ """Reconstruct a :class:`Board` through the real ``/board`` deserializer."""
172
+ from coord.client import board_from_payload # noqa: PLC0415
173
+
174
+ return board_from_payload(copy.deepcopy(self.board_payload))
175
+
176
+ def merge_queue(self) -> list:
177
+ """The seeded merge queue as real ``QueuedMerge`` objects."""
178
+ from coord.merge_queue import QueuedMerge # noqa: PLC0415
179
+
180
+ return [
181
+ QueuedMerge(**_filter_kwargs(QueuedMerge, copy.deepcopy(raw), what="merge_queue"))
182
+ for raw in self.merge_queue_raw
183
+ ]
184
+
185
+ def proposals(self) -> list[Proposal]:
186
+ return [
187
+ Proposal(**_filter_kwargs(Proposal, copy.deepcopy(raw), what="proposals"))
188
+ for raw in self.proposals_raw
189
+ ]
190
+
191
+ def machines(self) -> list[dict]:
192
+ return copy.deepcopy(self.machines_raw)
193
+
194
+ def sessions(self) -> list[dict]:
195
+ return copy.deepcopy(self.sessions_raw)
196
+
197
+ def review_findings(self, assignment_id: str) -> tuple[str, str] | None:
198
+ """Stand-in for ``coord.state.load_assignment_review_findings``.
199
+
200
+ Same ``(verdict, body) | None`` contract, keyed by the **review**
201
+ assignment id.
202
+ """
203
+ entry = self.review_findings_raw.get(assignment_id)
204
+ if not entry:
205
+ return None
206
+ return str(entry.get("verdict") or ""), str(entry.get("body") or "")
207
+
208
+ def diff(self, assignment_id: str) -> str:
209
+ return self.diffs.get(assignment_id, "")
210
+
211
+ def config(self, fallback: Config | None = None) -> Config:
212
+ """Resolve the Config the seeded server runs with.
213
+
214
+ An inline ``config`` block in the fixture wins (parsed by the real
215
+ :func:`coord.config.parse_mapping`, so it is validated exactly like a
216
+ coordinator.yml); otherwise *fallback*; otherwise an empty Config whose
217
+ defaults are enough for ``compute_pipeline``.
218
+ """
219
+ if self.config_raw is not None:
220
+ from coord.config import parse_mapping # noqa: PLC0415
221
+
222
+ return parse_mapping(copy.deepcopy(self.config_raw))
223
+ if fallback is not None:
224
+ return fallback
225
+ return Config(repos=[], machines=[])
226
+
227
+ # ── Write side (recorded, never executed) ──────────────────────────────
228
+
229
+ def record(
230
+ self,
231
+ endpoint: str,
232
+ payload: dict | None = None,
233
+ *,
234
+ method: str = "POST",
235
+ action: str | None = None,
236
+ ) -> RecordedAction:
237
+ entry = RecordedAction(
238
+ seq=len(self._actions) + 1,
239
+ endpoint=endpoint,
240
+ method=method,
241
+ action=action,
242
+ payload=copy.deepcopy(payload) if payload else {},
243
+ at=self.now,
244
+ )
245
+ self._actions.append(entry)
246
+ return entry
247
+
248
+ @property
249
+ def actions(self) -> list[RecordedAction]:
250
+ """The recorded-write log, oldest first."""
251
+ return list(self._actions)
252
+
253
+ def clear_actions(self) -> int:
254
+ n = len(self._actions)
255
+ self._actions.clear()
256
+ return n
257
+
258
+
259
+ # ── Loading ─────────────────────────────────────────────────────────────────
260
+
261
+ def _as_list(raw: Any, key: str) -> list:
262
+ if raw is None:
263
+ return []
264
+ if not isinstance(raw, list):
265
+ raise FixtureError(f"fixture '{key}' must be a list, got {type(raw).__name__}")
266
+ return raw
267
+
268
+
269
+ def _as_dict(raw: Any, key: str) -> dict:
270
+ if raw is None:
271
+ return {}
272
+ if not isinstance(raw, dict):
273
+ raise FixtureError(f"fixture '{key}' must be a mapping, got {type(raw).__name__}")
274
+ return raw
275
+
276
+
277
+ def _parse_events(raw: Any) -> list[ScriptedEvent]:
278
+ events: list[ScriptedEvent] = []
279
+ for i, entry in enumerate(_as_list(raw, "events")):
280
+ if not isinstance(entry, dict):
281
+ raise FixtureError(f"events[{i}] must be a mapping, got {type(entry).__name__}")
282
+ etype = entry.get("type")
283
+ if not etype or not isinstance(etype, str):
284
+ raise FixtureError(f"events[{i}].type is required (string)")
285
+ try:
286
+ after = float(entry.get("after", 0.0) or 0.0)
287
+ except (TypeError, ValueError) as exc:
288
+ raise FixtureError(f"events[{i}].after must be a number") from exc
289
+ if after < 0:
290
+ raise FixtureError(f"events[{i}].after must be >= 0")
291
+ events.append(ScriptedEvent(type=etype, data=entry.get("data"), after=after))
292
+ return events
293
+
294
+
295
+ def parse_fixture(raw: Any, *, path: Path | None = None) -> FixtureServer:
296
+ """Validate a decoded fixture mapping into a :class:`FixtureServer`."""
297
+ if not isinstance(raw, dict):
298
+ raise FixtureError(f"fixture must be a JSON object, got {type(raw).__name__}")
299
+
300
+ board_raw = raw.get("board")
301
+ if board_raw is None:
302
+ # Top-level /board payload shape (what scripts/gen_board_fixture.py
303
+ # emits) — accept it verbatim so a golden capture drops straight in.
304
+ if "assignments" in raw:
305
+ board_raw = raw
306
+ else:
307
+ raise FixtureError(
308
+ "fixture must define a 'board' object (or top-level 'assignments')"
309
+ )
310
+ board_raw = _as_dict(board_raw, "board")
311
+ if not isinstance(board_raw.get("assignments", []), list):
312
+ raise FixtureError("fixture board.assignments must be a list")
313
+
314
+ board_payload = {
315
+ "assignments": list(board_raw.get("assignments") or []),
316
+ "round_number": board_raw.get("round_number") or 0,
317
+ "plans": _as_dict(board_raw.get("plans"), "board.plans"),
318
+ "notifications": _as_list(board_raw.get("notifications"), "board.notifications"),
319
+ }
320
+
321
+ now = raw.get("now")
322
+ if now is not None:
323
+ try:
324
+ now = float(now)
325
+ except (TypeError, ValueError) as exc:
326
+ raise FixtureError("fixture 'now' must be a number (epoch seconds)") from exc
327
+
328
+ config_raw = raw.get("config")
329
+ if config_raw is not None and not isinstance(config_raw, dict):
330
+ raise FixtureError("fixture 'config' must be a mapping (coordinator.yml shape)")
331
+
332
+ chat_reply = raw.get("chat_reply")
333
+ if chat_reply is not None and not isinstance(chat_reply, str):
334
+ raise FixtureError("fixture 'chat_reply' must be a string")
335
+
336
+ diffs = _as_dict(raw.get("diffs"), "diffs")
337
+ if not all(isinstance(v, str) for v in diffs.values()):
338
+ raise FixtureError("fixture 'diffs' values must be strings")
339
+
340
+ server = FixtureServer(
341
+ board_payload=board_payload,
342
+ merge_queue_raw=_as_list(raw.get("merge_queue"), "merge_queue"),
343
+ proposals_raw=_as_list(raw.get("proposals"), "proposals"),
344
+ machines_raw=_as_list(raw.get("machines"), "machines"),
345
+ sessions_raw=_as_list(raw.get("sessions"), "sessions"),
346
+ review_findings_raw=_as_dict(raw.get("review_findings"), "review_findings"),
347
+ diffs=diffs,
348
+ events=_parse_events(raw.get("events")),
349
+ autoplay_events=bool(raw.get("autoplay_events", False)),
350
+ now=now,
351
+ config_raw=config_raw,
352
+ path=path,
353
+ )
354
+ if chat_reply is not None:
355
+ server.chat_reply = chat_reply
356
+
357
+ # Fail at load time, not on the first request: a malformed merge_queue /
358
+ # proposals entry should stop `coord web --fixture` from starting rather
359
+ # than 500 halfway through an acceptance run.
360
+ server.merge_queue()
361
+ server.proposals()
362
+ return server
363
+
364
+
365
+ def load_fixture(path: str | Path) -> FixtureServer:
366
+ """Read and validate a fixture JSON file."""
367
+ p = Path(path).expanduser()
368
+ try:
369
+ text = p.read_text()
370
+ except OSError as exc:
371
+ raise FixtureError(f"could not read fixture {p}: {exc}") from exc
372
+ try:
373
+ raw = json.loads(text)
374
+ except json.JSONDecodeError as exc:
375
+ raise FixtureError(f"invalid JSON in fixture {p}: {exc}") from exc
376
+ return parse_fixture(raw, path=p)