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/commands/report.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""`coord report` — CLI surface over the report engine (#1742).
|
|
2
|
+
|
|
3
|
+
Sibling of ``coord audit``: same ``--json`` convention, same
|
|
4
|
+
``coord.state`` seam discipline (``list_reports`` / ``run_report`` route to
|
|
5
|
+
the daemon when ``board_service`` is set, local registry otherwise), so a
|
|
6
|
+
thin client never opens ``~/.coord/coord.db`` and never folds the audit
|
|
7
|
+
trail itself.
|
|
8
|
+
|
|
9
|
+
``coord report list`` prints the catalogue with each report's parameters,
|
|
10
|
+
their allowed values and their defaults. ``coord report run <id>`` renders
|
|
11
|
+
one report as a plain table plus its notes block; ``--format json`` emits
|
|
12
|
+
the ``ReportResult`` verbatim — the same bytes the daemon's
|
|
13
|
+
``GET /report/{id}`` returns for the same window.
|
|
14
|
+
|
|
15
|
+
``--format csv`` (#1765) writes the machine-readable form to stdout, so it
|
|
16
|
+
pipes and redirects normally. It calls :func:`coord.reports.result_to_csv`
|
|
17
|
+
on the **raw wire result** — the same function, on the same input, that
|
|
18
|
+
``GET /report/{id}?format=csv`` calls — rather than re-serialising the human
|
|
19
|
+
table above. That is deliberate and load-bearing twice over: it is why the
|
|
20
|
+
CLI and the daemon emit identical bytes, and it is why ``started_at``
|
|
21
|
+
exports as an epoch instead of the ``13h ago`` that ``_format_cell``
|
|
22
|
+
renders. ``--json`` survives as a hidden alias for ``--format json``.
|
|
23
|
+
|
|
24
|
+
Exit codes: ``2`` for a bad request (unknown report, unknown parameter, bad
|
|
25
|
+
value — the message names what was allowed), ``1`` for a read/transport
|
|
26
|
+
failure. Never a traceback for either.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json as _json
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import click
|
|
37
|
+
|
|
38
|
+
from coord.commands._common import _CONFIG_OPTION
|
|
39
|
+
|
|
40
|
+
# Row keys whose values are epoch timestamps — rendered as a relative age in
|
|
41
|
+
# the human table (absolute in --json, which is the machine contract).
|
|
42
|
+
# Fallback only: used when a row column is not covered by the report's
|
|
43
|
+
# `column_meta` (#1760) — a report that ships full metadata never reaches
|
|
44
|
+
# this.
|
|
45
|
+
_TS_COLUMNS = frozenset(
|
|
46
|
+
{"started_at", "merged_at", "first_event_at", "last_event_at"}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Per-column display cap for the human table. Long free text (titles, drive
|
|
50
|
+
# exit reasons) is truncated here and never in --json.
|
|
51
|
+
_MAX_CELL = 44
|
|
52
|
+
|
|
53
|
+
# Shorter headers for the human table only — `columns` itself is the wire
|
|
54
|
+
# contract (#1741 renders against it) and is never rewritten. Fallback only
|
|
55
|
+
# (#1760): a report's `column_meta[].label` takes precedence when present,
|
|
56
|
+
# so this list only matters for a report that ships none.
|
|
57
|
+
_HEADER_ALIASES = {
|
|
58
|
+
"started_at": "started",
|
|
59
|
+
"merged_at": "merged",
|
|
60
|
+
"fix_iterations": "fixes",
|
|
61
|
+
"test_verdicts": "tests",
|
|
62
|
+
"review_verdicts": "reviews",
|
|
63
|
+
"drive_exit": "drive exit",
|
|
64
|
+
"first_event_at": "first",
|
|
65
|
+
"last_event_at": "last",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _relative_time(ts: float | None, *, now: float | None = None) -> str:
|
|
70
|
+
if ts is None:
|
|
71
|
+
return "-"
|
|
72
|
+
delta = (time.time() if now is None else now) - float(ts)
|
|
73
|
+
if delta < 0:
|
|
74
|
+
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%m-%d %H:%M")
|
|
75
|
+
for seconds, unit in ((60, "s"), (3600, "m"), (86400, "h"), (86400 * 30, "d")):
|
|
76
|
+
if delta < seconds:
|
|
77
|
+
prior = {60: 1, 3600: 60, 86400: 3600, 86400 * 30: 86400}[seconds]
|
|
78
|
+
return f"{int(delta // prior)}{unit} ago"
|
|
79
|
+
return f"{int(delta // (86400 * 30))}mo ago"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _truncate(s: str, width: int) -> str:
|
|
83
|
+
return s if len(s) <= width else s[: max(1, width - 1)] + "…"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _format_cell(column: str, row: dict, meta: dict | None = None) -> str:
|
|
87
|
+
"""Render one row value for the human table.
|
|
88
|
+
|
|
89
|
+
Dispatches on the column's declared ``kind`` from ``column_meta``
|
|
90
|
+
(#1760) when the report supplies one — that is what lets the CLI stop
|
|
91
|
+
hardcoding per-field knowledge (``_TS_COLUMNS`` et al) and the panel and
|
|
92
|
+
the CLI format the same column the same way without drifting. Falls
|
|
93
|
+
back to dispatching on the value's *shape* (timestamp column by name,
|
|
94
|
+
list, dict, None) when no metadata is present, so a report that ships
|
|
95
|
+
none still gets a table.
|
|
96
|
+
"""
|
|
97
|
+
value = row.get(column)
|
|
98
|
+
kind = (meta or {}).get("kind")
|
|
99
|
+
is_timestamp = kind == "timestamp" if kind else column in _TS_COLUMNS
|
|
100
|
+
if column == "started_at" and value is None and row.get("started_before_window"):
|
|
101
|
+
# Not "unknown" — "began before this window". The distinction is the
|
|
102
|
+
# whole point of the started_before_window flag. Scoped to
|
|
103
|
+
# started_at specifically: a None merged_at just means "not merged
|
|
104
|
+
# yet" and must not also read as "before window".
|
|
105
|
+
return "<window"
|
|
106
|
+
if value is None:
|
|
107
|
+
return "-"
|
|
108
|
+
if is_timestamp:
|
|
109
|
+
return _relative_time(value)
|
|
110
|
+
if kind == "money":
|
|
111
|
+
# Four decimals, matching `coord usage`'s rollup views — a leg can
|
|
112
|
+
# genuinely cost $0.0032 and "$0.00" would read as free.
|
|
113
|
+
try:
|
|
114
|
+
return f"${float(value):.4f}"
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
return str(value)
|
|
117
|
+
if isinstance(value, bool):
|
|
118
|
+
return "yes" if value else "no"
|
|
119
|
+
if isinstance(value, (list, tuple)):
|
|
120
|
+
return ",".join(str(v) for v in value) if value else "-"
|
|
121
|
+
if isinstance(value, dict):
|
|
122
|
+
# drive_exit and friends: the two fields that matter, inline.
|
|
123
|
+
if "exit_code" in value:
|
|
124
|
+
reason = value.get("reason")
|
|
125
|
+
code = value.get("exit_code")
|
|
126
|
+
code_s = "crash" if code is None else str(code)
|
|
127
|
+
return f"exit {code_s}" + (f": {reason}" if reason else "")
|
|
128
|
+
return ",".join(f"{k}={v}" for k, v in value.items())
|
|
129
|
+
return str(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _pad(cell: str, width: int, align: str) -> str:
|
|
133
|
+
return cell.rjust(width) if align == "right" else cell.ljust(width)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render_table(result: dict) -> list[str]:
|
|
137
|
+
columns = list(result.get("columns") or [])
|
|
138
|
+
rows = list(result.get("rows") or [])
|
|
139
|
+
if not columns or not rows:
|
|
140
|
+
return []
|
|
141
|
+
meta_by_id = {
|
|
142
|
+
m.get("id"): m for m in (result.get("column_meta") or []) if isinstance(m, dict)
|
|
143
|
+
}
|
|
144
|
+
headers = [
|
|
145
|
+
(meta_by_id.get(c, {}).get("label") or _HEADER_ALIASES.get(c, c)).upper()
|
|
146
|
+
for c in columns
|
|
147
|
+
]
|
|
148
|
+
aligns = [meta_by_id.get(c, {}).get("align") or "left" for c in columns]
|
|
149
|
+
cells = [
|
|
150
|
+
[_truncate(_format_cell(c, r, meta_by_id.get(c)), _MAX_CELL) for c in columns]
|
|
151
|
+
for r in rows
|
|
152
|
+
]
|
|
153
|
+
# #1763: an optional pinned grand-total row. Rendered through the same
|
|
154
|
+
# per-column formatter as any other row — the only special-casing is the
|
|
155
|
+
# `Σ` marker in the first column, which the wire deliberately leaves
|
|
156
|
+
# blank so each renderer picks its own.
|
|
157
|
+
totals = result.get("totals")
|
|
158
|
+
footer: list[str] | None = None
|
|
159
|
+
if isinstance(totals, dict):
|
|
160
|
+
footer = [
|
|
161
|
+
_truncate(_format_cell(c, totals, meta_by_id.get(c)), _MAX_CELL)
|
|
162
|
+
for c in columns
|
|
163
|
+
]
|
|
164
|
+
if footer and footer[0] == "-":
|
|
165
|
+
footer[0] = "Σ"
|
|
166
|
+
widths = [
|
|
167
|
+
max(len(header), *(len(row[i]) for row in cells + ([footer] if footer else [])))
|
|
168
|
+
for i, header in enumerate(headers)
|
|
169
|
+
]
|
|
170
|
+
lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
|
|
171
|
+
for row in cells:
|
|
172
|
+
lines.append(
|
|
173
|
+
" ".join(_pad(cell, widths[i], aligns[i]) for i, cell in enumerate(row)).rstrip()
|
|
174
|
+
)
|
|
175
|
+
if footer:
|
|
176
|
+
lines.append(" ".join("-" * widths[i] for i in range(len(headers))).rstrip())
|
|
177
|
+
lines.append(
|
|
178
|
+
" ".join(_pad(cell, widths[i], aligns[i]) for i, cell in enumerate(footer)).rstrip()
|
|
179
|
+
)
|
|
180
|
+
return lines
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _abs_time(ts: float | None) -> str:
|
|
184
|
+
if ts is None:
|
|
185
|
+
return "?"
|
|
186
|
+
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime(
|
|
187
|
+
"%Y-%m-%d %H:%M:%SZ"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@click.group("report", help="Run reports over the coordinator's own history (#1742).")
|
|
192
|
+
def report_group() -> None:
|
|
193
|
+
"""Report engine — a small registry of named, parameterised reports."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@report_group.command("list", help="List the available reports and their parameters.")
|
|
197
|
+
@click.option("--json", "output_json", is_flag=True, default=False, help="Output the raw catalogue JSON.")
|
|
198
|
+
@_CONFIG_OPTION
|
|
199
|
+
def report_list(
|
|
200
|
+
output_json: bool,
|
|
201
|
+
config_path: Path, # noqa: ARG001 — accepted for --config-flag consistency; reports need no coordinator.yml
|
|
202
|
+
) -> None:
|
|
203
|
+
from coord.state import list_reports # noqa: PLC0415
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
cat = list_reports()
|
|
207
|
+
except Exception as e: # noqa: BLE001 — a clean CLI error, not a traceback
|
|
208
|
+
click.echo(f"error: report catalogue read failed: {e}", err=True)
|
|
209
|
+
raise SystemExit(1) from e
|
|
210
|
+
|
|
211
|
+
if output_json:
|
|
212
|
+
click.echo(_json.dumps(cat, indent=2, default=str))
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
reports = cat.get("reports") or []
|
|
216
|
+
if not reports:
|
|
217
|
+
click.echo("(no reports available)")
|
|
218
|
+
return
|
|
219
|
+
for i, rep in enumerate(reports):
|
|
220
|
+
if i:
|
|
221
|
+
click.echo("")
|
|
222
|
+
click.echo(f"{rep.get('id')} — {rep.get('title')}")
|
|
223
|
+
desc = (rep.get("description") or "").strip()
|
|
224
|
+
if desc:
|
|
225
|
+
click.echo(f" {desc}")
|
|
226
|
+
params = rep.get("params") or []
|
|
227
|
+
if not params:
|
|
228
|
+
click.echo(" params: (none)")
|
|
229
|
+
continue
|
|
230
|
+
click.echo(" params:")
|
|
231
|
+
for p in params:
|
|
232
|
+
default = p.get("default")
|
|
233
|
+
default_s = repr(default) if default else "(none)"
|
|
234
|
+
bits = [f"kind={p.get('kind')}", f"default={default_s}"]
|
|
235
|
+
choices = p.get("choices") or []
|
|
236
|
+
if choices:
|
|
237
|
+
allowed = ", ".join(str(c) for c in choices)
|
|
238
|
+
if p.get("free_form"):
|
|
239
|
+
allowed += ", or any duration (e.g. 13h)"
|
|
240
|
+
bits.append(f"allowed={allowed}")
|
|
241
|
+
click.echo(f" {p.get('id')} ({p.get('label')})")
|
|
242
|
+
click.echo(f" {'; '.join(bits)}")
|
|
243
|
+
help_text = (p.get("help") or "").strip()
|
|
244
|
+
if help_text:
|
|
245
|
+
click.echo(f" {help_text}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@report_group.command("run", help="Run a report by id.")
|
|
249
|
+
@click.argument("report_id")
|
|
250
|
+
@click.option(
|
|
251
|
+
"--param", "raw_params", multiple=True, metavar="KEY=VALUE",
|
|
252
|
+
help="Report parameter, repeatable (e.g. --param since=13h).",
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"--format", "output_format",
|
|
256
|
+
type=click.Choice(["table", "json", "csv"]),
|
|
257
|
+
default="table",
|
|
258
|
+
show_default=True,
|
|
259
|
+
help="Output encoding: human table, raw ReportResult JSON, or CSV.",
|
|
260
|
+
)
|
|
261
|
+
@click.option(
|
|
262
|
+
"--json", "legacy_json", is_flag=True, default=False, hidden=True,
|
|
263
|
+
help="Deprecated alias for --format json.",
|
|
264
|
+
)
|
|
265
|
+
@_CONFIG_OPTION
|
|
266
|
+
def report_run(
|
|
267
|
+
report_id: str,
|
|
268
|
+
raw_params: tuple[str, ...],
|
|
269
|
+
output_format: str,
|
|
270
|
+
legacy_json: bool,
|
|
271
|
+
config_path: Path, # noqa: ARG001 — accepted for --config-flag consistency; reports need no coordinator.yml
|
|
272
|
+
) -> None:
|
|
273
|
+
# #1765: `--json` predates `--format` and is kept as a hidden alias so
|
|
274
|
+
# existing scripts and the #1742 smoke commands keep working verbatim.
|
|
275
|
+
# Hidden, not removed: it is absent from --help (there is one documented
|
|
276
|
+
# way to ask for JSON) while still being accepted.
|
|
277
|
+
if legacy_json:
|
|
278
|
+
output_format = "json"
|
|
279
|
+
from coord.state import run_report # noqa: PLC0415
|
|
280
|
+
|
|
281
|
+
params: dict[str, str] = {}
|
|
282
|
+
for raw in raw_params:
|
|
283
|
+
if "=" not in raw:
|
|
284
|
+
click.echo(
|
|
285
|
+
f"error: --param expects KEY=VALUE, got {raw!r} "
|
|
286
|
+
"(e.g. --param since=13h)",
|
|
287
|
+
err=True,
|
|
288
|
+
)
|
|
289
|
+
raise SystemExit(2)
|
|
290
|
+
key, _, value = raw.partition("=")
|
|
291
|
+
params[key.strip()] = value
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
result = run_report(report_id, params)
|
|
295
|
+
except ValueError as e:
|
|
296
|
+
# ReportError (local) or the daemon's 400/404 body, re-raised as a
|
|
297
|
+
# ValueError by coord.client.fetch_report — either way it is a user
|
|
298
|
+
# error whose message already names what was allowed.
|
|
299
|
+
click.echo(f"error: {e}", err=True)
|
|
300
|
+
raise SystemExit(2) from e
|
|
301
|
+
except Exception as e: # noqa: BLE001 — transport/DB failure
|
|
302
|
+
click.echo(f"error: report run failed: {e}", err=True)
|
|
303
|
+
raise SystemExit(1) from e
|
|
304
|
+
|
|
305
|
+
if output_format == "json":
|
|
306
|
+
click.echo(_json.dumps(result, indent=2, default=str))
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if output_format == "csv":
|
|
310
|
+
# #1765: the *server's* serializer, applied to the raw wire result —
|
|
311
|
+
# never a re-serialisation of the human table. That is what makes
|
|
312
|
+
# these bytes identical to `GET /report/{id}?format=csv`, and what
|
|
313
|
+
# keeps `started_at` an epoch instead of the `13h ago` this module
|
|
314
|
+
# renders two functions up.
|
|
315
|
+
from coord.reports import result_to_csv # noqa: PLC0415
|
|
316
|
+
|
|
317
|
+
# nl=False: the serializer already terminates every line, and click
|
|
318
|
+
# would otherwise append a stray blank line that the daemon's bytes
|
|
319
|
+
# do not have.
|
|
320
|
+
click.echo(result_to_csv(result), nl=False)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
window = result.get("window") or [None, None]
|
|
324
|
+
click.echo(
|
|
325
|
+
f"{result.get('report_id')} — window {_abs_time(window[0])} → "
|
|
326
|
+
f"{_abs_time(window[1])}"
|
|
327
|
+
)
|
|
328
|
+
rows = result.get("rows") or []
|
|
329
|
+
click.echo("")
|
|
330
|
+
if not rows:
|
|
331
|
+
click.echo("(no activity in this window)")
|
|
332
|
+
else:
|
|
333
|
+
for line in _render_table(result):
|
|
334
|
+
click.echo(line)
|
|
335
|
+
|
|
336
|
+
notes = result.get("notes") or []
|
|
337
|
+
if notes:
|
|
338
|
+
click.echo("")
|
|
339
|
+
click.echo(f"notes ({len(notes)}):")
|
|
340
|
+
for note in notes:
|
|
341
|
+
click.echo(f" • {note}")
|