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
coord/acceptance.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Core ``coord acceptance`` orchestration (#944, docs/ORACLE_LOOP.md).
|
|
2
|
+
|
|
3
|
+
Pure/testable logic shared by the ``coord acceptance run`` / ``record`` CLI
|
|
4
|
+
commands in ``coord/commands/acceptance.py``: manifest loading (test-id ->
|
|
5
|
+
issue slice mapping) and building the structured verdict payload from a
|
|
6
|
+
driver's parsed test results. Kept separate from the CLI so it can be unit
|
|
7
|
+
tested without Click's invocation machinery, mirroring the
|
|
8
|
+
``test_orchestrator.py`` / ``commands/test_gate.py`` split.
|
|
9
|
+
|
|
10
|
+
Layout this module expects (docs/ORACLE_LOOP.md "Layout"):
|
|
11
|
+
|
|
12
|
+
tests/acceptance/ms-NN/
|
|
13
|
+
contract.md # black-box surface (not read by this module)
|
|
14
|
+
mocks/ # viewable mocks == assertion fixtures
|
|
15
|
+
<suite files> # SEALED to the worker
|
|
16
|
+
manifest.(yml|json) # test-id -> issue-slice mapping
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from coord.config import Config
|
|
29
|
+
from coord.models import Machine, Repo
|
|
30
|
+
|
|
31
|
+
ACCEPTANCE_DIRNAME = "tests/acceptance"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ms_dirname(milestone_number: int) -> str:
|
|
35
|
+
"""The ``ms-NN`` directory name for *milestone_number* (docs/ORACLE_LOOP.md
|
|
36
|
+
"Layout"). Single source of truth for the naming convention so Gate A
|
|
37
|
+
(#930, ``coord acceptance mock``) and the manifest reader agree."""
|
|
38
|
+
return f"ms-{milestone_number}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gate_a_contract_path(milestone_number: int) -> str:
|
|
42
|
+
"""Repo-relative path to *milestone_number*'s Gate A contract
|
|
43
|
+
(docs/ORACLE_LOOP.md "Layout": ``tests/acceptance/ms-NN/contract.md``).
|
|
44
|
+
|
|
45
|
+
Used both by ``coord acceptance mock`` (#930, what it writes) and
|
|
46
|
+
``coord.milestone_dispatch.gate_a_status`` (what it checks for before
|
|
47
|
+
letting the milestone's issues dispatch).
|
|
48
|
+
"""
|
|
49
|
+
return f"{ACCEPTANCE_DIRNAME}/{ms_dirname(milestone_number)}/contract.md"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mocks_dir(milestone_number: int) -> str:
|
|
53
|
+
return f"{ACCEPTANCE_DIRNAME}/{ms_dirname(milestone_number)}/mocks"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Mock-fixture file extension -> the driver ``kind`` it implies (the SAME
|
|
57
|
+
# rule each ``AcceptanceDriverConfig.mock`` glob already encodes in
|
|
58
|
+
# coordinator.yml / docs/ORACLE_LOOP.md: ``"*.screen"`` for ``tui-tuidriver``,
|
|
59
|
+
# ``"*.out"`` for ``cli-pytest``, ``"*.html"`` for ``web-playwright`` (#1542
|
|
60
|
+
# — hand-authored, self-contained wireframes; see
|
|
61
|
+
# ``coord.agent.MOCK_AUTHOR_SYSTEM_PROMPT`` for the authoring rules and
|
|
62
|
+
# ``tests/acceptance/ms-example/mocks/`` for a worked example). Single
|
|
63
|
+
# source of truth for the mock-kind -> ``--for-path`` derivation (#1453
|
|
64
|
+
# review) — do not re-derive this mapping a second time anywhere else.
|
|
65
|
+
MOCK_EXT_TO_DRIVER_KIND: dict[str, str] = {
|
|
66
|
+
".screen": "tui-tuidriver",
|
|
67
|
+
".out": "cli-pytest",
|
|
68
|
+
".html": "web-playwright",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ForPathResolutionError(Exception):
|
|
73
|
+
"""A routed repo's ``--for-path`` could not be resolved unambiguously
|
|
74
|
+
from a milestone's Gate-A mocks. Message is operator-facing."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# (repo_github, dir_path, branch) -> filenames (not full paths) directly
|
|
78
|
+
# under that directory, or () when it doesn't exist. Injected so tests never
|
|
79
|
+
# hit `gh` — mirrors ``coord.milestone_dispatch.GateAFileExists``.
|
|
80
|
+
MockLister = Callable[[str, str, str], "tuple[str, ...]"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _default_list_mock_dir(repo_github: str, path: str, branch: str) -> "tuple[str, ...]":
|
|
84
|
+
from coord import github_ops # noqa: PLC0415
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return tuple(github_ops.list_repo_dir(repo_github, path, branch=branch))
|
|
88
|
+
except RuntimeError:
|
|
89
|
+
return ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_for_path(
|
|
93
|
+
config: Config,
|
|
94
|
+
repo_cfg: Repo,
|
|
95
|
+
milestone_number: int,
|
|
96
|
+
*,
|
|
97
|
+
list_mock_dir: MockLister | None = None,
|
|
98
|
+
) -> str | None:
|
|
99
|
+
"""Derive the ``--for-path`` glob a ROUTED repo's JIT acceptance-author
|
|
100
|
+
dispatch needs, from *milestone_number*'s Gate-A mock file kind.
|
|
101
|
+
|
|
102
|
+
SHARED helper (#1453 review finding 1, tracked for #1460's TUI-menu
|
|
103
|
+
equivalent too — do not duplicate this rule): the mock fixtures a
|
|
104
|
+
milestone's Gate-A contract ships under ``tests/acceptance/ms-NN/mocks/``
|
|
105
|
+
(already merged to the default branch by the time this is ever called —
|
|
106
|
+
:func:`coord.milestone_dispatch.gate_a_status` gates on exactly that)
|
|
107
|
+
have a file extension that implies exactly one driver ``kind``
|
|
108
|
+
(:data:`MOCK_EXT_TO_DRIVER_KIND`). Crossing that against
|
|
109
|
+
``acceptance.drivers.<repo>.routes[].kind`` picks the one route whose
|
|
110
|
+
``match`` glob is this milestone's ``--for-path``.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
- ``None`` when *repo_cfg* has no acceptance driver at all, or a FLAT
|
|
114
|
+
(unrouted) one — :meth:`coord.config.AcceptanceConfig.driver_for`
|
|
115
|
+
already resolves those with no path, so no ``--for-path`` is needed.
|
|
116
|
+
- the single matching route's ``match`` glob when resolution is
|
|
117
|
+
unambiguous.
|
|
118
|
+
|
|
119
|
+
Raises :class:`ForPathResolutionError` (operator-facing, mirrors
|
|
120
|
+
``coord.test_author.dispatch_test_author``'s "no route matched" message)
|
|
121
|
+
when the repo IS routed but resolution is ambiguous — no mocks found,
|
|
122
|
+
more than one mock kind present, or zero/more-than-one route declares
|
|
123
|
+
the implied kind. Callers should surface this rather than guess.
|
|
124
|
+
"""
|
|
125
|
+
entry = config.acceptance.drivers.get(repo_cfg.name)
|
|
126
|
+
if entry is None or not entry.routes:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
lister = list_mock_dir or _default_list_mock_dir
|
|
130
|
+
mocks_dir = _mocks_dir(milestone_number)
|
|
131
|
+
names = lister(repo_cfg.github, mocks_dir, repo_cfg.default_branch)
|
|
132
|
+
|
|
133
|
+
kinds = {
|
|
134
|
+
MOCK_EXT_TO_DRIVER_KIND[Path(name).suffix]
|
|
135
|
+
for name in names
|
|
136
|
+
if Path(name).suffix in MOCK_EXT_TO_DRIVER_KIND
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def _refuse(reason: str) -> "ForPathResolutionError":
|
|
140
|
+
routes = ", ".join(f"{r.match!r} ({r.kind})" for r in entry.routes)
|
|
141
|
+
return ForPathResolutionError(
|
|
142
|
+
f"repo {repo_cfg.name!r} has a routed acceptance driver ({routes}) "
|
|
143
|
+
f"but --for-path could not be derived from {mocks_dir!r}'s mock "
|
|
144
|
+
f"kind: {reason}. Pass --no-acceptance to skip JIT authoring, or "
|
|
145
|
+
f"dispatch by hand: coord acceptance author {repo_cfg.name} "
|
|
146
|
+
"<tracking_issue> --issue <N> --for-path <glob>"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not kinds:
|
|
150
|
+
raise _refuse(f"no recognized mock files found ({names!r})")
|
|
151
|
+
if len(kinds) > 1:
|
|
152
|
+
raise _refuse(f"mixed mock kinds found ({sorted(kinds)!r})")
|
|
153
|
+
kind = next(iter(kinds))
|
|
154
|
+
|
|
155
|
+
matches = [route.match for route in entry.routes if route.kind == kind]
|
|
156
|
+
if len(matches) != 1:
|
|
157
|
+
raise _refuse(
|
|
158
|
+
f"mock kind {kind!r} matches {len(matches)} routes, need exactly 1"
|
|
159
|
+
)
|
|
160
|
+
return matches[0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ManifestError(Exception):
|
|
164
|
+
"""Raised when a manifest file exists but is malformed."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _manifest_paths(acceptance_root: Path) -> list[Path]:
|
|
168
|
+
"""``ms-NN/manifest.(yml|yaml|json)`` paths under *acceptance_root*,
|
|
169
|
+
sorted for deterministic scan order. ``[]`` when the dir doesn't exist."""
|
|
170
|
+
if not acceptance_root.exists():
|
|
171
|
+
return []
|
|
172
|
+
return sorted(
|
|
173
|
+
p for p in acceptance_root.glob("*/manifest.*")
|
|
174
|
+
if p.suffix in (".yml", ".yaml", ".json")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class ManifestData:
|
|
180
|
+
"""One manifest file's parsed contents: the test-id -> issue-number
|
|
181
|
+
mapping, plus the #1138 issue-level ``exempt:`` list (issues in an
|
|
182
|
+
oracle-opted-in milestone that are deliberately validated by their own
|
|
183
|
+
unit tests instead of the sealed suite — e.g. the driver-building issue
|
|
184
|
+
itself, #1125 — declared here instead of living only as tribal knowledge
|
|
185
|
+
in an issue body).
|
|
186
|
+
|
|
187
|
+
#2063 adds the milestone-level ``gate_a:`` block: the declared,
|
|
188
|
+
reviewable opt-out from the Gate-A human sign-off gate, same posture as
|
|
189
|
+
``exempt:`` above — a milestone whose surface genuinely needs no human
|
|
190
|
+
eye says so in the repo, in writing, rather than the gate quietly not
|
|
191
|
+
existing for everyone."""
|
|
192
|
+
|
|
193
|
+
tests: dict[str, int] = field(default_factory=dict)
|
|
194
|
+
exempt: frozenset[int] = field(default_factory=frozenset)
|
|
195
|
+
#: ``gate_a: {exempt: true, reason: "..."}`` — this milestone's contract
|
|
196
|
+
#: may be consumed without a recorded human verdict (#2063).
|
|
197
|
+
gate_a_exempt: bool = False
|
|
198
|
+
gate_a_exempt_reason: str = ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_manifest_text(text: str, *, source: str = "<manifest>") -> ManifestData:
|
|
202
|
+
"""Pure parse of one manifest file's YAML/JSON text into
|
|
203
|
+
:class:`ManifestData`. Shared by the local-disk loader
|
|
204
|
+
(:func:`_parse_manifest_file`, used by ``coord acceptance run``/
|
|
205
|
+
``record`` on a worker's own checkout) and the dispatch-time GitHub-fetch
|
|
206
|
+
reader (:func:`coord.milestone_dispatch.issue_oracle_ready`, #1138) so
|
|
207
|
+
both agree on the manifest schema.
|
|
208
|
+
|
|
209
|
+
Three on-disk shapes are accepted:
|
|
210
|
+
|
|
211
|
+
- ``tests: {<test-id>: <issue-number>, ...}`` — flat, one issue per test.
|
|
212
|
+
- ``issues: {<issue-number>: [<test-id>, ...], ...}`` — grouped by issue.
|
|
213
|
+
- ``exempt: [<issue-number>, ...]`` — issues exempted from the #1138
|
|
214
|
+
issue-level oracle gate (no slice required before Work dispatch).
|
|
215
|
+
|
|
216
|
+
Plus one milestone-level block:
|
|
217
|
+
|
|
218
|
+
- ``gate_a: {exempt: true, reason: "..."}`` — this milestone's contract
|
|
219
|
+
may be consumed without a recorded human sign-off (#2063). ``gate_a:
|
|
220
|
+
true`` is accepted as shorthand. Anything else (including a missing
|
|
221
|
+
key) leaves the gate on.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
raw = yaml.safe_load(text)
|
|
225
|
+
except yaml.YAMLError as e:
|
|
226
|
+
raise ManifestError(f"failed to parse manifest {source}: {e}") from e
|
|
227
|
+
if raw is None:
|
|
228
|
+
return ManifestData()
|
|
229
|
+
if not isinstance(raw, dict):
|
|
230
|
+
raise ManifestError(f"manifest {source} must be a mapping")
|
|
231
|
+
|
|
232
|
+
mapping: dict[str, int] = {}
|
|
233
|
+
tests_raw = raw.get("tests")
|
|
234
|
+
if isinstance(tests_raw, dict):
|
|
235
|
+
for test_id, issue in tests_raw.items():
|
|
236
|
+
mapping[str(test_id)] = int(issue)
|
|
237
|
+
|
|
238
|
+
issues_raw = raw.get("issues")
|
|
239
|
+
if isinstance(issues_raw, dict):
|
|
240
|
+
for issue, test_ids in issues_raw.items():
|
|
241
|
+
if not isinstance(test_ids, list):
|
|
242
|
+
continue
|
|
243
|
+
for test_id in test_ids:
|
|
244
|
+
mapping[str(test_id)] = int(issue)
|
|
245
|
+
|
|
246
|
+
exempt: frozenset[int] = frozenset()
|
|
247
|
+
exempt_raw = raw.get("exempt")
|
|
248
|
+
if isinstance(exempt_raw, list):
|
|
249
|
+
exempt = frozenset(int(x) for x in exempt_raw)
|
|
250
|
+
|
|
251
|
+
gate_a_exempt = False
|
|
252
|
+
gate_a_reason = ""
|
|
253
|
+
gate_a_raw = raw.get("gate_a")
|
|
254
|
+
if isinstance(gate_a_raw, dict):
|
|
255
|
+
gate_a_exempt = bool(gate_a_raw.get("exempt"))
|
|
256
|
+
gate_a_reason = str(gate_a_raw.get("reason") or "")
|
|
257
|
+
elif isinstance(gate_a_raw, bool):
|
|
258
|
+
gate_a_exempt = gate_a_raw
|
|
259
|
+
|
|
260
|
+
return ManifestData(
|
|
261
|
+
tests=mapping,
|
|
262
|
+
exempt=exempt,
|
|
263
|
+
gate_a_exempt=gate_a_exempt,
|
|
264
|
+
gate_a_exempt_reason=gate_a_reason,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_manifest_file(path: Path) -> dict[str, int]:
|
|
269
|
+
"""Parse one manifest file into ``{test_id: issue_number}`` (the
|
|
270
|
+
``exempt:`` list, if any, is dropped — callers that need it use
|
|
271
|
+
:func:`parse_manifest_text` directly). Two on-disk shapes are accepted:
|
|
272
|
+
|
|
273
|
+
- ``tests: {<test-id>: <issue-number>, ...}`` — flat, one issue per test.
|
|
274
|
+
- ``issues: {<issue-number>: [<test-id>, ...], ...}`` — grouped by issue.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
text = path.read_text()
|
|
278
|
+
except OSError as e:
|
|
279
|
+
raise ManifestError(f"failed to parse manifest {path}: {e}") from e
|
|
280
|
+
return parse_manifest_text(text, source=str(path)).tests
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def load_manifest(acceptance_root: Path) -> dict[str, int]:
|
|
284
|
+
"""Merge every ``ms-NN/manifest.(yml|json)`` under *acceptance_root* into
|
|
285
|
+
one ``{test_id: issue_number}`` mapping.
|
|
286
|
+
|
|
287
|
+
Returns ``{}`` when *acceptance_root* doesn't exist or has no manifest
|
|
288
|
+
files yet (the suite hasn't been authored — sibling issue #931). Later
|
|
289
|
+
manifests win on a test-id collision (last one scanned, sorted by path
|
|
290
|
+
for determinism) rather than raising, since two milestones legitimately
|
|
291
|
+
sharing a test id is an authoring bug, not something this reader should
|
|
292
|
+
crash the whole run over.
|
|
293
|
+
"""
|
|
294
|
+
mapping: dict[str, int] = {}
|
|
295
|
+
for path in _manifest_paths(acceptance_root):
|
|
296
|
+
mapping.update(_parse_manifest_file(path))
|
|
297
|
+
return mapping
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def ms_dir_for_issue(acceptance_root: Path, issue_number: int) -> str | None:
|
|
301
|
+
"""The ``ms-NN`` directory name (under *acceptance_root*) whose manifest
|
|
302
|
+
covers *issue_number*, or ``None`` if no manifest maps any test to it yet
|
|
303
|
+
(the issue's slice hasn't been authored — #945 uses this to decide
|
|
304
|
+
whether there's a contract to point the worker at).
|
|
305
|
+
|
|
306
|
+
Unlike :func:`load_manifest`, this checks manifests **per file** rather
|
|
307
|
+
than merging first, since the whole point is recovering *which* ``ms-NN``
|
|
308
|
+
dir a given issue's tests live under.
|
|
309
|
+
"""
|
|
310
|
+
for path in _manifest_paths(acceptance_root):
|
|
311
|
+
mapping = _parse_manifest_file(path)
|
|
312
|
+
if test_ids_for_issue(mapping, issue_number):
|
|
313
|
+
return path.parent.name
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def oracle_loop_contract_block(
|
|
318
|
+
acceptance_root: Path, repo_name: str, issue_number: int
|
|
319
|
+
) -> str:
|
|
320
|
+
"""The worker briefing contract (#945, docs/ORACLE_LOOP.md "The worker
|
|
321
|
+
briefing contract") prepended to the TOP of a Work briefing when
|
|
322
|
+
*issue_number* has a sealed acceptance slice authored for it under
|
|
323
|
+
*acceptance_root*.
|
|
324
|
+
|
|
325
|
+
Returns ``""`` when the issue has no authored slice yet (nothing to
|
|
326
|
+
point the worker at — Gate A/#931 hasn't run for it) or on any read
|
|
327
|
+
error. Fully fail-soft — mirrors ``coord.state.issue_context_block``
|
|
328
|
+
(#603): this runs on the dispatch hot path, so a manifest hiccup must
|
|
329
|
+
degrade to "no block" rather than break dispatch.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
ms_dir = ms_dir_for_issue(acceptance_root, issue_number)
|
|
333
|
+
except Exception: # noqa: BLE001 — never let a manifest read break dispatch
|
|
334
|
+
return ""
|
|
335
|
+
if ms_dir is None:
|
|
336
|
+
return ""
|
|
337
|
+
|
|
338
|
+
contract_path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/contract.md"
|
|
339
|
+
mocks_dir = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks"
|
|
340
|
+
return (
|
|
341
|
+
"## 🔒 Oracle-loop acceptance contract — READ THIS FIRST\n\n"
|
|
342
|
+
"This issue has a sealed acceptance slice authored for it. Treat "
|
|
343
|
+
f"`{contract_path}` (the black-box surface) — and, if present, "
|
|
344
|
+
f"the rendered mock(s) under `{mocks_dir}/` — as the spec — not "
|
|
345
|
+
"guesswork. For a web slice the mocks ARE part of the contract "
|
|
346
|
+
"(hand-authored HTML wireframes, one per screen state): the app "
|
|
347
|
+
"must satisfy the sealed assertions written against them, not the "
|
|
348
|
+
"other way around.\n\n"
|
|
349
|
+
f"- You **may not** edit `{ACCEPTANCE_DIRNAME}/**` (contract, "
|
|
350
|
+
"mocks, or the sealed suite). It is the sealed oracle, authored "
|
|
351
|
+
"independently of your work — touching it fails the gate.\n"
|
|
352
|
+
f"- Run `coord acceptance run --repo {repo_name} --issue "
|
|
353
|
+
f"{issue_number}` to check yourself; iterate in this warm session "
|
|
354
|
+
"until your slice is green, then release.\n"
|
|
355
|
+
"- Write your own unit / internal tests too — that is still your "
|
|
356
|
+
"job.\n"
|
|
357
|
+
"- If your slice won't converge — the failing set churns rather "
|
|
358
|
+
"than shrinks across 2 rounds — **stop grinding**: run "
|
|
359
|
+
f"`coord acceptance stall --repo {repo_name} --issue {issue_number} "
|
|
360
|
+
'--tried "..." --stuck "..."` (#846) so the coordinator sees it '
|
|
361
|
+
"immediately, in addition to a `STUCK:` line for the interactive "
|
|
362
|
+
"log.\n\n"
|
|
363
|
+
"---\n\n"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_ids_for_issue(manifest: dict[str, int], issue_number: int) -> set[str]:
|
|
368
|
+
"""The set of test ids mapped to *issue_number* in *manifest*."""
|
|
369
|
+
return {test_id for test_id, issue in manifest.items() if issue == issue_number}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def build_verdict(
|
|
373
|
+
tests: list[dict],
|
|
374
|
+
*,
|
|
375
|
+
scope: str,
|
|
376
|
+
issue_number: int | None = None,
|
|
377
|
+
) -> dict[str, Any]:
|
|
378
|
+
"""Assemble the structured pass/fail payload ``coord acceptance run``
|
|
379
|
+
prints and ``record`` persists a summary of.
|
|
380
|
+
|
|
381
|
+
*tests* is the (already filtered, when scoped to one issue) list of
|
|
382
|
+
``{"id", "status", "message"}`` dicts from a driver. Sealed: this only
|
|
383
|
+
ever carries verdicts (id/status/message), never test source.
|
|
384
|
+
"""
|
|
385
|
+
passed = sum(1 for t in tests if t.get("status") == "pass")
|
|
386
|
+
failed = sum(1 for t in tests if t.get("status") == "fail")
|
|
387
|
+
skipped = sum(1 for t in tests if t.get("status") == "skip")
|
|
388
|
+
payload: dict[str, Any] = {
|
|
389
|
+
"scope": scope,
|
|
390
|
+
"total": len(tests),
|
|
391
|
+
"passed": passed,
|
|
392
|
+
"failed": failed,
|
|
393
|
+
"skipped": skipped,
|
|
394
|
+
"green": failed == 0 and len(tests) > 0,
|
|
395
|
+
"tests": tests,
|
|
396
|
+
}
|
|
397
|
+
if issue_number is not None:
|
|
398
|
+
payload["issue"] = issue_number
|
|
399
|
+
return payload
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def failure_summary(verdict: dict[str, Any], *, limit: int = 5) -> str:
|
|
403
|
+
"""One-line-per-failure summary text for a verdict payload (used as the
|
|
404
|
+
Acceptance-gate reason string and the #603 durable-context note)."""
|
|
405
|
+
failing = [t for t in verdict.get("tests", []) if t.get("status") == "fail"]
|
|
406
|
+
if not failing:
|
|
407
|
+
return ""
|
|
408
|
+
lines = [f"{t['id']}: {t.get('message') or 'failed'}" for t in failing[:limit]]
|
|
409
|
+
if len(failing) > limit:
|
|
410
|
+
lines.append(f"... and {len(failing) - limit} more")
|
|
411
|
+
return "\n".join(lines)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def dump_manifest_error_hint(acceptance_root: Path) -> str:
|
|
415
|
+
"""Human-facing hint for "no manifest found" — points at the authoring
|
|
416
|
+
step (#931) rather than leaving the operator guessing."""
|
|
417
|
+
return (
|
|
418
|
+
f"no acceptance manifest found under {acceptance_root} — the sealed "
|
|
419
|
+
"suite has not been authored yet for this repo (see docs/ORACLE_LOOP.md "
|
|
420
|
+
"/ #931)."
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def acceptance_capability_gap(
|
|
425
|
+
capability: str, repo_name: str, config: Config,
|
|
426
|
+
) -> Machine | None:
|
|
427
|
+
"""Detect a capability-matched-routing gap for an acceptance driver run
|
|
428
|
+
(#966, deferred from #932/#944).
|
|
429
|
+
|
|
430
|
+
``coord acceptance run --all`` (Gate C) and ``coord acceptance record``
|
|
431
|
+
always execute the driver's ``run`` command on whatever host invoked
|
|
432
|
+
them — there is no remote-exec plumbing to actually route the run
|
|
433
|
+
elsewhere yet (that's the "new plumbing, not a copy-paste" part #966
|
|
434
|
+
defers until a driver with a real capability mismatch exists). What this
|
|
435
|
+
function *can* do cheaply — mirroring ``coord.smoke.pick_smoke_machine``'s
|
|
436
|
+
candidate filter, minus the async/busy-machine bits that don't apply to
|
|
437
|
+
a synchronous command — is detect when it's about to run on the *wrong*
|
|
438
|
+
hardware, so the caller can fail loudly instead of silently.
|
|
439
|
+
|
|
440
|
+
Returns the first other configured machine that has *repo_name* and
|
|
441
|
+
*capability*, when:
|
|
442
|
+
- *capability* is set (drivers without one, e.g. today's only real
|
|
443
|
+
driver's implicit local-only assumption, are never gapped), AND
|
|
444
|
+
- this host is a recognized machine in ``coordinator.yml`` that does
|
|
445
|
+
NOT have *capability* (an unrecognized host is given the benefit of
|
|
446
|
+
the doubt — it might be a dev machine outside the fleet that happens
|
|
447
|
+
to have everything installed), AND
|
|
448
|
+
- some other configured machine actually has it (nothing to route to
|
|
449
|
+
otherwise, so failing wouldn't be actionable).
|
|
450
|
+
|
|
451
|
+
Returns ``None`` in every other case — i.e. "no known gap, proceed."
|
|
452
|
+
"""
|
|
453
|
+
if not capability:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
from coord.test_orchestrator import local_machine # noqa: PLC0415 — avoid import cycle
|
|
457
|
+
|
|
458
|
+
here = local_machine(config)
|
|
459
|
+
if here is None or capability in here.capabilities:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
candidates = [
|
|
463
|
+
m for m in config.machines
|
|
464
|
+
if m.can_work_on(repo_name) and capability in m.capabilities
|
|
465
|
+
]
|
|
466
|
+
if not candidates:
|
|
467
|
+
return None
|
|
468
|
+
return candidates[0]
|