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/reports.py
ADDED
|
@@ -0,0 +1,1643 @@
|
|
|
1
|
+
"""Report engine (#1742) — a registry of named, parameterised reports folded
|
|
2
|
+
out of the coordinator's own history.
|
|
3
|
+
|
|
4
|
+
The point is to stop paying an Opus coordinator session to hand-roll the same
|
|
5
|
+
aggregation every morning. "What did the fleet do overnight, and where did it
|
|
6
|
+
all end up?" is pure deterministic arithmetic over the audit trail (#1036 /
|
|
7
|
+
#1037) — this module makes it a `coord report run` away, and reproducible.
|
|
8
|
+
|
|
9
|
+
Three layers, deliberately separated so the interesting one is testable:
|
|
10
|
+
|
|
11
|
+
1. :func:`fold_issue_activity` — **pure**. Takes already-fetched audit
|
|
12
|
+
entries plus an explicit ``(start, end)`` window and returns a
|
|
13
|
+
:class:`ReportResult`. No daemon, no DB, no clock (``generated_at``
|
|
14
|
+
defaults to the window end). This is where every derivation lives, and
|
|
15
|
+
it unit-tests against fixture events.
|
|
16
|
+
2. :func:`fetch_audit_window` — pagination. The audit read path hard-caps a
|
|
17
|
+
single call at 500 rows (``coord.audit.MAX_LIMIT``); that is a *page
|
|
18
|
+
size*, not a window bound, so this walks the keyset cursor until the
|
|
19
|
+
window is covered and reports ``truncated=True`` if it genuinely could
|
|
20
|
+
not finish. Never silently drops the tail (#1742: "no silent caps").
|
|
21
|
+
3. :data:`REPORTS` + :func:`run_report` — the registry and its parameter
|
|
22
|
+
validation. Three entries: ``issue-activity``; ``drive-queue-status``
|
|
23
|
+
(#1805), a **live snapshot** of ``drive_queue`` (no window, no audit
|
|
24
|
+
trail, no clock beyond ``generated_at``) rather than a fold over history;
|
|
25
|
+
and ``usage`` (#1763), a cost/token fold over board assignment rows that
|
|
26
|
+
delegates every number to :mod:`coord.usage_rollup` priced with the
|
|
27
|
+
daemon's own loaded ``pricing:`` config — the report that replaced
|
|
28
|
+
coord-tui's ``panel:usage`` and its hardcoded pricing snapshot.
|
|
29
|
+
|
|
30
|
+
The :class:`ReportResult` field names are the **wire contract** the coord-tui
|
|
31
|
+
Reports panel (#1741) renders against, and the CLI's ``--json`` and the
|
|
32
|
+
daemon's ``GET /report/{id}`` both emit exactly this shape — treat them as
|
|
33
|
+
public.
|
|
34
|
+
|
|
35
|
+
Read-only by construction: every query here is a ``SELECT``. Running a
|
|
36
|
+
report must never touch the board (this repo has a recurring
|
|
37
|
+
"``reconcile()`` accretes behaviour" problem; reports do not join it).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import csv
|
|
43
|
+
import io
|
|
44
|
+
import re
|
|
45
|
+
import time
|
|
46
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"ReportError",
|
|
53
|
+
"UnknownReportError",
|
|
54
|
+
"ReportParam",
|
|
55
|
+
"ReportDef",
|
|
56
|
+
"ColumnMeta",
|
|
57
|
+
"ReportResult",
|
|
58
|
+
"REPORTS",
|
|
59
|
+
"catalogue",
|
|
60
|
+
"resolve_params",
|
|
61
|
+
"run_report",
|
|
62
|
+
"fetch_audit_window",
|
|
63
|
+
"detect_prior_activity",
|
|
64
|
+
"fold_issue_activity",
|
|
65
|
+
"run_issue_activity",
|
|
66
|
+
"fold_drive_queue_status",
|
|
67
|
+
"run_drive_queue_status",
|
|
68
|
+
"resolve_usage_window",
|
|
69
|
+
"fold_usage",
|
|
70
|
+
"run_usage",
|
|
71
|
+
"parse_duration",
|
|
72
|
+
"result_to_csv",
|
|
73
|
+
"csv_filename",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ReportError(ValueError):
|
|
78
|
+
"""A bad request against the report engine — unknown parameter, bad value.
|
|
79
|
+
|
|
80
|
+
Callers (the CLI, the daemon) turn this into a clean message + non-zero
|
|
81
|
+
exit / 400, never a traceback.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UnknownReportError(ReportError):
|
|
86
|
+
"""The requested ``report_id`` is not in :data:`REPORTS` (daemon: 404)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── parameter / definition / result shapes ─────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ReportParam:
|
|
94
|
+
"""One parameter of a report, described richly enough that a client can
|
|
95
|
+
build its input form from the catalogue alone (#1741 must NOT hardcode
|
|
96
|
+
the param list).
|
|
97
|
+
|
|
98
|
+
``kind`` is ``"choice"`` (render a picker over ``choices``) or ``"text"``
|
|
99
|
+
(render a free-text field). ``free_form`` marks a ``choice`` param whose
|
|
100
|
+
``choices`` are *presets* rather than a whitelist — ``since`` is one:
|
|
101
|
+
``13h`` is a perfectly good window that nobody wants in a five-item
|
|
102
|
+
picker. ``validate`` is the server-side check, and is the authority; a
|
|
103
|
+
client's form is a convenience on top of it.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
id: str
|
|
107
|
+
label: str
|
|
108
|
+
kind: str = "text"
|
|
109
|
+
choices: tuple[str, ...] = ()
|
|
110
|
+
default: str = ""
|
|
111
|
+
help: str = ""
|
|
112
|
+
free_form: bool = False
|
|
113
|
+
# Not part of the wire shape — the server-side validator. Raises
|
|
114
|
+
# ReportError (message names the allowed values) on a bad value.
|
|
115
|
+
validate: Callable[[str], None] | None = field(default=None, compare=False)
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"id": self.id,
|
|
120
|
+
"label": self.label,
|
|
121
|
+
"kind": self.kind,
|
|
122
|
+
"choices": list(self.choices),
|
|
123
|
+
"default": self.default,
|
|
124
|
+
"help": self.help,
|
|
125
|
+
"free_form": self.free_form,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class ReportDef:
|
|
131
|
+
"""A named report. ``run(**params)`` returns a :class:`ReportResult`."""
|
|
132
|
+
|
|
133
|
+
id: str
|
|
134
|
+
title: str
|
|
135
|
+
description: str
|
|
136
|
+
params: tuple[ReportParam, ...]
|
|
137
|
+
run: Callable[..., "ReportResult"] = field(compare=False)
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, Any]:
|
|
140
|
+
"""Catalogue entry — everything a client needs except the callable."""
|
|
141
|
+
return {
|
|
142
|
+
"id": self.id,
|
|
143
|
+
"title": self.title,
|
|
144
|
+
"description": self.description,
|
|
145
|
+
"params": [p.to_dict() for p in self.params],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class ColumnMeta:
|
|
151
|
+
"""Display metadata for one entry of ``ReportResult.columns`` (#1760).
|
|
152
|
+
|
|
153
|
+
Additive, not a retype: ``columns`` stays a bare ``list[str]`` (the
|
|
154
|
+
already-shipped #1741 panel deserialises it as ``Vec<String>`` and must
|
|
155
|
+
keep working unchanged), and row values stay raw — a ``started_at`` cell
|
|
156
|
+
is still an epoch float, a ``machines`` cell is still a list. This is
|
|
157
|
+
only the hint a generic renderer needs to turn that raw value into a
|
|
158
|
+
reasonable cell: ``kind`` says how to format it, ``align``/``weight``
|
|
159
|
+
say how to lay out the column. ``id`` matches the corresponding
|
|
160
|
+
``columns[]`` entry (and order matches too), so a client can zip them.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
label: str
|
|
165
|
+
# Open vocabulary — a client that meets a `kind` it predates must fall
|
|
166
|
+
# back to plain stringification, never fail to parse:
|
|
167
|
+
# "text" | "int" | "timestamp" | "list" | "enum" | "duration" | "money"
|
|
168
|
+
kind: str
|
|
169
|
+
align: str = "left" # "left" | "right"
|
|
170
|
+
weight: float = 1.0 # relative column width hint
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict[str, Any]:
|
|
173
|
+
return {
|
|
174
|
+
"id": self.id,
|
|
175
|
+
"label": self.label,
|
|
176
|
+
"kind": self.kind,
|
|
177
|
+
"align": self.align,
|
|
178
|
+
"weight": self.weight,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class ReportResult:
|
|
184
|
+
"""The wire contract (#1741 renders against these exact field names).
|
|
185
|
+
|
|
186
|
+
``columns`` is the ordered list of row keys worth putting in a table;
|
|
187
|
+
``rows`` may carry extra keys beyond it (``started_before_window``,
|
|
188
|
+
``last_event_at``, ...) for clients that want the detail. ``notes``
|
|
189
|
+
holds derived anomalies and caveats, rendered under the table.
|
|
190
|
+
``column_meta`` is additive display metadata, one entry per ``columns``
|
|
191
|
+
entry in the same order (#1760) — a client that ignores it entirely
|
|
192
|
+
still gets byte-identical ``columns``/``rows``.
|
|
193
|
+
|
|
194
|
+
``totals`` (#1763) is an optional grand-total row for reports that are a
|
|
195
|
+
*fold* with a meaningful sum (``usage``), keyed by the same column ids as
|
|
196
|
+
``rows``. It is **additive and defaults to ``None``**: reports that have
|
|
197
|
+
no meaningful total (``issue-activity``, ``drive-queue-status``) leave it
|
|
198
|
+
unset, and a client that ignores the key renders exactly as it did
|
|
199
|
+
before. Identity columns are deliberately *absent* from the dict rather
|
|
200
|
+
than filled with a placeholder — a renderer that wants a ``Σ`` marker
|
|
201
|
+
picks one itself, and one that doesn't leaves the cell blank.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
report_id: str
|
|
205
|
+
generated_at: float
|
|
206
|
+
window: tuple[float, float]
|
|
207
|
+
columns: list[str]
|
|
208
|
+
rows: list[dict]
|
|
209
|
+
notes: list[str]
|
|
210
|
+
column_meta: list[ColumnMeta] = field(default_factory=list)
|
|
211
|
+
totals: dict[str, Any] | None = None
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict[str, Any]:
|
|
214
|
+
return {
|
|
215
|
+
"report_id": self.report_id,
|
|
216
|
+
"generated_at": self.generated_at,
|
|
217
|
+
"window": [self.window[0], self.window[1]],
|
|
218
|
+
"columns": list(self.columns),
|
|
219
|
+
"column_meta": [m.to_dict() for m in self.column_meta],
|
|
220
|
+
"rows": list(self.rows),
|
|
221
|
+
"notes": list(self.notes),
|
|
222
|
+
"totals": None if self.totals is None else dict(self.totals),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── time helpers ───────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*$", re.IGNORECASE)
|
|
229
|
+
_UNIT_SECONDS = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0, "w": 604800.0}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_duration(raw: str) -> float:
|
|
233
|
+
"""``"13h"`` → ``46800.0``. Units: s, m, h, d, w. Raises ReportError."""
|
|
234
|
+
match = _DURATION_RE.match(raw or "")
|
|
235
|
+
if match is None:
|
|
236
|
+
raise ReportError(
|
|
237
|
+
f"not a duration: {raw!r} — expected e.g. '90m', '13h', '3d' "
|
|
238
|
+
"(units: s, m, h, d, w)"
|
|
239
|
+
)
|
|
240
|
+
return float(match.group(1)) * _UNIT_SECONDS[match.group(2).lower()]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def parse_timestamp(raw: str) -> float:
|
|
244
|
+
"""Epoch seconds or ISO-8601 → float. Mirrors ``coord audit``'s parsing
|
|
245
|
+
so ``--param until=...`` and ``coord audit --until`` agree."""
|
|
246
|
+
try:
|
|
247
|
+
return float(raw)
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
pass
|
|
250
|
+
try:
|
|
251
|
+
return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).timestamp()
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
raise ReportError(
|
|
254
|
+
f"not an epoch number or ISO-8601 timestamp: {raw!r}"
|
|
255
|
+
) from exc
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _iso(ts: float | None) -> str:
|
|
259
|
+
if ts is None:
|
|
260
|
+
return "?"
|
|
261
|
+
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime(
|
|
262
|
+
"%Y-%m-%d %H:%M:%SZ"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── parameter resolution ───────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def resolve_params(report: ReportDef, raw: Mapping[str, Any] | None) -> dict[str, str]:
|
|
270
|
+
"""Validate ``raw`` against ``report.params`` and fill in defaults.
|
|
271
|
+
|
|
272
|
+
Unknown keys and bad values raise :class:`ReportError` with a message
|
|
273
|
+
that names what *was* allowed — the CLI and the daemon both surface it
|
|
274
|
+
verbatim, so it has to read well on its own.
|
|
275
|
+
"""
|
|
276
|
+
raw = dict(raw or {})
|
|
277
|
+
known = {p.id: p for p in report.params}
|
|
278
|
+
for key in raw:
|
|
279
|
+
if key not in known:
|
|
280
|
+
raise ReportError(
|
|
281
|
+
f"unknown parameter {key!r} for report {report.id!r} — "
|
|
282
|
+
f"known parameters: {', '.join(sorted(known)) or '(none)'}"
|
|
283
|
+
)
|
|
284
|
+
resolved: dict[str, str] = {}
|
|
285
|
+
for param in report.params:
|
|
286
|
+
value = raw.get(param.id)
|
|
287
|
+
value = param.default if value is None or value == "" else str(value)
|
|
288
|
+
_validate_param(param, value)
|
|
289
|
+
resolved[param.id] = value
|
|
290
|
+
return resolved
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _validate_param(param: ReportParam, value: str) -> None:
|
|
294
|
+
if param.validate is not None:
|
|
295
|
+
param.validate(value)
|
|
296
|
+
return
|
|
297
|
+
if param.kind == "choice" and param.choices and not param.free_form:
|
|
298
|
+
if value not in param.choices:
|
|
299
|
+
raise ReportError(
|
|
300
|
+
f"invalid value for {param.id!r}: {value!r} — "
|
|
301
|
+
f"allowed values: {', '.join(param.choices)}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ── audit fetch + pagination ───────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
# 100 pages x 500 rows = 50k events. Far past any real window; a backstop
|
|
308
|
+
# against an infinite cursor walk, not a coverage limit — hitting it sets
|
|
309
|
+
# truncated=True and the report says so in `notes`.
|
|
310
|
+
MAX_PAGES = 100
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _default_fetch(**kwargs: Any) -> dict:
|
|
314
|
+
from coord.audit import query_audit_log # noqa: PLC0415
|
|
315
|
+
|
|
316
|
+
return query_audit_log(**kwargs)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fetch_audit_window(
|
|
320
|
+
*,
|
|
321
|
+
since: float,
|
|
322
|
+
until: float,
|
|
323
|
+
repo: str | None = None,
|
|
324
|
+
fetch: Callable[..., Mapping[str, Any]] | None = None,
|
|
325
|
+
page_limit: int | None = None,
|
|
326
|
+
max_pages: int = MAX_PAGES,
|
|
327
|
+
) -> tuple[list[dict], bool]:
|
|
328
|
+
"""Walk the keyset cursor until the whole ``[since, until]`` window is
|
|
329
|
+
covered. Returns ``(entries, truncated)``.
|
|
330
|
+
|
|
331
|
+
``truncated`` is True only when the walk gave up with rows still
|
|
332
|
+
outstanding (page cap hit, or a page claimed ``has_more`` but handed
|
|
333
|
+
back no cursor) — the caller turns that into an explicit note rather
|
|
334
|
+
than shipping a silently short answer.
|
|
335
|
+
"""
|
|
336
|
+
if fetch is None:
|
|
337
|
+
fetch = _default_fetch
|
|
338
|
+
if page_limit is None:
|
|
339
|
+
from coord.audit import MAX_LIMIT # noqa: PLC0415
|
|
340
|
+
|
|
341
|
+
page_limit = MAX_LIMIT
|
|
342
|
+
|
|
343
|
+
entries: list[dict] = []
|
|
344
|
+
cursor: str | None = None
|
|
345
|
+
truncated = True # flipped to False the moment a page says "that's all"
|
|
346
|
+
for _ in range(max(1, int(max_pages))):
|
|
347
|
+
page = fetch(
|
|
348
|
+
since=since,
|
|
349
|
+
until=until,
|
|
350
|
+
repo=repo or None,
|
|
351
|
+
limit=page_limit,
|
|
352
|
+
cursor=cursor,
|
|
353
|
+
) or {}
|
|
354
|
+
entries.extend(page.get("entries") or [])
|
|
355
|
+
if not page.get("has_more"):
|
|
356
|
+
truncated = False
|
|
357
|
+
break
|
|
358
|
+
cursor = page.get("next_cursor")
|
|
359
|
+
if not cursor:
|
|
360
|
+
# has_more with no cursor — can't advance; stop rather than loop.
|
|
361
|
+
break
|
|
362
|
+
return entries, truncated
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ── issue-activity: the fold ───────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
ISSUE_ACTIVITY_COLUMNS = [
|
|
368
|
+
"repo",
|
|
369
|
+
"issue",
|
|
370
|
+
"title",
|
|
371
|
+
"started_at",
|
|
372
|
+
"machines",
|
|
373
|
+
"fix_iterations",
|
|
374
|
+
"test_verdicts",
|
|
375
|
+
"review_verdicts",
|
|
376
|
+
"merged_at",
|
|
377
|
+
"drive_exit",
|
|
378
|
+
"outcome",
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
# One entry per ISSUE_ACTIVITY_COLUMNS entry, same order (#1760) — the
|
|
382
|
+
# display metadata a generic renderer (CLI table, coord-tui panel) needs to
|
|
383
|
+
# format a raw row value without hardcoding per-report field knowledge.
|
|
384
|
+
ISSUE_ACTIVITY_COLUMN_META = [
|
|
385
|
+
ColumnMeta(id="repo", label="Repo", kind="text"),
|
|
386
|
+
ColumnMeta(id="issue", label="Issue", kind="int", align="right"),
|
|
387
|
+
ColumnMeta(id="title", label="Title", kind="text", weight=3.0),
|
|
388
|
+
ColumnMeta(id="started_at", label="Started", kind="timestamp"),
|
|
389
|
+
ColumnMeta(id="machines", label="Machines", kind="list"),
|
|
390
|
+
ColumnMeta(id="fix_iterations", label="Fixes", kind="int", align="right"),
|
|
391
|
+
ColumnMeta(id="test_verdicts", label="Tests", kind="list"),
|
|
392
|
+
ColumnMeta(id="review_verdicts", label="Reviews", kind="list"),
|
|
393
|
+
ColumnMeta(id="merged_at", label="Merged", kind="timestamp"),
|
|
394
|
+
ColumnMeta(id="drive_exit", label="Drive Exit", kind="text"),
|
|
395
|
+
ColumnMeta(id="outcome", label="Outcome", kind="enum"),
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
_TEST_EVENTS = ("test_passed", "test_failed", "test_skipped")
|
|
399
|
+
_REVIEW_EVENTS = ("review_approve", "review_request-changes")
|
|
400
|
+
|
|
401
|
+
# An issue with no drive_exit and no event for this long by the end of the
|
|
402
|
+
# window is called `stalled` rather than `in-flight`. Two hours is well past
|
|
403
|
+
# any normal gate turnaround in this fleet.
|
|
404
|
+
STALL_QUIET_SECONDS = 2 * 3600.0
|
|
405
|
+
|
|
406
|
+
# A work-like dispatch is a real attempt at the issue; a review/smoke/plan
|
|
407
|
+
# dispatch is not, and must not count as a fix iteration.
|
|
408
|
+
_WORK_LIKE_TYPES = frozenset({"work", "mock-author", "test-author"})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def fold_issue_activity(
|
|
412
|
+
entries: Iterable[Mapping[str, Any]],
|
|
413
|
+
window: tuple[float, float],
|
|
414
|
+
*,
|
|
415
|
+
titles: Mapping[tuple[str, int], str] | None = None,
|
|
416
|
+
generated_at: float | None = None,
|
|
417
|
+
truncated: bool = False,
|
|
418
|
+
prior_activity: frozenset[tuple[str, int]] = frozenset(),
|
|
419
|
+
) -> ReportResult:
|
|
420
|
+
"""Fold audit entries into one row per ``(repo, issue)``.
|
|
421
|
+
|
|
422
|
+
**Pure** — no DB, no daemon, no clock. ``generated_at`` defaults to the
|
|
423
|
+
window end so a frozen-clock test gets a deterministic result.
|
|
424
|
+
|
|
425
|
+
``entries`` may arrive in any order (the audit read path is newest-first);
|
|
426
|
+
they are sorted ascending on ``(ts, id)`` here, which is what makes
|
|
427
|
+
"first dispatch", "last merge" and the ordered verdict lists mean what
|
|
428
|
+
they say.
|
|
429
|
+
|
|
430
|
+
``prior_activity`` (#1760) is the one fact this pure fold cannot derive
|
|
431
|
+
for itself: the set of ``(repo, issue)`` keys that have *any* audit event
|
|
432
|
+
before the window opened, as determined by the caller's bounded
|
|
433
|
+
look-back (:func:`detect_prior_activity`). Without it, an issue whose
|
|
434
|
+
real start predates the window but which was re-dispatched inside it
|
|
435
|
+
reads as "started here, zero fixes" — a real timestamp and a real count
|
|
436
|
+
that are both wrong, with nothing in the row saying so. With it, that
|
|
437
|
+
row instead reports ``started_at=None``, ``started_before_window=True``
|
|
438
|
+
and ``counts_partial=True``, and every in-window work dispatch counts as
|
|
439
|
+
a fix (the issue was already running when the window opened, so each one
|
|
440
|
+
is a re-dispatch). Default is empty, so existing callers are unaffected
|
|
441
|
+
in shape.
|
|
442
|
+
"""
|
|
443
|
+
start, end = float(window[0]), float(window[1])
|
|
444
|
+
title_map = dict(titles or {})
|
|
445
|
+
|
|
446
|
+
usable: list[Mapping[str, Any]] = []
|
|
447
|
+
orphans = 0
|
|
448
|
+
for entry in entries:
|
|
449
|
+
if entry.get("repo") and entry.get("issue") is not None:
|
|
450
|
+
usable.append(entry)
|
|
451
|
+
else:
|
|
452
|
+
orphans += 1
|
|
453
|
+
usable.sort(key=lambda e: (float(e.get("ts") or 0.0), int(e.get("id") or 0)))
|
|
454
|
+
|
|
455
|
+
groups: dict[tuple[str, int], list[Mapping[str, Any]]] = {}
|
|
456
|
+
for entry in usable:
|
|
457
|
+
key = (str(entry["repo"]), int(entry["issue"]))
|
|
458
|
+
groups.setdefault(key, []).append(entry)
|
|
459
|
+
|
|
460
|
+
rows = [
|
|
461
|
+
_fold_one_issue(
|
|
462
|
+
repo,
|
|
463
|
+
issue,
|
|
464
|
+
evs,
|
|
465
|
+
end,
|
|
466
|
+
title_map.get((repo, issue)),
|
|
467
|
+
had_prior_activity=(repo, issue) in prior_activity,
|
|
468
|
+
)
|
|
469
|
+
for (repo, issue), evs in groups.items()
|
|
470
|
+
]
|
|
471
|
+
# Most-recently-active first: the morning question is "what moved", and
|
|
472
|
+
# the thing that moved last is the thing still moving.
|
|
473
|
+
rows.sort(key=lambda r: r["last_event_at"] or 0.0, reverse=True)
|
|
474
|
+
|
|
475
|
+
notes: list[str] = []
|
|
476
|
+
if truncated:
|
|
477
|
+
notes.append(
|
|
478
|
+
f"TRUNCATED: the window {_iso(start)} → {_iso(end)} could not be "
|
|
479
|
+
f"fully fetched from the audit trail ({len(usable) + orphans} "
|
|
480
|
+
"events read before the page cap). Rows below cover only part of "
|
|
481
|
+
"the window — narrow it with a smaller `since` for a complete "
|
|
482
|
+
"answer."
|
|
483
|
+
)
|
|
484
|
+
if orphans:
|
|
485
|
+
notes.append(
|
|
486
|
+
f"{orphans} event(s) in the window carry no repo/issue "
|
|
487
|
+
"(fleet-level housekeeping) and are not represented in any row."
|
|
488
|
+
)
|
|
489
|
+
notes.extend(_derive_notes(rows))
|
|
490
|
+
|
|
491
|
+
return ReportResult(
|
|
492
|
+
report_id="issue-activity",
|
|
493
|
+
generated_at=end if generated_at is None else float(generated_at),
|
|
494
|
+
window=(start, end),
|
|
495
|
+
columns=list(ISSUE_ACTIVITY_COLUMNS),
|
|
496
|
+
column_meta=list(ISSUE_ACTIVITY_COLUMN_META),
|
|
497
|
+
rows=rows,
|
|
498
|
+
notes=notes,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _fold_one_issue(
|
|
503
|
+
repo: str,
|
|
504
|
+
issue: int,
|
|
505
|
+
events: Sequence[Mapping[str, Any]],
|
|
506
|
+
window_end: float,
|
|
507
|
+
title: str | None,
|
|
508
|
+
*,
|
|
509
|
+
had_prior_activity: bool = False,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
started_at: float | None = None
|
|
512
|
+
machines: list[str] = []
|
|
513
|
+
work_dispatches = 0
|
|
514
|
+
test_verdicts: list[str] = []
|
|
515
|
+
review_verdicts: list[str] = []
|
|
516
|
+
merged_at: float | None = None
|
|
517
|
+
drive_exit: dict[str, Any] | None = None
|
|
518
|
+
|
|
519
|
+
for entry in events:
|
|
520
|
+
category = entry.get("category")
|
|
521
|
+
event_type = entry.get("event_type")
|
|
522
|
+
ts = float(entry.get("ts") or 0.0)
|
|
523
|
+
details = entry.get("details") or {}
|
|
524
|
+
if not isinstance(details, Mapping):
|
|
525
|
+
details = {}
|
|
526
|
+
machine = entry.get("machine")
|
|
527
|
+
if machine and machine not in machines:
|
|
528
|
+
machines.append(machine)
|
|
529
|
+
|
|
530
|
+
if category == "drive" and event_type == "drive_started":
|
|
531
|
+
if started_at is None:
|
|
532
|
+
started_at = ts
|
|
533
|
+
elif category == "dispatch" and event_type == "dispatched":
|
|
534
|
+
# `details.type` is absent on the oldest rows; "work" is the
|
|
535
|
+
# assignment default, so that is the right assumption.
|
|
536
|
+
if (details.get("type") or "work") in _WORK_LIKE_TYPES:
|
|
537
|
+
work_dispatches += 1
|
|
538
|
+
if started_at is None:
|
|
539
|
+
started_at = ts
|
|
540
|
+
elif category == "test" and event_type in _TEST_EVENTS:
|
|
541
|
+
test_verdicts.append(str(event_type)[len("test_"):])
|
|
542
|
+
elif category == "review" and event_type in _REVIEW_EVENTS:
|
|
543
|
+
review_verdicts.append(str(event_type)[len("review_"):])
|
|
544
|
+
elif category == "merge" and event_type == "merged":
|
|
545
|
+
merged_at = ts
|
|
546
|
+
elif category == "drive" and event_type == "drive_exited":
|
|
547
|
+
drive_exit = {
|
|
548
|
+
"at": ts,
|
|
549
|
+
"exit_code": details.get("exit_code"),
|
|
550
|
+
"reason": details.get("reason") or details.get("error"),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if had_prior_activity:
|
|
554
|
+
# The caller's look-back (#1760) found an event before the window
|
|
555
|
+
# opened — this issue was already running. Report that plainly
|
|
556
|
+
# rather than claiming a start the window cannot support: no start
|
|
557
|
+
# time, and every in-window work dispatch is a re-dispatch (not
|
|
558
|
+
# "first dispatch, zero fixes").
|
|
559
|
+
started_at = None
|
|
560
|
+
started_before_window = True
|
|
561
|
+
fix_iterations = work_dispatches
|
|
562
|
+
else:
|
|
563
|
+
# "In-window activity, but no start event in it" — the issue began
|
|
564
|
+
# before the window opened. Reported as started_at=None + this flag
|
|
565
|
+
# rather than as a bogus start time taken from the first event we
|
|
566
|
+
# happened to see.
|
|
567
|
+
started_before_window = started_at is None
|
|
568
|
+
# Every work dispatch after the *first* one is a fix iteration.
|
|
569
|
+
fix_iterations = max(0, work_dispatches - 1)
|
|
570
|
+
# counts_partial is narrower than started_before_window: the latter can
|
|
571
|
+
# also fire from the plain "no start event in this window" inference
|
|
572
|
+
# above, which doesn't know whether fix_iterations/test_verdicts are
|
|
573
|
+
# complete or merely empty. Only a confirmed look-back hit means the
|
|
574
|
+
# counts are a known lower bound.
|
|
575
|
+
counts_partial = had_prior_activity
|
|
576
|
+
|
|
577
|
+
first_event_at = float(events[0].get("ts") or 0.0) if events else None
|
|
578
|
+
last_event_at = float(events[-1].get("ts") or 0.0) if events else None
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
"repo": repo,
|
|
582
|
+
"issue": issue,
|
|
583
|
+
"title": title,
|
|
584
|
+
"started_at": started_at,
|
|
585
|
+
"started_before_window": started_before_window,
|
|
586
|
+
"machines": machines,
|
|
587
|
+
"fix_iterations": fix_iterations,
|
|
588
|
+
"counts_partial": counts_partial,
|
|
589
|
+
"test_verdicts": test_verdicts,
|
|
590
|
+
"review_verdicts": review_verdicts,
|
|
591
|
+
"merged_at": merged_at,
|
|
592
|
+
"drive_exit": drive_exit,
|
|
593
|
+
"outcome": _derive_outcome(merged_at, drive_exit, last_event_at, window_end),
|
|
594
|
+
"first_event_at": first_event_at,
|
|
595
|
+
"last_event_at": last_event_at,
|
|
596
|
+
"event_count": len(events),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _nonzero_exit(drive_exit: Mapping[str, Any] | None) -> bool:
|
|
601
|
+
"""True when the driver did NOT exit clean. A missing/None ``exit_code``
|
|
602
|
+
counts — that shape is written by the crash path
|
|
603
|
+
(``DriveRunner._drive_exit_summary``), which is exactly as unclean as a
|
|
604
|
+
non-zero code."""
|
|
605
|
+
return drive_exit is not None and drive_exit.get("exit_code") != 0
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _derive_outcome(
|
|
609
|
+
merged_at: float | None,
|
|
610
|
+
drive_exit: Mapping[str, Any] | None,
|
|
611
|
+
last_event_at: float | None,
|
|
612
|
+
window_end: float,
|
|
613
|
+
) -> str:
|
|
614
|
+
if merged_at is not None:
|
|
615
|
+
return "merged"
|
|
616
|
+
if drive_exit is not None:
|
|
617
|
+
# The driver is gone. Non-zero => it gave up loudly; clean exit with
|
|
618
|
+
# nothing landed => it gave up quietly. Neither is in-flight.
|
|
619
|
+
return "failed" if _nonzero_exit(drive_exit) else "stalled"
|
|
620
|
+
if last_event_at is not None and (window_end - last_event_at) > STALL_QUIET_SECONDS:
|
|
621
|
+
return "stalled"
|
|
622
|
+
return "in-flight"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _derive_notes(rows: Sequence[Mapping[str, Any]]) -> list[str]:
|
|
626
|
+
"""Anomalies worth a human's eye, derived from the folded rows.
|
|
627
|
+
|
|
628
|
+
The load-bearing one is the first: a driver that exits non-zero on an
|
|
629
|
+
issue that then merges anyway. That is the real 2026-08-02 case
|
|
630
|
+
(#1631 exited 1 with "merge attempted 3 times without landing" at 21:48;
|
|
631
|
+
the merge landed at 22:01) — a driver giving up on a merge that was
|
|
632
|
+
still converging, otherwise invisible in both the event stream and the
|
|
633
|
+
final board state.
|
|
634
|
+
"""
|
|
635
|
+
notes: list[str] = []
|
|
636
|
+
for row in rows:
|
|
637
|
+
ident = f"{row['repo']}#{row['issue']}"
|
|
638
|
+
drive_exit = row.get("drive_exit")
|
|
639
|
+
if drive_exit and _nonzero_exit(drive_exit) and row.get("merged_at") is not None:
|
|
640
|
+
reason = drive_exit.get("reason")
|
|
641
|
+
reason_part = f" ({reason})" if reason else ""
|
|
642
|
+
notes.append(
|
|
643
|
+
f"{ident}: driver exited exit_code="
|
|
644
|
+
f"{drive_exit.get('exit_code')!r} at {_iso(drive_exit.get('at'))}"
|
|
645
|
+
f"{reason_part}, but the merge landed at "
|
|
646
|
+
f"{_iso(row['merged_at'])} — the driver gave up on a merge "
|
|
647
|
+
"that was still converging."
|
|
648
|
+
)
|
|
649
|
+
if (
|
|
650
|
+
row.get("merged_at") is not None
|
|
651
|
+
and row.get("test_verdicts")
|
|
652
|
+
and row["test_verdicts"][-1] == "failed"
|
|
653
|
+
):
|
|
654
|
+
notes.append(
|
|
655
|
+
f"{ident}: merged at {_iso(row['merged_at'])} with the last "
|
|
656
|
+
"in-window Test-gate verdict still 'failed'."
|
|
657
|
+
)
|
|
658
|
+
if int(row.get("fix_iterations") or 0) >= 3:
|
|
659
|
+
notes.append(
|
|
660
|
+
f"{ident}: {row['fix_iterations']} fix iterations in this "
|
|
661
|
+
"window — the work is not converging on its own."
|
|
662
|
+
)
|
|
663
|
+
if row.get("counts_partial"):
|
|
664
|
+
# #1760: this issue was already running when the window opened
|
|
665
|
+
# (the caller's look-back found an earlier event) — say so
|
|
666
|
+
# explicitly rather than let a real-looking fix_iterations/
|
|
667
|
+
# test_verdicts count pass as complete.
|
|
668
|
+
notes.append(
|
|
669
|
+
f"{ident}: started before this window — fix_iterations and "
|
|
670
|
+
"test_verdicts are lower bounds, not the full count. Widen "
|
|
671
|
+
"`since` to see the real start."
|
|
672
|
+
)
|
|
673
|
+
elif (
|
|
674
|
+
"request-changes" in (row.get("review_verdicts") or [])
|
|
675
|
+
and int(row.get("fix_iterations") or 0) == 0
|
|
676
|
+
):
|
|
677
|
+
# #1760: a request-changes verdict implies at least one
|
|
678
|
+
# re-dispatch happened. fix_iterations=0 with counts_partial
|
|
679
|
+
# False (the elif) means the fold believes it saw the whole
|
|
680
|
+
# window's activity — this combination should not be reachable,
|
|
681
|
+
# and if it appears the row is self-contradictory.
|
|
682
|
+
notes.append(
|
|
683
|
+
f"{ident}: review verdict 'request-changes' with "
|
|
684
|
+
"fix_iterations=0 — this combination should not happen; the "
|
|
685
|
+
"row is internally inconsistent."
|
|
686
|
+
)
|
|
687
|
+
return notes
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
# ── issue-activity: the runner ─────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _lookup_titles(
|
|
694
|
+
keys: Iterable[tuple[str, int]],
|
|
695
|
+
) -> dict[tuple[str, int], str]:
|
|
696
|
+
"""Best-effort issue titles from the local DB. Read-only, and failure is
|
|
697
|
+
not an error — a missing title renders as ``None`` in the row, which is
|
|
698
|
+
strictly better than failing the whole report over cosmetics."""
|
|
699
|
+
keys = sorted(set(keys))
|
|
700
|
+
if not keys:
|
|
701
|
+
return {}
|
|
702
|
+
out: dict[tuple[str, int], str] = {}
|
|
703
|
+
try:
|
|
704
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
705
|
+
|
|
706
|
+
conn = get_connection()
|
|
707
|
+
for repo, number in keys:
|
|
708
|
+
row = conn.execute(
|
|
709
|
+
"SELECT title FROM issues WHERE repo_name = ? AND number = ?",
|
|
710
|
+
(repo, number),
|
|
711
|
+
).fetchone()
|
|
712
|
+
if row is not None and row["title"]:
|
|
713
|
+
out[(repo, number)] = row["title"]
|
|
714
|
+
continue
|
|
715
|
+
row = conn.execute(
|
|
716
|
+
"SELECT issue_title FROM assignments WHERE repo_name = ? "
|
|
717
|
+
"AND issue_number = ? AND issue_title IS NOT NULL "
|
|
718
|
+
"ORDER BY rowid DESC LIMIT 1",
|
|
719
|
+
(repo, number),
|
|
720
|
+
).fetchone()
|
|
721
|
+
if row is not None and row["issue_title"]:
|
|
722
|
+
out[(repo, number)] = row["issue_title"]
|
|
723
|
+
except Exception: # noqa: BLE001 — titles are cosmetic; never fail a report
|
|
724
|
+
return out
|
|
725
|
+
return out
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def detect_prior_activity(
|
|
729
|
+
keys: Iterable[tuple[str, int]],
|
|
730
|
+
*,
|
|
731
|
+
until: float,
|
|
732
|
+
fetch: Callable[..., Mapping[str, Any]],
|
|
733
|
+
) -> frozenset[tuple[str, int]]:
|
|
734
|
+
"""Bounded look-back (#1760): which ``(repo, issue)`` keys already have
|
|
735
|
+
at least one audit event before ``until`` (the window start)?
|
|
736
|
+
|
|
737
|
+
One query per issue in ``keys`` — not one per event, not an unbounded
|
|
738
|
+
scan. Same query path as the window fetch (``fetch`` is the same
|
|
739
|
+
callable, real or injected), just ``until=window_start``, a per-issue
|
|
740
|
+
filter, and ``limit=1`` newest-first: the fold only needs a yes/no per
|
|
741
|
+
issue, not the events themselves.
|
|
742
|
+
"""
|
|
743
|
+
prior: set[tuple[str, int]] = set()
|
|
744
|
+
for key_repo, key_issue in sorted(set(keys)):
|
|
745
|
+
page = fetch(
|
|
746
|
+
since=None,
|
|
747
|
+
until=until,
|
|
748
|
+
repo=key_repo,
|
|
749
|
+
issue=key_issue,
|
|
750
|
+
limit=1,
|
|
751
|
+
cursor=None,
|
|
752
|
+
) or {}
|
|
753
|
+
if page.get("entries"):
|
|
754
|
+
prior.add((key_repo, key_issue))
|
|
755
|
+
return frozenset(prior)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def run_issue_activity(
|
|
759
|
+
*,
|
|
760
|
+
since: str = "24h",
|
|
761
|
+
until: str = "",
|
|
762
|
+
repo: str = "",
|
|
763
|
+
now: float | None = None,
|
|
764
|
+
fetch: Callable[..., Mapping[str, Any]] | None = None,
|
|
765
|
+
title_lookup: Callable[..., Mapping[tuple[str, int], str]] | None = None,
|
|
766
|
+
) -> ReportResult:
|
|
767
|
+
"""Fetch the window (paginated) and fold it. ``now``/``fetch``/
|
|
768
|
+
``title_lookup`` are test seams; the report's own parameters are
|
|
769
|
+
``since``/``until``/``repo``."""
|
|
770
|
+
generated_at = time.time() if now is None else float(now)
|
|
771
|
+
end = parse_timestamp(until) if until else generated_at
|
|
772
|
+
start = end - parse_duration(since)
|
|
773
|
+
|
|
774
|
+
fetch_fn = _default_fetch if fetch is None else fetch
|
|
775
|
+
|
|
776
|
+
entries, truncated = fetch_audit_window(
|
|
777
|
+
since=start, until=end, repo=repo or None, fetch=fetch_fn
|
|
778
|
+
)
|
|
779
|
+
keys = {
|
|
780
|
+
(str(e["repo"]), int(e["issue"]))
|
|
781
|
+
for e in entries
|
|
782
|
+
if e.get("repo") and e.get("issue") is not None
|
|
783
|
+
}
|
|
784
|
+
lookup = _lookup_titles if title_lookup is None else title_lookup
|
|
785
|
+
titles = lookup(keys)
|
|
786
|
+
prior_activity = detect_prior_activity(keys, until=start, fetch=fetch_fn)
|
|
787
|
+
return fold_issue_activity(
|
|
788
|
+
entries,
|
|
789
|
+
(start, end),
|
|
790
|
+
titles=titles,
|
|
791
|
+
generated_at=generated_at,
|
|
792
|
+
truncated=truncated,
|
|
793
|
+
prior_activity=prior_activity,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ── drive-queue-status: a live snapshot, not a fold ────────────────────────
|
|
798
|
+
#
|
|
799
|
+
# #1805: "what is queued, and is it moving?" without a CLI round-trip. Unlike
|
|
800
|
+
# issue-activity this is not a fold over an audit-trail window — it is a
|
|
801
|
+
# point-in-time read of `drive_queue` via `coord.state.list_drive_queue`
|
|
802
|
+
# (daemon-or-local already handled there), so `window` is degenerate:
|
|
803
|
+
# `(generated_at, generated_at)`. `drive_queue` has no `completed_at` and
|
|
804
|
+
# `coord/drive_queue.py` emits no audit events, so there is no data source
|
|
805
|
+
# for a queue *history* report — see this issue's "Out of scope".
|
|
806
|
+
|
|
807
|
+
DRIVE_QUEUE_STATUS_COLUMNS = [
|
|
808
|
+
"position",
|
|
809
|
+
"repo",
|
|
810
|
+
"issue",
|
|
811
|
+
"title",
|
|
812
|
+
"state",
|
|
813
|
+
"machine",
|
|
814
|
+
"attempts",
|
|
815
|
+
"deferrals",
|
|
816
|
+
"last_reason",
|
|
817
|
+
"enqueued_at",
|
|
818
|
+
"launched_at",
|
|
819
|
+
"hold_state",
|
|
820
|
+
"after",
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
# One entry per DRIVE_QUEUE_STATUS_COLUMNS entry, same order (#1760).
|
|
824
|
+
DRIVE_QUEUE_STATUS_COLUMN_META = [
|
|
825
|
+
ColumnMeta(id="position", label="Pos", kind="int", align="right"),
|
|
826
|
+
ColumnMeta(id="repo", label="Repo", kind="text"),
|
|
827
|
+
ColumnMeta(id="issue", label="Issue", kind="int", align="right"),
|
|
828
|
+
ColumnMeta(id="title", label="Title", kind="text", weight=2.0),
|
|
829
|
+
ColumnMeta(id="state", label="State", kind="enum"),
|
|
830
|
+
ColumnMeta(id="machine", label="Machine", kind="text"),
|
|
831
|
+
ColumnMeta(id="attempts", label="Attempts", kind="int", align="right"),
|
|
832
|
+
ColumnMeta(id="deferrals", label="Deferrals", kind="int", align="right"),
|
|
833
|
+
ColumnMeta(id="last_reason", label="Last Reason", kind="text", weight=3.0),
|
|
834
|
+
ColumnMeta(id="enqueued_at", label="Enqueued", kind="timestamp"),
|
|
835
|
+
ColumnMeta(id="launched_at", label="Launched", kind="timestamp"),
|
|
836
|
+
ColumnMeta(id="hold_state", label="Hold", kind="enum"),
|
|
837
|
+
ColumnMeta(id="after", label="After", kind="list"),
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
# The #1794 tell: an entry that has already burned at least one launch
|
|
841
|
+
# attempt is the thing an operator most wants shouted at them.
|
|
842
|
+
_RETRIED_ATTEMPTS_THRESHOLD = 1
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def fold_drive_queue_status(
|
|
846
|
+
entries: Iterable[Mapping[str, Any]],
|
|
847
|
+
generated_at: float,
|
|
848
|
+
*,
|
|
849
|
+
titles: Mapping[tuple[str, int], str] | None = None,
|
|
850
|
+
queue_escalation: Mapping[str, Any] | None = None,
|
|
851
|
+
) -> ReportResult:
|
|
852
|
+
"""Fold already-fetched ``drive_queue`` rows into a snapshot ``ReportResult``.
|
|
853
|
+
|
|
854
|
+
**Pure** — no DB, no daemon, no clock: ``entries`` is whatever
|
|
855
|
+
:func:`coord.state.list_drive_queue` returned (raw column names,
|
|
856
|
+
``after_json`` already decoded to a list) and ``generated_at`` is the
|
|
857
|
+
caller's clock reading, reused verbatim for both ends of ``window`` since
|
|
858
|
+
a live snapshot has no meaningful range.
|
|
859
|
+
|
|
860
|
+
``entries`` arrives pre-ordered (``list_drive_queue`` is
|
|
861
|
+
``ORDER BY position, id``) — this fold does not re-sort.
|
|
862
|
+
"""
|
|
863
|
+
title_map = dict(titles or {})
|
|
864
|
+
rows: list[dict[str, Any]] = []
|
|
865
|
+
for entry in entries:
|
|
866
|
+
repo = str(entry.get("repo_name") or "")
|
|
867
|
+
issue = int(entry.get("issue_number") or 0)
|
|
868
|
+
rows.append(
|
|
869
|
+
{
|
|
870
|
+
"position": int(entry.get("position") or 0),
|
|
871
|
+
"repo": repo,
|
|
872
|
+
"issue": issue,
|
|
873
|
+
"title": title_map.get((repo, issue)),
|
|
874
|
+
"state": entry.get("state") or "",
|
|
875
|
+
"machine": entry.get("machine") or "",
|
|
876
|
+
"attempts": int(entry.get("attempts") or 0),
|
|
877
|
+
"deferrals": int(entry.get("deferrals") or 0),
|
|
878
|
+
"last_reason": entry.get("last_reason") or "",
|
|
879
|
+
"enqueued_at": entry.get("enqueued_at"),
|
|
880
|
+
"launched_at": entry.get("launched_at"),
|
|
881
|
+
"hold_state": entry.get("hold_state") or "",
|
|
882
|
+
"after": list(entry.get("after_json") or []),
|
|
883
|
+
# Extra keys beyond `columns` — ReportResult's contract
|
|
884
|
+
# explicitly allows this for clients that want the detail.
|
|
885
|
+
"session_name": entry.get("session_name") or "",
|
|
886
|
+
"hold_reason": entry.get("hold_reason") or "",
|
|
887
|
+
"resume_when": entry.get("resume_when") or "",
|
|
888
|
+
}
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
notes: list[str] = []
|
|
892
|
+
if not rows:
|
|
893
|
+
notes.append("The drive queue is empty.")
|
|
894
|
+
else:
|
|
895
|
+
from coord.drive_queue import ( # noqa: PLC0415
|
|
896
|
+
STATE_BLOCKED,
|
|
897
|
+
STATE_DONE,
|
|
898
|
+
STATE_FAILED,
|
|
899
|
+
STATE_RUNNING,
|
|
900
|
+
STATE_WAITING,
|
|
901
|
+
TERMINAL_QUEUE_STATES,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
counts: dict[str, int] = {}
|
|
905
|
+
for r in rows:
|
|
906
|
+
counts[r["state"]] = counts.get(r["state"], 0) + 1
|
|
907
|
+
# The headline is entries the queue will still act on — `done` (and
|
|
908
|
+
# any other terminal state) is run history, not queue depth, so it
|
|
909
|
+
# is excluded here rather than folded into `len(rows)` (#1855).
|
|
910
|
+
queued = sum(n for state, n in counts.items() if state not in TERMINAL_QUEUE_STATES)
|
|
911
|
+
|
|
912
|
+
def _ordered(present: set[str], preferred: tuple[str, ...]) -> list[str]:
|
|
913
|
+
# `preferred` is display polish only — any state absent from it
|
|
914
|
+
# (a future addition to drive_queue.py's five, or one we simply
|
|
915
|
+
# forgot to list) still surfaces, just alphabetically after the
|
|
916
|
+
# known ones, so nothing can silently vanish the way `blocked`
|
|
917
|
+
# did before this fix.
|
|
918
|
+
return [s for s in preferred if s in present] + sorted(present - set(preferred))
|
|
919
|
+
|
|
920
|
+
non_terminal_states = _ordered(
|
|
921
|
+
{s for s in counts if s not in TERMINAL_QUEUE_STATES},
|
|
922
|
+
(STATE_RUNNING, STATE_WAITING),
|
|
923
|
+
)
|
|
924
|
+
# `blocked`/`failed` are the states that need a human — call them
|
|
925
|
+
# out ahead of the benign `done` count, not appended after it.
|
|
926
|
+
terminal_states = _ordered(
|
|
927
|
+
{s for s in counts if s in TERMINAL_QUEUE_STATES},
|
|
928
|
+
(STATE_BLOCKED, STATE_FAILED, STATE_DONE),
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
breakdown = ", ".join(f"{counts[s]} {s}" for s in non_terminal_states)
|
|
932
|
+
headline = f"{queued} entr{'y' if queued == 1 else 'ies'} queued"
|
|
933
|
+
if breakdown:
|
|
934
|
+
headline += f" ({breakdown})"
|
|
935
|
+
terminal_parts = [f"{counts[s]} {s}" for s in terminal_states]
|
|
936
|
+
if terminal_parts:
|
|
937
|
+
headline += " · " + " · ".join(terminal_parts)
|
|
938
|
+
notes.append(headline + ".")
|
|
939
|
+
retried = [r for r in rows if r["attempts"] >= _RETRIED_ATTEMPTS_THRESHOLD]
|
|
940
|
+
if retried:
|
|
941
|
+
named = ", ".join(
|
|
942
|
+
f"{r['repo']}#{r['issue']} (attempts={r['attempts']})" for r in retried
|
|
943
|
+
)
|
|
944
|
+
notes.append(f"attempts>=1: {named}.")
|
|
945
|
+
if queue_escalation:
|
|
946
|
+
reason = queue_escalation.get("reason") or "(no reason recorded)"
|
|
947
|
+
stage = queue_escalation.get("stage") or "?"
|
|
948
|
+
notes.append(
|
|
949
|
+
f"standing queue-level escalation: stage={stage!r} — {reason}"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
return ReportResult(
|
|
953
|
+
report_id="drive-queue-status",
|
|
954
|
+
generated_at=generated_at,
|
|
955
|
+
window=(generated_at, generated_at),
|
|
956
|
+
columns=list(DRIVE_QUEUE_STATUS_COLUMNS),
|
|
957
|
+
column_meta=list(DRIVE_QUEUE_STATUS_COLUMN_META),
|
|
958
|
+
rows=rows,
|
|
959
|
+
notes=notes,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _default_list_drive_queue(repo: str | None) -> list[dict]:
|
|
964
|
+
from coord.state import list_drive_queue # noqa: PLC0415
|
|
965
|
+
|
|
966
|
+
return list_drive_queue(repo)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _default_queue_escalation() -> Mapping[str, Any] | None:
|
|
970
|
+
"""The standing queue-level escalation record (#1754's synthetic key),
|
|
971
|
+
if one exists. A plain read — never runs a tick — so it is safe to
|
|
972
|
+
surface here; best-effort, mirroring :func:`_lookup_titles`."""
|
|
973
|
+
try:
|
|
974
|
+
from coord.drive_queue import ( # noqa: PLC0415
|
|
975
|
+
QUEUE_ALERT_ISSUE,
|
|
976
|
+
QUEUE_ALERT_REPO,
|
|
977
|
+
)
|
|
978
|
+
from coord.state import get_drive_escalation # noqa: PLC0415
|
|
979
|
+
|
|
980
|
+
return get_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
|
|
981
|
+
except Exception: # noqa: BLE001 — cosmetic; never fail the report over it
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def run_drive_queue_status(
|
|
986
|
+
*,
|
|
987
|
+
repo: str = "",
|
|
988
|
+
now: float | None = None,
|
|
989
|
+
fetch: Callable[[str | None], Sequence[Mapping[str, Any]]] | None = None,
|
|
990
|
+
title_lookup: Callable[..., Mapping[tuple[str, int], str]] | None = None,
|
|
991
|
+
escalation_lookup: Callable[[], Mapping[str, Any] | None] | None = None,
|
|
992
|
+
) -> ReportResult:
|
|
993
|
+
"""Fetch the live queue and fold it. ``now``/``fetch``/``title_lookup``/
|
|
994
|
+
``escalation_lookup`` are test seams (mirrors :func:`run_issue_activity`);
|
|
995
|
+
the report's own parameter is ``repo``.
|
|
996
|
+
|
|
997
|
+
Read-only and tick-free by construction: the only call here is
|
|
998
|
+
``list_drive_queue`` (or the injected ``fetch``) — never ``plan_tick``.
|
|
999
|
+
"""
|
|
1000
|
+
generated_at = time.time() if now is None else float(now)
|
|
1001
|
+
fetch_fn = _default_list_drive_queue if fetch is None else fetch
|
|
1002
|
+
entries = list(fetch_fn(repo or None) or [])
|
|
1003
|
+
|
|
1004
|
+
keys = {
|
|
1005
|
+
(str(e["repo_name"]), int(e["issue_number"]))
|
|
1006
|
+
for e in entries
|
|
1007
|
+
if e.get("repo_name") and e.get("issue_number") is not None
|
|
1008
|
+
}
|
|
1009
|
+
lookup = _lookup_titles if title_lookup is None else title_lookup
|
|
1010
|
+
titles = lookup(keys)
|
|
1011
|
+
|
|
1012
|
+
esc_lookup = _default_queue_escalation if escalation_lookup is None else escalation_lookup
|
|
1013
|
+
queue_escalation = esc_lookup()
|
|
1014
|
+
|
|
1015
|
+
return fold_drive_queue_status(
|
|
1016
|
+
entries,
|
|
1017
|
+
generated_at,
|
|
1018
|
+
titles=titles,
|
|
1019
|
+
queue_escalation=queue_escalation,
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
# ── usage: the per-issue / per-repo cost + token rollup ────────────────────
|
|
1024
|
+
#
|
|
1025
|
+
# #1763. This is a **correctness fix**, not a consolidation. `coord-tui`'s
|
|
1026
|
+
# `panel:usage` was a Rust port of `coord/usage_rollup.py` carrying a
|
|
1027
|
+
# hardcoded snapshot of `coord.config.PricingConfig`'s shipped defaults, so
|
|
1028
|
+
# an operator who overrode `pricing:` in coordinator.yml changed what
|
|
1029
|
+
# `coord usage` reported and left the panel confidently showing different
|
|
1030
|
+
# numbers (the durable #1116 finding). The daemon holds the config, so the
|
|
1031
|
+
# daemon does the arithmetic: everything below *calls* `usage_rollup.rollup`
|
|
1032
|
+
# / `rollup_by_stage` with the loaded `PricingConfig` and reimplements none
|
|
1033
|
+
# of its window predicate, leg-cost rule or default sort.
|
|
1034
|
+
|
|
1035
|
+
USAGE_WINDOW_CHOICES = ("today", "week", "month", "7d", "30d")
|
|
1036
|
+
USAGE_GROUP_BY_CHOICES = ("issue", "repo")
|
|
1037
|
+
|
|
1038
|
+
# Columns depend on `group_by`: a repo-grouped row IS the whole repo, so it
|
|
1039
|
+
# carries no issue number and no title (same shape the retired panel used).
|
|
1040
|
+
USAGE_ISSUE_COLUMNS = [
|
|
1041
|
+
"issue",
|
|
1042
|
+
"repo",
|
|
1043
|
+
"title",
|
|
1044
|
+
"legs",
|
|
1045
|
+
"tokens_in",
|
|
1046
|
+
"tokens_out",
|
|
1047
|
+
"cost_captured",
|
|
1048
|
+
"cost_est",
|
|
1049
|
+
"cost_total",
|
|
1050
|
+
]
|
|
1051
|
+
|
|
1052
|
+
USAGE_REPO_COLUMNS = [
|
|
1053
|
+
"repo",
|
|
1054
|
+
"legs",
|
|
1055
|
+
"tokens_in",
|
|
1056
|
+
"tokens_out",
|
|
1057
|
+
"cost_captured",
|
|
1058
|
+
"cost_est",
|
|
1059
|
+
"cost_total",
|
|
1060
|
+
]
|
|
1061
|
+
|
|
1062
|
+
# #1760 display metadata, indexed by column id and emitted in `columns`
|
|
1063
|
+
# order — a large `weight` on `title`, `int`/`right` for the counts, and
|
|
1064
|
+
# `money`/`right` for the three dollar columns. `money` is a *generic* kind
|
|
1065
|
+
# (the vocabulary is open, see ColumnMeta): a client that predates it falls
|
|
1066
|
+
# back to plain stringification and still shows the number.
|
|
1067
|
+
_USAGE_COLUMN_META: dict[str, ColumnMeta] = {
|
|
1068
|
+
"issue": ColumnMeta(id="issue", label="Issue", kind="int", align="right", weight=0.8),
|
|
1069
|
+
"repo": ColumnMeta(id="repo", label="Repo", kind="text", weight=1.5),
|
|
1070
|
+
"title": ColumnMeta(id="title", label="Title", kind="text", weight=4.0),
|
|
1071
|
+
"legs": ColumnMeta(id="legs", label="Legs", kind="int", align="right", weight=0.6),
|
|
1072
|
+
"tokens_in": ColumnMeta(id="tokens_in", label="Tok In", kind="int", align="right"),
|
|
1073
|
+
"tokens_out": ColumnMeta(id="tokens_out", label="Tok Out", kind="int", align="right"),
|
|
1074
|
+
"cost_captured": ColumnMeta(
|
|
1075
|
+
id="cost_captured", label="Cost $", kind="money", align="right"
|
|
1076
|
+
),
|
|
1077
|
+
"cost_est": ColumnMeta(id="cost_est", label="Est ~$", kind="money", align="right"),
|
|
1078
|
+
"cost_total": ColumnMeta(id="cost_total", label="Total $", kind="money", align="right"),
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
# Dollar figures are rounded before they go on the wire so a float artefact
|
|
1082
|
+
# (2.8000000000000003) never reaches a generic renderer. Six places is far
|
|
1083
|
+
# below any real per-leg cost and above any rounding that could change a
|
|
1084
|
+
# reported cent.
|
|
1085
|
+
_USAGE_COST_PLACES = 6
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def usage_columns(group_by: str) -> list[str]:
|
|
1089
|
+
"""The ``columns`` list for *group_by*. Raises :class:`ReportError`."""
|
|
1090
|
+
if group_by == "issue":
|
|
1091
|
+
return list(USAGE_ISSUE_COLUMNS)
|
|
1092
|
+
if group_by == "repo":
|
|
1093
|
+
return list(USAGE_REPO_COLUMNS)
|
|
1094
|
+
raise ReportError(
|
|
1095
|
+
f"invalid value for 'group_by': {group_by!r} — "
|
|
1096
|
+
f"allowed values: {', '.join(USAGE_GROUP_BY_CHOICES)}"
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def resolve_usage_window(window: str, now: float | None = None):
|
|
1101
|
+
"""Resolve a ``window`` parameter to a :class:`coord.usage_rollup.TimeWindow`.
|
|
1102
|
+
|
|
1103
|
+
Every preset is *called* from :mod:`coord.usage_rollup`, never
|
|
1104
|
+
reimplemented — that module owns the calendar (this is precisely what the
|
|
1105
|
+
retired panel hand-rolled a civil calendar to duplicate).
|
|
1106
|
+
"""
|
|
1107
|
+
from coord.usage_rollup import ( # noqa: PLC0415
|
|
1108
|
+
Window,
|
|
1109
|
+
window_month,
|
|
1110
|
+
window_today,
|
|
1111
|
+
window_week,
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
if window == "today":
|
|
1115
|
+
return window_today(now)
|
|
1116
|
+
if window == "week":
|
|
1117
|
+
return window_week(now)
|
|
1118
|
+
if window == "month":
|
|
1119
|
+
return window_month(now)
|
|
1120
|
+
if window in ("7d", "30d"):
|
|
1121
|
+
# Window.since is the *bounded* variant: [now - spec, now).
|
|
1122
|
+
return Window.since(window, now)
|
|
1123
|
+
raise ReportError(
|
|
1124
|
+
f"invalid value for 'window': {window!r} — "
|
|
1125
|
+
f"allowed values: {', '.join(USAGE_WINDOW_CHOICES)}"
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _usage_row_title(leg_rows: Sequence[Mapping[str, Any]]) -> str | None:
|
|
1130
|
+
for row in leg_rows:
|
|
1131
|
+
title = row.get("issue_title")
|
|
1132
|
+
if title:
|
|
1133
|
+
return str(title)
|
|
1134
|
+
return None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _usage_metrics(group: Any) -> dict[str, Any]:
|
|
1138
|
+
"""The numeric half of a row (or of ``totals``) — identical for both."""
|
|
1139
|
+
return {
|
|
1140
|
+
"legs": int(group.legs),
|
|
1141
|
+
"tokens_in": int(group.tokens.input),
|
|
1142
|
+
"tokens_out": int(group.tokens.output),
|
|
1143
|
+
"cost_captured": round(float(group.cost_captured), _USAGE_COST_PLACES),
|
|
1144
|
+
"cost_est": round(float(group.cost_est), _USAGE_COST_PLACES),
|
|
1145
|
+
"cost_total": round(float(group.cost_total), _USAGE_COST_PLACES),
|
|
1146
|
+
# Beyond `columns` — the contract explicitly allows extra row keys,
|
|
1147
|
+
# and a client that wants the cache split or the open-leg count can
|
|
1148
|
+
# have it without another column in an already-wide table.
|
|
1149
|
+
"tokens_cache_read": int(group.tokens.cache_read),
|
|
1150
|
+
"tokens_cache_creation": int(group.tokens.cache_creation),
|
|
1151
|
+
"duration_secs": round(float(group.duration_secs), 3),
|
|
1152
|
+
"open_legs": int(group.open_legs),
|
|
1153
|
+
"unknown_model_legs": int(group.unknown_model_legs),
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _usage_stage_breakdown(
|
|
1158
|
+
leg_rows: Sequence[Mapping[str, Any]], window: Any, pricing: Any
|
|
1159
|
+
) -> list[dict[str, Any]]:
|
|
1160
|
+
"""Per-stage sub-rollup for one group, as a list of plain dicts.
|
|
1161
|
+
|
|
1162
|
+
The panel's only drill-down was "click a row → its per-stage legs"; that
|
|
1163
|
+
maps onto rows without needing a second request, so it ships inline as an
|
|
1164
|
+
extra row key rather than as a second report.
|
|
1165
|
+
"""
|
|
1166
|
+
from coord.usage_rollup import rollup_by_stage # noqa: PLC0415
|
|
1167
|
+
|
|
1168
|
+
sub = rollup_by_stage(list(leg_rows), window, pricing)
|
|
1169
|
+
stages = [
|
|
1170
|
+
{"stage": str(key), **_usage_metrics(grp)} for key, grp in sub.groups.items()
|
|
1171
|
+
]
|
|
1172
|
+
stages.sort(key=lambda s: s["cost_total"], reverse=True)
|
|
1173
|
+
return stages
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def fold_usage(
|
|
1177
|
+
rows: Iterable[Mapping[str, Any]],
|
|
1178
|
+
window: Any,
|
|
1179
|
+
*,
|
|
1180
|
+
group_by: str = "issue",
|
|
1181
|
+
pricing: Any = None,
|
|
1182
|
+
generated_at: float | None = None,
|
|
1183
|
+
extra_notes: Sequence[str] = (),
|
|
1184
|
+
) -> ReportResult:
|
|
1185
|
+
"""Fold board assignment rows into a per-issue / per-repo cost rollup.
|
|
1186
|
+
|
|
1187
|
+
**Pure** — no DB, no daemon, no clock: *rows* is whatever the caller
|
|
1188
|
+
fetched (daemon ``/board`` ``assignments`` wire shape), *window* is a
|
|
1189
|
+
resolved :class:`~coord.usage_rollup.TimeWindow`, and *pricing* is the
|
|
1190
|
+
:class:`~coord.config.PricingConfig` that was actually loaded. Every
|
|
1191
|
+
number comes back out of :func:`coord.usage_rollup.rollup` — this
|
|
1192
|
+
function only shapes it into the report wire contract.
|
|
1193
|
+
|
|
1194
|
+
*pricing* left at ``None`` falls through to ``usage_rollup``'s own
|
|
1195
|
+
built-in defaults, which is correct for a unit test and **not** what the
|
|
1196
|
+
runner does (see :func:`run_usage`, which loads ``coordinator.yml``).
|
|
1197
|
+
"""
|
|
1198
|
+
from coord.usage_rollup import IssueKey, rollup # noqa: PLC0415
|
|
1199
|
+
|
|
1200
|
+
columns = usage_columns(group_by)
|
|
1201
|
+
rows = list(rows)
|
|
1202
|
+
result = rollup(rows, group_by=group_by, window=window, pricing=pricing)
|
|
1203
|
+
|
|
1204
|
+
out_rows: list[dict[str, Any]] = []
|
|
1205
|
+
for key, group in result.groups.items():
|
|
1206
|
+
row: dict[str, Any] = {}
|
|
1207
|
+
if isinstance(key, IssueKey):
|
|
1208
|
+
row["issue"] = int(key.issue_number)
|
|
1209
|
+
row["repo"] = str(key.repo_name)
|
|
1210
|
+
row["title"] = _usage_row_title(group.leg_rows)
|
|
1211
|
+
else:
|
|
1212
|
+
row["repo"] = str(key)
|
|
1213
|
+
row.update(_usage_metrics(group))
|
|
1214
|
+
row["stages"] = _usage_stage_breakdown(group.leg_rows, window, pricing)
|
|
1215
|
+
out_rows.append(row)
|
|
1216
|
+
|
|
1217
|
+
# Same default order as `coord usage` and the retired panel: biggest
|
|
1218
|
+
# spend first. `_ident` breaks ties deterministically so a frozen-clock
|
|
1219
|
+
# test isn't at the mercy of dict ordering.
|
|
1220
|
+
out_rows.sort(
|
|
1221
|
+
key=lambda r: (-r["cost_total"], str(r.get("repo") or ""), int(r.get("issue") or 0))
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
start = 0.0 if getattr(window, "start", None) is None else float(window.start)
|
|
1225
|
+
end = (
|
|
1226
|
+
float(generated_at if generated_at is not None else start)
|
|
1227
|
+
if getattr(window, "end", None) is None
|
|
1228
|
+
else float(window.end)
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
totals = _usage_metrics(result.total)
|
|
1232
|
+
|
|
1233
|
+
notes: list[str] = list(extra_notes)
|
|
1234
|
+
if not out_rows:
|
|
1235
|
+
notes.append("No usage recorded in this window.")
|
|
1236
|
+
for row in out_rows:
|
|
1237
|
+
unknown = int(row.get("unknown_model_legs") or 0)
|
|
1238
|
+
if unknown:
|
|
1239
|
+
ident = (
|
|
1240
|
+
f"{row['repo']}#{row['issue']}" if "issue" in row else str(row["repo"])
|
|
1241
|
+
)
|
|
1242
|
+
notes.append(
|
|
1243
|
+
f"{ident}: {unknown} leg(s) ran a model with no entry in the "
|
|
1244
|
+
"loaded `pricing:` config — their tokens are counted but "
|
|
1245
|
+
"their spend is NOT in `cost_est` (never silently priced at "
|
|
1246
|
+
"$0). Add a rate for that model to coordinator.yml."
|
|
1247
|
+
)
|
|
1248
|
+
if totals["open_legs"]:
|
|
1249
|
+
notes.append(
|
|
1250
|
+
f"{totals['open_legs']} leg(s) in this window are still running — "
|
|
1251
|
+
"their duration counts as 0 and their cost is not final."
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
return ReportResult(
|
|
1255
|
+
report_id="usage",
|
|
1256
|
+
generated_at=end if generated_at is None else float(generated_at),
|
|
1257
|
+
window=(start, end),
|
|
1258
|
+
columns=columns,
|
|
1259
|
+
column_meta=[_USAGE_COLUMN_META[c] for c in columns],
|
|
1260
|
+
rows=out_rows,
|
|
1261
|
+
notes=notes,
|
|
1262
|
+
totals=totals,
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _default_usage_rows(repo: str | None) -> list[dict]: # noqa: ARG001
|
|
1267
|
+
"""Board assignment rows from the local DB.
|
|
1268
|
+
|
|
1269
|
+
Deliberately **not** :func:`coord.usage.fetch_usage_rows`: that helper
|
|
1270
|
+
branches to a ``GET /board`` when a board service is configured, and a
|
|
1271
|
+
report already runs *on* the daemon host (``coord.state.run_report``
|
|
1272
|
+
routes a thin client's request to ``GET /report/{id}``), so going through
|
|
1273
|
+
it would make the daemon HTTP-call itself. This mirrors that helper's
|
|
1274
|
+
*local* branch exactly — ``list_assignments()`` rather than the
|
|
1275
|
+
retention-capped ``/board`` projection, because a usage rollup wants full
|
|
1276
|
+
history.
|
|
1277
|
+
"""
|
|
1278
|
+
from coord.dao import SqliteStore # noqa: PLC0415
|
|
1279
|
+
|
|
1280
|
+
return SqliteStore().list_assignments()
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _load_pricing() -> tuple[Any, list[str]]:
|
|
1284
|
+
"""The ``pricing:`` block from the loaded ``coordinator.yml``.
|
|
1285
|
+
|
|
1286
|
+
Returns ``(PricingConfig, notes)``. A config that cannot be loaded falls
|
|
1287
|
+
back to the built-in defaults **and says so in ``notes``** — silently
|
|
1288
|
+
falling back is exactly the failure mode #1763 exists to remove.
|
|
1289
|
+
"""
|
|
1290
|
+
from coord.config import PricingConfig # noqa: PLC0415
|
|
1291
|
+
|
|
1292
|
+
try:
|
|
1293
|
+
from coord.config import load, resolve_config_path # noqa: PLC0415
|
|
1294
|
+
|
|
1295
|
+
return load(resolve_config_path()).pricing, []
|
|
1296
|
+
except Exception as exc: # noqa: BLE001 — surfaced as a note, not a crash
|
|
1297
|
+
return (
|
|
1298
|
+
PricingConfig(),
|
|
1299
|
+
[
|
|
1300
|
+
"WARNING: coordinator.yml could not be loaded "
|
|
1301
|
+
f"({type(exc).__name__}: {exc}) — `cost_est` uses the built-in "
|
|
1302
|
+
"default rates, which may differ from this fleet's `pricing:` "
|
|
1303
|
+
"block."
|
|
1304
|
+
],
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def run_usage(
|
|
1309
|
+
*,
|
|
1310
|
+
window: str = "today",
|
|
1311
|
+
group_by: str = "issue",
|
|
1312
|
+
repo: str = "",
|
|
1313
|
+
now: float | None = None,
|
|
1314
|
+
fetch: Callable[[str | None], Sequence[Mapping[str, Any]]] | None = None,
|
|
1315
|
+
pricing: Any = None,
|
|
1316
|
+
) -> ReportResult:
|
|
1317
|
+
"""Fetch board rows and fold them. ``now``/``fetch``/``pricing`` are test
|
|
1318
|
+
seams; the report's own parameters are ``window``/``group_by``/``repo``."""
|
|
1319
|
+
generated_at = time.time() if now is None else float(now)
|
|
1320
|
+
resolved = resolve_usage_window(window, generated_at)
|
|
1321
|
+
|
|
1322
|
+
fetch_fn = _default_usage_rows if fetch is None else fetch
|
|
1323
|
+
rows = list(fetch_fn(repo or None) or [])
|
|
1324
|
+
if repo:
|
|
1325
|
+
rows = [r for r in rows if str(r.get("repo_name") or "") == repo]
|
|
1326
|
+
|
|
1327
|
+
extra_notes: list[str] = []
|
|
1328
|
+
if pricing is None:
|
|
1329
|
+
pricing, extra_notes = _load_pricing()
|
|
1330
|
+
|
|
1331
|
+
return fold_usage(
|
|
1332
|
+
rows,
|
|
1333
|
+
resolved,
|
|
1334
|
+
group_by=group_by,
|
|
1335
|
+
pricing=pricing,
|
|
1336
|
+
generated_at=generated_at,
|
|
1337
|
+
extra_notes=extra_notes,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
# ── the catalogue ──────────────────────────────────────────────────────────
|
|
1342
|
+
|
|
1343
|
+
SINCE_PRESETS = ("1h", "6h", "24h", "3d", "7d")
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _validate_since(value: str) -> None:
|
|
1347
|
+
if value in SINCE_PRESETS:
|
|
1348
|
+
return
|
|
1349
|
+
try:
|
|
1350
|
+
parse_duration(value)
|
|
1351
|
+
except ReportError as exc:
|
|
1352
|
+
raise ReportError(
|
|
1353
|
+
f"invalid value for 'since': {value!r} — allowed values: "
|
|
1354
|
+
f"{', '.join(SINCE_PRESETS)}, or any duration like '13h' "
|
|
1355
|
+
"(units: s, m, h, d, w)"
|
|
1356
|
+
) from exc
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _validate_until(value: str) -> None:
|
|
1360
|
+
if not value:
|
|
1361
|
+
return
|
|
1362
|
+
try:
|
|
1363
|
+
parse_timestamp(value)
|
|
1364
|
+
except ReportError as exc:
|
|
1365
|
+
raise ReportError(
|
|
1366
|
+
f"invalid value for 'until': {value!r} — expected epoch seconds "
|
|
1367
|
+
"or an ISO-8601 timestamp (e.g. '2026-08-03T09:16:00Z'), or "
|
|
1368
|
+
"empty for 'now'"
|
|
1369
|
+
) from exc
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
ISSUE_ACTIVITY = ReportDef(
|
|
1373
|
+
id="issue-activity",
|
|
1374
|
+
title="Issue Activity",
|
|
1375
|
+
description=(
|
|
1376
|
+
"What moved in this window and where did it end up — the audit trail "
|
|
1377
|
+
"folded into one row per issue: when it started, which machines "
|
|
1378
|
+
"touched it, how many fix iterations it took, its Test/Review "
|
|
1379
|
+
"verdicts in order, whether it merged, and how its driver exited."
|
|
1380
|
+
),
|
|
1381
|
+
params=(
|
|
1382
|
+
ReportParam(
|
|
1383
|
+
id="since",
|
|
1384
|
+
label="Time range",
|
|
1385
|
+
kind="choice",
|
|
1386
|
+
choices=SINCE_PRESETS,
|
|
1387
|
+
default="24h",
|
|
1388
|
+
help="How far back the window reaches from `until`. Presets, or any duration (e.g. 13h).",
|
|
1389
|
+
free_form=True,
|
|
1390
|
+
validate=_validate_since,
|
|
1391
|
+
),
|
|
1392
|
+
ReportParam(
|
|
1393
|
+
id="until",
|
|
1394
|
+
label="Window end",
|
|
1395
|
+
kind="text",
|
|
1396
|
+
default="",
|
|
1397
|
+
help="Epoch seconds or ISO-8601. Empty means now.",
|
|
1398
|
+
validate=_validate_until,
|
|
1399
|
+
),
|
|
1400
|
+
ReportParam(
|
|
1401
|
+
id="repo",
|
|
1402
|
+
label="Repo",
|
|
1403
|
+
kind="text",
|
|
1404
|
+
default="",
|
|
1405
|
+
help="Restrict to one repo by name. Empty means all repos.",
|
|
1406
|
+
),
|
|
1407
|
+
),
|
|
1408
|
+
run=run_issue_activity,
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
DRIVE_QUEUE_STATUS = ReportDef(
|
|
1413
|
+
id="drive-queue-status",
|
|
1414
|
+
title="Drive Queue Status",
|
|
1415
|
+
description=(
|
|
1416
|
+
"A live snapshot of the drive queue — one row per queued entry in "
|
|
1417
|
+
"run order, with its state, machine pin, attempts/deferrals and the "
|
|
1418
|
+
"tick's own last_reason. A snapshot, not a history: `drive_queue` "
|
|
1419
|
+
"has no `completed_at`, so this shows what is queued now, not what "
|
|
1420
|
+
"the queue has processed."
|
|
1421
|
+
),
|
|
1422
|
+
params=(
|
|
1423
|
+
ReportParam(
|
|
1424
|
+
id="repo",
|
|
1425
|
+
label="Repo",
|
|
1426
|
+
kind="text",
|
|
1427
|
+
default="",
|
|
1428
|
+
help="Restrict to one repo by name. Empty means all repos.",
|
|
1429
|
+
),
|
|
1430
|
+
),
|
|
1431
|
+
run=run_drive_queue_status,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
USAGE = ReportDef(
|
|
1436
|
+
id="usage",
|
|
1437
|
+
title="Usage",
|
|
1438
|
+
description=(
|
|
1439
|
+
"Cost and token spend for a time window, one row per issue (or per "
|
|
1440
|
+
"repo): legs, tokens in/out, captured $, estimated ~$ for legs with "
|
|
1441
|
+
"no captured cost, and the total. Estimates use the daemon's own "
|
|
1442
|
+
"loaded `pricing:` block, so they agree with `coord usage` by "
|
|
1443
|
+
"construction."
|
|
1444
|
+
),
|
|
1445
|
+
params=(
|
|
1446
|
+
ReportParam(
|
|
1447
|
+
id="window",
|
|
1448
|
+
label="Time window",
|
|
1449
|
+
kind="choice",
|
|
1450
|
+
choices=USAGE_WINDOW_CHOICES,
|
|
1451
|
+
default="today",
|
|
1452
|
+
help=(
|
|
1453
|
+
"today/week/month are local calendar periods; 7d/30d are "
|
|
1454
|
+
"rolling windows ending now."
|
|
1455
|
+
),
|
|
1456
|
+
),
|
|
1457
|
+
ReportParam(
|
|
1458
|
+
id="group_by",
|
|
1459
|
+
label="Group by",
|
|
1460
|
+
kind="choice",
|
|
1461
|
+
choices=USAGE_GROUP_BY_CHOICES,
|
|
1462
|
+
default="issue",
|
|
1463
|
+
help="One row per issue, or one row per repo.",
|
|
1464
|
+
),
|
|
1465
|
+
ReportParam(
|
|
1466
|
+
id="repo",
|
|
1467
|
+
label="Repo",
|
|
1468
|
+
kind="text",
|
|
1469
|
+
default="",
|
|
1470
|
+
help="Restrict to one repo by name. Empty means all repos.",
|
|
1471
|
+
),
|
|
1472
|
+
),
|
|
1473
|
+
run=run_usage,
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
# ── CSV serialisation (#1765) ──────────────────────────────────────────────
|
|
1478
|
+
#
|
|
1479
|
+
# One serializer, server-side, for every surface: `coord report run --format
|
|
1480
|
+
# csv`, `GET /report/{id}?format=csv`, and the coord-tui Reports panel's
|
|
1481
|
+
# Export action (which fetches the route rather than formatting anything
|
|
1482
|
+
# itself). Doing it here is not incidental — the values on the wire are
|
|
1483
|
+
# **raw** (`started_at` is an epoch float, `machines` is a list), and every
|
|
1484
|
+
# renderer turns those into display strings (`13h ago`, `dellserver,
|
|
1485
|
+
# precision`). A client-side CSV would therefore export the *formatting*,
|
|
1486
|
+
# not the data: an epoch would become a relative string no spreadsheet can
|
|
1487
|
+
# sort, and the bytes would silently depend on when Export was clicked.
|
|
1488
|
+
#
|
|
1489
|
+
# Line terminator is `\n`, not RFC 4180's `\r\n`: this is a Unix tool whose
|
|
1490
|
+
# output is piped and redirected, `csv.reader` accepts either, and every
|
|
1491
|
+
# spreadsheet we care about does too. Fixing it (rather than taking
|
|
1492
|
+
# `csv.writer`'s platform-ish default) is what makes CLI and daemon bytes
|
|
1493
|
+
# identical.
|
|
1494
|
+
_CSV_LINE_TERMINATOR = "\n"
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _csv_scalar(value: Any) -> str:
|
|
1498
|
+
"""One *raw* value → its CSV text. Never a display string."""
|
|
1499
|
+
if value is None:
|
|
1500
|
+
return ""
|
|
1501
|
+
if isinstance(value, bool):
|
|
1502
|
+
# Before the int check — bool is an int in Python, and `true`/`false`
|
|
1503
|
+
# is what every consumer of this file expects to see.
|
|
1504
|
+
return "true" if value else "false"
|
|
1505
|
+
return str(value)
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _csv_cell(value: Any) -> str:
|
|
1509
|
+
"""One row value → one CSV field.
|
|
1510
|
+
|
|
1511
|
+
Composite values collapse into a single field rather than spilling into
|
|
1512
|
+
extra columns: lists (``machines``, ``test_verdicts``) join with ``"; "``,
|
|
1513
|
+
and dicts (``drive_exit``) render as ``key=value`` pairs joined the same
|
|
1514
|
+
way. ``drive_exit.reason`` is embedded **verbatim** — commas, quotes and
|
|
1515
|
+
newlines and all — because `csv.writer` quotes and escapes it, and a
|
|
1516
|
+
round-trip through `csv.reader` has to return the original text (#1631's
|
|
1517
|
+
multi-line driver-exit reason is the regression fixture). JSON-encoding
|
|
1518
|
+
the dict would have escaped that newline into a literal ``\\n`` and lost
|
|
1519
|
+
the round-trip.
|
|
1520
|
+
"""
|
|
1521
|
+
if isinstance(value, (list, tuple)):
|
|
1522
|
+
return "; ".join(_csv_scalar(v) for v in value)
|
|
1523
|
+
if isinstance(value, Mapping):
|
|
1524
|
+
return "; ".join(f"{k}={_csv_scalar(v)}" for k, v in value.items())
|
|
1525
|
+
return _csv_scalar(value)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _csv_comment(text: str) -> list[str]:
|
|
1529
|
+
"""A note → its ``#``-prefixed line(s). A note that itself spans lines
|
|
1530
|
+
gets one ``#`` per physical line, so no fragment can escape into the
|
|
1531
|
+
data and be parsed as a row."""
|
|
1532
|
+
lines = str(text).splitlines() or [""]
|
|
1533
|
+
return [f"# {line}" if line else "#" for line in lines]
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def result_to_csv(result: "ReportResult | Mapping[str, Any]") -> str:
|
|
1537
|
+
"""Serialise a :class:`ReportResult` (or its ``to_dict()`` form) as CSV.
|
|
1538
|
+
|
|
1539
|
+
Shape:
|
|
1540
|
+
|
|
1541
|
+
* leading ``#``-prefixed comment lines — the report id, the window, and
|
|
1542
|
+
**every** ``notes`` entry. Notes are the derived anomalies and are the
|
|
1543
|
+
most valuable part of ``issue-activity``; they are not rows, and they
|
|
1544
|
+
must never silently vanish, so they ride along as comments that keep
|
|
1545
|
+
the file self-describing and still let it parse once ``#`` lines are
|
|
1546
|
+
skipped.
|
|
1547
|
+
* a header row, labelled from ``column_meta[].label`` (#1760) when
|
|
1548
|
+
present and from the raw column key otherwise.
|
|
1549
|
+
* one row per ``rows`` entry, raw values only.
|
|
1550
|
+
* ``totals`` (#1763), when the report has one, as a final row — flagged
|
|
1551
|
+
in the comments so nobody mistakes it for another data row. Reports
|
|
1552
|
+
without a meaningful sum emit no such row and are unaffected.
|
|
1553
|
+
"""
|
|
1554
|
+
data = result.to_dict() if isinstance(result, ReportResult) else dict(result)
|
|
1555
|
+
|
|
1556
|
+
columns = [str(c) for c in (data.get("columns") or [])]
|
|
1557
|
+
labels = {
|
|
1558
|
+
str(m.get("id")): str(m.get("label") or m.get("id"))
|
|
1559
|
+
for m in (data.get("column_meta") or [])
|
|
1560
|
+
if isinstance(m, Mapping)
|
|
1561
|
+
}
|
|
1562
|
+
window = data.get("window") or [None, None]
|
|
1563
|
+
|
|
1564
|
+
comments: list[str] = [
|
|
1565
|
+
f"# report: {data.get('report_id')}",
|
|
1566
|
+
f"# window: {_iso(window[0])} to {_iso(window[1])}",
|
|
1567
|
+
f"# generated: {_iso(data.get('generated_at'))}",
|
|
1568
|
+
]
|
|
1569
|
+
rows = list(data.get("rows") or [])
|
|
1570
|
+
comments.append(f"# rows: {len(rows)}")
|
|
1571
|
+
# The export is the report's own canonical row order — never a client's
|
|
1572
|
+
# transient sort (#1762), which is view state over one result set.
|
|
1573
|
+
comments.append("# order: the report's canonical row order")
|
|
1574
|
+
totals = data.get("totals")
|
|
1575
|
+
if isinstance(totals, Mapping):
|
|
1576
|
+
comments.append(
|
|
1577
|
+
"# totals: the final row is the grand total, not a data row "
|
|
1578
|
+
"(identity columns are blank)"
|
|
1579
|
+
)
|
|
1580
|
+
for note in data.get("notes") or []:
|
|
1581
|
+
comments.extend(_csv_comment(note))
|
|
1582
|
+
|
|
1583
|
+
buf = io.StringIO()
|
|
1584
|
+
writer = csv.writer(buf, lineterminator=_CSV_LINE_TERMINATOR)
|
|
1585
|
+
writer.writerow([labels.get(c, c) for c in columns])
|
|
1586
|
+
for row in rows:
|
|
1587
|
+
row = row if isinstance(row, Mapping) else {}
|
|
1588
|
+
writer.writerow([_csv_cell(row.get(c)) for c in columns])
|
|
1589
|
+
if isinstance(totals, Mapping):
|
|
1590
|
+
writer.writerow([_csv_cell(totals.get(c)) for c in columns])
|
|
1591
|
+
|
|
1592
|
+
header = "".join(line + _CSV_LINE_TERMINATOR for line in comments)
|
|
1593
|
+
return header + buf.getvalue()
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def csv_filename(result: "ReportResult | Mapping[str, Any]") -> str:
|
|
1597
|
+
"""``issue-activity-20260804-1130.csv`` — the suggested download name.
|
|
1598
|
+
|
|
1599
|
+
Derived from the *result* (its window end), not from the wall clock, so
|
|
1600
|
+
the daemon's ``Content-Disposition`` and the panel's save-dialog
|
|
1601
|
+
suggestion agree for the same run.
|
|
1602
|
+
"""
|
|
1603
|
+
data = result.to_dict() if isinstance(result, ReportResult) else dict(result)
|
|
1604
|
+
window = data.get("window") or [None, None]
|
|
1605
|
+
stamp_at = window[1] if window[1] is not None else data.get("generated_at")
|
|
1606
|
+
try:
|
|
1607
|
+
stamp = datetime.fromtimestamp(float(stamp_at), tz=timezone.utc).strftime(
|
|
1608
|
+
"%Y%m%d-%H%M"
|
|
1609
|
+
)
|
|
1610
|
+
except (TypeError, ValueError):
|
|
1611
|
+
stamp = "unknown"
|
|
1612
|
+
report_id = re.sub(r"[^A-Za-z0-9._-]+", "-", str(data.get("report_id") or "report"))
|
|
1613
|
+
return f"{report_id}-{stamp}.csv"
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
REPORTS: dict[str, ReportDef] = {
|
|
1617
|
+
ISSUE_ACTIVITY.id: ISSUE_ACTIVITY,
|
|
1618
|
+
DRIVE_QUEUE_STATUS.id: DRIVE_QUEUE_STATUS,
|
|
1619
|
+
USAGE.id: USAGE,
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def catalogue() -> dict[str, Any]:
|
|
1624
|
+
"""The wire shape of ``GET /report`` — everything #1741 needs to build a
|
|
1625
|
+
report picker and its parameter form without hardcoding anything."""
|
|
1626
|
+
return {"reports": [REPORTS[rid].to_dict() for rid in sorted(REPORTS)]}
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def run_report(
|
|
1630
|
+
report_id: str,
|
|
1631
|
+
params: Mapping[str, Any] | None = None,
|
|
1632
|
+
**injected: Any,
|
|
1633
|
+
) -> ReportResult:
|
|
1634
|
+
"""Look up, validate, run. Raises :class:`UnknownReportError` /
|
|
1635
|
+
:class:`ReportError` — never a traceback for a bad request."""
|
|
1636
|
+
report = REPORTS.get(report_id)
|
|
1637
|
+
if report is None:
|
|
1638
|
+
raise UnknownReportError(
|
|
1639
|
+
f"unknown report {report_id!r} — known reports: "
|
|
1640
|
+
f"{', '.join(sorted(REPORTS))}"
|
|
1641
|
+
)
|
|
1642
|
+
resolved = resolve_params(report, params)
|
|
1643
|
+
return report.run(**resolved, **injected)
|