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/client.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Thin-client access to a remote ``coord serve`` daemon (#584/#594).
|
|
2
|
+
|
|
3
|
+
When a ``board_service`` is configured, the ``coord`` CLI (and, in spirit, any
|
|
4
|
+
Python consumer) reads the board + config from the daemon over Tailscale instead
|
|
5
|
+
of opening a local ``~/.coord/coord.db`` / ``coordinator.yml``. When it is NOT
|
|
6
|
+
configured, callers fall back to the existing local-SQLite path — byte-identical
|
|
7
|
+
behaviour, no regression.
|
|
8
|
+
|
|
9
|
+
Bootstrap contract (kubeconfig-style), resolution order **flag > env > file**:
|
|
10
|
+
|
|
11
|
+
* ``--service`` / ``--token`` flags (passed by the caller),
|
|
12
|
+
* ``COORD_SERVICE_URL`` / ``COORD_TOKEN`` environment variables,
|
|
13
|
+
* ``~/.coord/client.toml`` with ``board_service = "http://host:7435"`` and an
|
|
14
|
+
optional ``token = "..."``.
|
|
15
|
+
|
|
16
|
+
The client carries **no** Claude or gh credentials — it only reads the board and
|
|
17
|
+
config.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import tomllib
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from coord._board_mapping import assemble_board, infer_review_state
|
|
31
|
+
from coord.models import Board
|
|
32
|
+
|
|
33
|
+
COORD_DIR = Path.home() / ".coord"
|
|
34
|
+
CLIENT_TOML = COORD_DIR / "client.toml"
|
|
35
|
+
# Local cache of the daemon-served coordinator.yml so the existing
|
|
36
|
+
# coord.config.load() parser can consume it unchanged (config.py has no dict
|
|
37
|
+
# round-trip — raw YAML is the lossless contract).
|
|
38
|
+
REMOTE_CONFIG_CACHE = COORD_DIR / "coordinator.remote.yml"
|
|
39
|
+
|
|
40
|
+
_DEFAULT_TIMEOUT = 5.0
|
|
41
|
+
# Writes can post a GitHub comment synchronously on the daemon, so give them a
|
|
42
|
+
# longer ceiling than read GETs.
|
|
43
|
+
_WRITE_TIMEOUT = 30.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ServiceConfig:
|
|
48
|
+
url: str
|
|
49
|
+
token: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_board_service(
|
|
53
|
+
flag_url: str | None = None,
|
|
54
|
+
flag_token: str | None = None,
|
|
55
|
+
) -> ServiceConfig | None:
|
|
56
|
+
"""Resolve the board service per the bootstrap contract, or ``None`` if unset."""
|
|
57
|
+
url = flag_url or os.environ.get("COORD_SERVICE_URL")
|
|
58
|
+
token = flag_token or os.environ.get("COORD_TOKEN")
|
|
59
|
+
if not url and CLIENT_TOML.exists():
|
|
60
|
+
try:
|
|
61
|
+
data = tomllib.loads(CLIENT_TOML.read_text())
|
|
62
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
63
|
+
data = {}
|
|
64
|
+
url = data.get("board_service")
|
|
65
|
+
token = token or data.get("token")
|
|
66
|
+
if not url:
|
|
67
|
+
return None
|
|
68
|
+
return ServiceConfig(url=url.rstrip("/"), token=token)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _headers(svc: ServiceConfig) -> dict[str, str]:
|
|
72
|
+
return {"Authorization": f"Bearer {svc.token}"} if svc.token else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_board_payload(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> dict:
|
|
76
|
+
"""GET /board → the raw projection dict (also carries machines/merge_queue/…)."""
|
|
77
|
+
resp = httpx.get(f"{svc.url}/board", headers=_headers(svc), timeout=timeout)
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
return resp.json()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch_assignment(
|
|
83
|
+
svc: ServiceConfig, assignment_id: str, *, timeout: float = _DEFAULT_TIMEOUT
|
|
84
|
+
) -> dict | None:
|
|
85
|
+
"""GET /assignment/{id} → the full single-assignment row (#1336/#1337).
|
|
86
|
+
|
|
87
|
+
The point lookup for the fields the ``/board`` collection bounds or omits
|
|
88
|
+
(``briefing``, full ``review_findings``/``test_plan``/``test_reason``/…).
|
|
89
|
+
Returns ``None`` on 404 — either the id is genuinely unknown, or the
|
|
90
|
+
daemon predates the endpoint (an unmatched route is also a 404); callers
|
|
91
|
+
that must distinguish fall back to :func:`fetch_board_payload`.
|
|
92
|
+
Raises ``httpx.HTTPError`` on transport failure / other HTTP errors.
|
|
93
|
+
"""
|
|
94
|
+
resp = httpx.get(
|
|
95
|
+
f"{svc.url}/assignment/{assignment_id}",
|
|
96
|
+
headers=_headers(svc),
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
)
|
|
99
|
+
if resp.status_code == 404:
|
|
100
|
+
return None
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
return resp.json()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fetch_issue(
|
|
106
|
+
svc: ServiceConfig,
|
|
107
|
+
repo_name: str,
|
|
108
|
+
number: int,
|
|
109
|
+
*,
|
|
110
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
111
|
+
) -> dict | None:
|
|
112
|
+
"""GET /issue/{repo_name}/{number} → the full single-issue row (#1337).
|
|
113
|
+
|
|
114
|
+
Same contract as :func:`fetch_assignment`: ``None`` on 404 (unknown issue
|
|
115
|
+
OR pre-#1337 daemon), ``httpx.HTTPError`` on transport/HTTP failure.
|
|
116
|
+
"""
|
|
117
|
+
resp = httpx.get(
|
|
118
|
+
f"{svc.url}/issue/{repo_name}/{number}",
|
|
119
|
+
headers=_headers(svc),
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
)
|
|
122
|
+
if resp.status_code == 404:
|
|
123
|
+
return None
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
return resp.json()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def board_from_payload(payload: dict) -> Board:
|
|
129
|
+
"""Reconstruct a :class:`Board` from a ``/board`` payload.
|
|
130
|
+
|
|
131
|
+
Delegates row→Board assembly to ``coord._board_mapping.assemble_board`` —
|
|
132
|
+
the same core ``coord.state._query_board`` uses — so the remote board
|
|
133
|
+
can't drift from a locally-built one (#749: the dual state.py/client.py
|
|
134
|
+
projection is now one shared implementation).
|
|
135
|
+
"""
|
|
136
|
+
assignments_raw = payload.get("assignments", [])
|
|
137
|
+
plans = payload.get("plans") or {}
|
|
138
|
+
board = assemble_board(assignments_raw, plans, payload.get("round_number") or 0)
|
|
139
|
+
review_rows = [
|
|
140
|
+
{
|
|
141
|
+
"assignment_id": d.get("assignment_id"),
|
|
142
|
+
"review_of_assignment_id": d.get("review_of_assignment_id"),
|
|
143
|
+
"status": d.get("status"),
|
|
144
|
+
}
|
|
145
|
+
for d in assignments_raw
|
|
146
|
+
if d.get("type") == "review" and d.get("review_of_assignment_id")
|
|
147
|
+
]
|
|
148
|
+
notified_ids = {n.get("assignment_id") for n in payload.get("notifications", [])}
|
|
149
|
+
infer_review_state(board, review_rows, notified_ids)
|
|
150
|
+
return board
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_remote_board(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> Board:
|
|
154
|
+
"""GET /board and reconstruct a :class:`Board`."""
|
|
155
|
+
return board_from_payload(fetch_board_payload(svc, timeout=timeout))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def serialize_board(board: Board) -> dict:
|
|
159
|
+
"""Serialize *board* for ``POST /board`` — the inverse of :func:`board_from_payload`.
|
|
160
|
+
|
|
161
|
+
Only ships what ``coord.state.save_board`` actually persists: assignment
|
|
162
|
+
rows (``dataclasses.asdict``, so JSON columns are native lists/dicts —
|
|
163
|
+
tolerated by ``row_to_assignment`` on the way back in) + ``round_number``.
|
|
164
|
+
``save_board`` is upsert-only (never deletes), so POSTing a client's full
|
|
165
|
+
in-memory board is a safe, non-lossy drop-in for what today runs directly
|
|
166
|
+
against the local DB (#749).
|
|
167
|
+
"""
|
|
168
|
+
from dataclasses import asdict # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"assignments": [asdict(a) for a in board.active + board.completed],
|
|
172
|
+
"round_number": board.round_number,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def post_board(svc: ServiceConfig, board: Board, *, timeout: float = _WRITE_TIMEOUT) -> None:
|
|
177
|
+
"""POST the full board to the daemon's generic upsert endpoint (#749).
|
|
178
|
+
|
|
179
|
+
Backs ``coord.board_service.write_board`` for the commands that still
|
|
180
|
+
read-modify-write the whole board locally (``assign``/``approve``/``stop``/
|
|
181
|
+
``retry``/``resume``/``bounce``/``done``/``pr``/… and the dashboard/auto_loop
|
|
182
|
+
call sites) — replacing a local ``save_board`` that used to silently no-op
|
|
183
|
+
on a thin client.
|
|
184
|
+
"""
|
|
185
|
+
post_record(svc, "/board", serialize_board(board), timeout=timeout)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def post_record(
|
|
189
|
+
svc: ServiceConfig,
|
|
190
|
+
path: str,
|
|
191
|
+
payload: dict,
|
|
192
|
+
*,
|
|
193
|
+
timeout: float = _WRITE_TIMEOUT,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""POST a serialized seam record to the daemon and return the JSON outcome.
|
|
196
|
+
|
|
197
|
+
Used by the ``coord.issue_store`` seam (#590): when ``board_service`` is
|
|
198
|
+
set, ``post_result`` / ``post_completion`` POST the record here instead of
|
|
199
|
+
writing the local DB, so a thin client's result lands on the one shared DB.
|
|
200
|
+
Raises ``httpx.HTTPError`` on transport/HTTP failure — the seam decides
|
|
201
|
+
whether that is fatal (``post_result``) or best-effort (``post_completion``).
|
|
202
|
+
"""
|
|
203
|
+
resp = httpx.post(
|
|
204
|
+
f"{svc.url}{path}", json=payload, headers=_headers(svc), timeout=timeout
|
|
205
|
+
)
|
|
206
|
+
resp.raise_for_status()
|
|
207
|
+
return resp.json()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fetch_paused_machines(
|
|
211
|
+
svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
|
|
212
|
+
) -> set[str]:
|
|
213
|
+
"""GET /pause → the daemon's current paused-machine set (#1563).
|
|
214
|
+
|
|
215
|
+
Raises ``httpx.HTTPError`` on transport/HTTP failure. This is the read
|
|
216
|
+
side of the thin-client pause fix: `coord.machine_pause.paused_set()`
|
|
217
|
+
catches the error itself and degrades to "nothing is paused" (fail-soft,
|
|
218
|
+
matching every other daemon read-through helper here) — this function
|
|
219
|
+
stays strict so a caller that DOES want to distinguish "confirmed empty"
|
|
220
|
+
from "couldn't ask" still can.
|
|
221
|
+
"""
|
|
222
|
+
resp = httpx.get(f"{svc.url}/pause", headers=_headers(svc), timeout=timeout)
|
|
223
|
+
resp.raise_for_status()
|
|
224
|
+
data = resp.json()
|
|
225
|
+
items = data.get("paused") if isinstance(data, dict) else None
|
|
226
|
+
return {str(x) for x in items if isinstance(x, str) and x} if isinstance(items, list) else set()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fetch_cordons(
|
|
230
|
+
svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
|
|
231
|
+
) -> list[dict]:
|
|
232
|
+
"""GET /pause → the daemon's release-cordon records (#2101).
|
|
233
|
+
|
|
234
|
+
Returns the raw record dicts (``machine``/``owner``/``reason``/
|
|
235
|
+
``target_version``/``created_at``/``expires_at``) so a caller can decide
|
|
236
|
+
expiry and drain deadlines for itself — the whole point of #2101 trap B is
|
|
237
|
+
that a cordon carries its own expiry rather than depending on some process
|
|
238
|
+
remembering to clean it up.
|
|
239
|
+
|
|
240
|
+
``[]`` when the daemon predates #2101 (the key is simply absent), which is
|
|
241
|
+
the right read: a daemon with no cordon store has no cordons. Raises
|
|
242
|
+
``httpx.HTTPError`` on transport/HTTP failure — `coord.machine_pause.
|
|
243
|
+
cordons()` catches that and degrades to "nothing is cordoned", same
|
|
244
|
+
fail-soft read posture as `fetch_paused_machines`.
|
|
245
|
+
"""
|
|
246
|
+
resp = httpx.get(f"{svc.url}/pause", headers=_headers(svc), timeout=timeout)
|
|
247
|
+
resp.raise_for_status()
|
|
248
|
+
data = resp.json()
|
|
249
|
+
rows = data.get("cordons") if isinstance(data, dict) else None
|
|
250
|
+
return [dict(r) for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def post_cordon(
|
|
254
|
+
svc: ServiceConfig,
|
|
255
|
+
machine: str,
|
|
256
|
+
action: str,
|
|
257
|
+
*,
|
|
258
|
+
reason: str = "",
|
|
259
|
+
target_version: str | None = None,
|
|
260
|
+
ttl_seconds: float | None = None,
|
|
261
|
+
timeout: float = _WRITE_TIMEOUT,
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""POST /pause {machine, action: cordon|uncordon, ...} (#2101).
|
|
264
|
+
|
|
265
|
+
Deliberately the SAME endpoint as pause/unpause rather than a new one: a
|
|
266
|
+
cordon is the same routing decision with a different owner, and one
|
|
267
|
+
endpoint means one place a thin client can be wrong about it.
|
|
268
|
+
|
|
269
|
+
Raises ``httpx.HTTPError`` on transport/HTTP failure — a cordon that
|
|
270
|
+
silently fails to reach the daemon would let `coord release propagate`
|
|
271
|
+
restart agents believing it had stopped new work. A daemon that predates
|
|
272
|
+
#2101 answers 400 ``unknown action``, which surfaces here as an
|
|
273
|
+
``HTTPStatusError`` rather than a silent no-op — deploy the daemon first
|
|
274
|
+
(the same server-first ordering every other endpoint here needs).
|
|
275
|
+
"""
|
|
276
|
+
payload: dict = {"machine": machine, "action": action}
|
|
277
|
+
if reason:
|
|
278
|
+
payload["reason"] = reason
|
|
279
|
+
if target_version:
|
|
280
|
+
payload["target_version"] = target_version
|
|
281
|
+
if ttl_seconds is not None:
|
|
282
|
+
payload["ttl_seconds"] = float(ttl_seconds)
|
|
283
|
+
return post_record(svc, "/pause", payload, timeout=timeout)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def post_pause(
|
|
287
|
+
svc: ServiceConfig, machine: str, action: str, *, timeout: float = _WRITE_TIMEOUT
|
|
288
|
+
) -> dict:
|
|
289
|
+
"""POST /pause {machine, action} → {"paused": [...], "changed": bool} (#1563).
|
|
290
|
+
|
|
291
|
+
*action* is ``"pause"`` or ``"unpause"``. Raises ``httpx.HTTPError`` on
|
|
292
|
+
transport/HTTP failure — deliberately NOT fail-soft: `coord pause` on a
|
|
293
|
+
thin client that can't reach the daemon must fail loudly, not print
|
|
294
|
+
"paused: X" while the daemon never hears about it (the root cause of
|
|
295
|
+
#1563).
|
|
296
|
+
"""
|
|
297
|
+
return post_record(svc, "/pause", {"machine": machine, "action": action}, timeout=timeout)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def fetch_remote_config(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> Path:
|
|
301
|
+
"""GET /config, cache it to ``~/.coord/coordinator.remote.yml``, return the path.
|
|
302
|
+
|
|
303
|
+
The caller feeds the returned path to ``coord.config.load()``.
|
|
304
|
+
"""
|
|
305
|
+
resp = httpx.get(f"{svc.url}/config", headers=_headers(svc), timeout=timeout)
|
|
306
|
+
resp.raise_for_status()
|
|
307
|
+
COORD_DIR.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
REMOTE_CONFIG_CACHE.write_text(resp.text)
|
|
309
|
+
return REMOTE_CONFIG_CACHE
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def fetch_issue_context(
|
|
313
|
+
svc: ServiceConfig,
|
|
314
|
+
repo_name: str,
|
|
315
|
+
issue_number: int,
|
|
316
|
+
*,
|
|
317
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
318
|
+
) -> list[dict]:
|
|
319
|
+
"""GET an issue's raw context entries from the daemon (#603).
|
|
320
|
+
|
|
321
|
+
Returns the ``entries`` list (oldest-first); ``[]`` on ANY failure (404 from
|
|
322
|
+
an older daemon, network error, bad JSON). Fail-soft on purpose: this rides
|
|
323
|
+
the briefing read-path, so a missing/old daemon must degrade to "no context
|
|
324
|
+
block", never crash a dispatch.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
resp = httpx.get(
|
|
328
|
+
f"{svc.url}/issue-context",
|
|
329
|
+
params={"repo_name": repo_name, "issue_number": issue_number},
|
|
330
|
+
headers=_headers(svc),
|
|
331
|
+
timeout=timeout,
|
|
332
|
+
)
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
data = resp.json()
|
|
335
|
+
except Exception: # noqa: BLE001 — fail-soft; briefing just omits the block
|
|
336
|
+
return []
|
|
337
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
338
|
+
return entries if isinstance(entries, list) else []
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def fetch_drive_escalation(
|
|
342
|
+
svc: ServiceConfig,
|
|
343
|
+
repo_name: str,
|
|
344
|
+
issue_number: int,
|
|
345
|
+
*,
|
|
346
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
347
|
+
) -> dict | None:
|
|
348
|
+
"""GET the (at most one) open driver-escalation record for an issue (#1505).
|
|
349
|
+
|
|
350
|
+
Returns ``None`` on ANY failure (404 from an older daemon, network error,
|
|
351
|
+
bad JSON, or genuinely no record) — ``coord escalate run``/``dismiss`` on a
|
|
352
|
+
thin client treat that as "nothing on file", same as the local-DB path.
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
resp = httpx.get(
|
|
356
|
+
f"{svc.url}/drive-escalations",
|
|
357
|
+
params={"repo_name": repo_name, "issue_number": issue_number},
|
|
358
|
+
headers=_headers(svc),
|
|
359
|
+
timeout=timeout,
|
|
360
|
+
)
|
|
361
|
+
resp.raise_for_status()
|
|
362
|
+
data = resp.json()
|
|
363
|
+
except Exception: # noqa: BLE001 — fail-soft, mirrors fetch_issue_context
|
|
364
|
+
return None
|
|
365
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
366
|
+
if isinstance(entries, list) and entries:
|
|
367
|
+
return entries[0]
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def fetch_drive_escalations(
|
|
372
|
+
svc: ServiceConfig,
|
|
373
|
+
repo_name: str | None = None,
|
|
374
|
+
*,
|
|
375
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
376
|
+
) -> list[dict]:
|
|
377
|
+
"""GET every open driver-escalation record, optionally filtered to one
|
|
378
|
+
repo (#1505). ``[]`` on ANY failure — fail-soft, mirrors
|
|
379
|
+
:func:`fetch_issue_context`."""
|
|
380
|
+
try:
|
|
381
|
+
params = {"repo_name": repo_name} if repo_name else {}
|
|
382
|
+
resp = httpx.get(
|
|
383
|
+
f"{svc.url}/drive-escalations",
|
|
384
|
+
params=params,
|
|
385
|
+
headers=_headers(svc),
|
|
386
|
+
timeout=timeout,
|
|
387
|
+
)
|
|
388
|
+
resp.raise_for_status()
|
|
389
|
+
data = resp.json()
|
|
390
|
+
except Exception: # noqa: BLE001
|
|
391
|
+
return []
|
|
392
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
393
|
+
return entries if isinstance(entries, list) else []
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def fetch_drive_queue(
|
|
397
|
+
svc: ServiceConfig,
|
|
398
|
+
repo_name: str | None = None,
|
|
399
|
+
*,
|
|
400
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
401
|
+
) -> list[dict]:
|
|
402
|
+
"""GET the drive queue in run order, optionally filtered to one repo (#1753).
|
|
403
|
+
|
|
404
|
+
``[]`` on ANY failure (404 from a daemon predating this route, network
|
|
405
|
+
error, bad JSON, or a genuinely empty queue) — fail-soft, mirrors
|
|
406
|
+
:func:`fetch_drive_escalations`. ``after_json`` arrives already decoded to
|
|
407
|
+
a list; this helper does not re-parse it.
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
params = {"repo_name": repo_name} if repo_name else {}
|
|
411
|
+
resp = httpx.get(
|
|
412
|
+
f"{svc.url}/drive-queue",
|
|
413
|
+
params=params,
|
|
414
|
+
headers=_headers(svc),
|
|
415
|
+
timeout=timeout,
|
|
416
|
+
)
|
|
417
|
+
resp.raise_for_status()
|
|
418
|
+
data = resp.json()
|
|
419
|
+
except Exception: # noqa: BLE001
|
|
420
|
+
return []
|
|
421
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
422
|
+
return entries if isinstance(entries, list) else []
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def fetch_drive_queue_entry(
|
|
426
|
+
svc: ServiceConfig,
|
|
427
|
+
repo_name: str,
|
|
428
|
+
issue_number: int,
|
|
429
|
+
*,
|
|
430
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
431
|
+
) -> dict | None:
|
|
432
|
+
"""GET the (at most one) drive-queue entry for an issue (#1753).
|
|
433
|
+
|
|
434
|
+
``None`` on ANY failure or when the issue is not queued — fail-soft,
|
|
435
|
+
mirrors :func:`fetch_drive_escalation`.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
resp = httpx.get(
|
|
439
|
+
f"{svc.url}/drive-queue",
|
|
440
|
+
params={"repo_name": repo_name, "issue_number": issue_number},
|
|
441
|
+
headers=_headers(svc),
|
|
442
|
+
timeout=timeout,
|
|
443
|
+
)
|
|
444
|
+
resp.raise_for_status()
|
|
445
|
+
data = resp.json()
|
|
446
|
+
except Exception: # noqa: BLE001
|
|
447
|
+
return None
|
|
448
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
449
|
+
if isinstance(entries, list) and entries:
|
|
450
|
+
return entries[0]
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def fetch_gate_a_approval(
|
|
455
|
+
svc: ServiceConfig,
|
|
456
|
+
repo_name: str,
|
|
457
|
+
milestone_number: int,
|
|
458
|
+
*,
|
|
459
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
460
|
+
) -> dict | None:
|
|
461
|
+
"""GET one milestone's Gate-A human sign-off record (#2063).
|
|
462
|
+
|
|
463
|
+
Fail-soft to ``None`` — the opposite posture to
|
|
464
|
+
:func:`fetch_milestone_gate` above, and deliberately so. There, "couldn't
|
|
465
|
+
ask" collapsing to ``None`` would let a thin client silently race a gate
|
|
466
|
+
tick, so it raises. Here ``None`` means "no approval recorded", which
|
|
467
|
+
makes the only consumer
|
|
468
|
+
(:func:`coord.milestone_dispatch.issue_oracle_ready`) **refuse** — an
|
|
469
|
+
unreachable daemon fails closed, with a refusal an operator can read and
|
|
470
|
+
act on, rather than a traceback out of an unrelated dispatch path.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
resp = httpx.get(
|
|
474
|
+
f"{svc.url}/gate-a-approval",
|
|
475
|
+
params={"repo_name": repo_name, "milestone_number": milestone_number},
|
|
476
|
+
headers=_headers(svc),
|
|
477
|
+
timeout=timeout,
|
|
478
|
+
)
|
|
479
|
+
resp.raise_for_status()
|
|
480
|
+
data = resp.json()
|
|
481
|
+
except (httpx.HTTPError, ValueError):
|
|
482
|
+
return None
|
|
483
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
484
|
+
if isinstance(entries, list) and entries:
|
|
485
|
+
first = entries[0]
|
|
486
|
+
return first if isinstance(first, dict) else None
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def fetch_milestone_gate(
|
|
491
|
+
svc: ServiceConfig,
|
|
492
|
+
repo_name: str,
|
|
493
|
+
tracking_issue: int,
|
|
494
|
+
*,
|
|
495
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
496
|
+
) -> dict | None:
|
|
497
|
+
"""GET one milestone's gate record from the daemon (#1930, epic #1440).
|
|
498
|
+
|
|
499
|
+
Deliberately does **not** follow the fail-soft-to-``None`` convention
|
|
500
|
+
the other ``fetch_*`` helpers in this module use. The one caller that
|
|
501
|
+
matters, ``coord milestone dispatch``'s exactly-one-overseer guard, must
|
|
502
|
+
be able to tell "confirmed not gate-controlled" apart from "couldn't
|
|
503
|
+
ask the daemon" — collapsing both to ``None`` would let a thin client
|
|
504
|
+
that can't reach the daemon silently race a gate tick it has no way to
|
|
505
|
+
see, which is exactly the bug #1930 exists to close. Raises
|
|
506
|
+
``httpx.HTTPError`` on a network/HTTP failure and ``ValueError`` on a
|
|
507
|
+
malformed body; callers should treat either as "unknown — refuse rather
|
|
508
|
+
than risk a race", not swallow it.
|
|
509
|
+
"""
|
|
510
|
+
resp = httpx.get(
|
|
511
|
+
f"{svc.url}/milestone-gate",
|
|
512
|
+
params={"repo_name": repo_name, "tracking_issue": tracking_issue},
|
|
513
|
+
headers=_headers(svc),
|
|
514
|
+
timeout=timeout,
|
|
515
|
+
)
|
|
516
|
+
resp.raise_for_status()
|
|
517
|
+
data = resp.json()
|
|
518
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
519
|
+
if not isinstance(entries, list):
|
|
520
|
+
raise ValueError("malformed /milestone-gate response: no 'entries' list")
|
|
521
|
+
return entries[0] if entries else None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def fetch_audit_log(
|
|
525
|
+
svc: ServiceConfig,
|
|
526
|
+
*,
|
|
527
|
+
since: float | None = None,
|
|
528
|
+
until: float | None = None,
|
|
529
|
+
event_type: str | None = None,
|
|
530
|
+
category: str | None = None,
|
|
531
|
+
repo: str | None = None,
|
|
532
|
+
issue: int | None = None,
|
|
533
|
+
assignment_id: str | None = None,
|
|
534
|
+
tier: str | None = None,
|
|
535
|
+
limit: int = 200,
|
|
536
|
+
cursor: str | None = None,
|
|
537
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
538
|
+
) -> dict:
|
|
539
|
+
"""GET the paginated audit log from the daemon (#1037).
|
|
540
|
+
|
|
541
|
+
Unlike :func:`fetch_issue_context`, this does NOT fail-soft: ``coord
|
|
542
|
+
audit`` is an explicit read the user asked for, so a transport/HTTP
|
|
543
|
+
error (including a 404 from a daemon that predates this endpoint —
|
|
544
|
+
see the endpoint-deploy-ordering rule) raises ``httpx.HTTPError`` for
|
|
545
|
+
the CLI to report plainly instead of silently rendering an empty log.
|
|
546
|
+
"""
|
|
547
|
+
params: dict[str, Any] = {"limit": limit}
|
|
548
|
+
for key, value in (
|
|
549
|
+
("since", since), ("until", until), ("type", event_type),
|
|
550
|
+
("category", category), ("repo", repo), ("issue", issue),
|
|
551
|
+
("assignment", assignment_id), ("tier", tier), ("cursor", cursor),
|
|
552
|
+
):
|
|
553
|
+
if value is not None:
|
|
554
|
+
params[key] = value
|
|
555
|
+
resp = httpx.get(
|
|
556
|
+
f"{svc.url}/audit", params=params, headers=_headers(svc), timeout=timeout
|
|
557
|
+
)
|
|
558
|
+
resp.raise_for_status()
|
|
559
|
+
return resp.json()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# #1742: the report engine lives on the daemon (the audit trail it folds
|
|
563
|
+
# does), so a thin client fetches both the catalogue and the rendered result
|
|
564
|
+
# over HTTP. Like fetch_audit_log, these do NOT fail-soft — `coord report` is
|
|
565
|
+
# an explicit ask, and a 404 from a daemon predating these routes must read
|
|
566
|
+
# as "restart coord-serve", not as "no reports".
|
|
567
|
+
_REPORT_TIMEOUT = 30.0
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def fetch_report_catalogue(
|
|
571
|
+
svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
|
|
572
|
+
) -> dict:
|
|
573
|
+
"""GET the report catalogue (``{"reports": [...]}``) from the daemon."""
|
|
574
|
+
resp = httpx.get(f"{svc.url}/report", headers=_headers(svc), timeout=timeout)
|
|
575
|
+
resp.raise_for_status()
|
|
576
|
+
return resp.json()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def fetch_report(
|
|
580
|
+
svc: ServiceConfig,
|
|
581
|
+
report_id: str,
|
|
582
|
+
params: dict[str, Any] | None = None,
|
|
583
|
+
*,
|
|
584
|
+
timeout: float = _REPORT_TIMEOUT,
|
|
585
|
+
) -> dict:
|
|
586
|
+
"""GET a rendered ``ReportResult`` from the daemon.
|
|
587
|
+
|
|
588
|
+
Timeout is deliberately far above ``_DEFAULT_TIMEOUT``: a wide window
|
|
589
|
+
walks several 500-row audit pages server-side, which is slower than any
|
|
590
|
+
point read but still bounded.
|
|
591
|
+
"""
|
|
592
|
+
resp = httpx.get(
|
|
593
|
+
f"{svc.url}/report/{report_id}",
|
|
594
|
+
params={k: v for k, v in (params or {}).items() if v not in (None, "")},
|
|
595
|
+
headers=_headers(svc),
|
|
596
|
+
timeout=timeout,
|
|
597
|
+
)
|
|
598
|
+
if resp.status_code in (400, 404):
|
|
599
|
+
# A bad param / unknown report is a user error with a message the
|
|
600
|
+
# daemon already worded — surface it as a plain error, not as an
|
|
601
|
+
# httpx status traceback.
|
|
602
|
+
detail = ""
|
|
603
|
+
try:
|
|
604
|
+
body = resp.json()
|
|
605
|
+
detail = body.get("error") or body.get("detail") or ""
|
|
606
|
+
except Exception: # noqa: BLE001 — non-JSON error body; fall through
|
|
607
|
+
detail = resp.text.strip()
|
|
608
|
+
raise ValueError(detail or f"report request rejected ({resp.status_code})")
|
|
609
|
+
resp.raise_for_status()
|
|
610
|
+
return resp.json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""coord CLI command modules (#747 cli.py split)."""
|