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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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)
|