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/health/registry.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The check registry (#1628).
|
|
2
|
+
|
|
3
|
+
**The acceptance bar for this abstraction is that adding a check touches
|
|
4
|
+
exactly one file: the new check module.** Not the renderer, not the CLI,
|
|
5
|
+
not a transport. That is enforced two ways:
|
|
6
|
+
|
|
7
|
+
1. Registration is a decorator (:func:`check`) applied in the check's own
|
|
8
|
+
module — there is no central list of checks to append to.
|
|
9
|
+
2. Discovery is :func:`pkgutil.iter_modules` over ``coord.health.checks`` —
|
|
10
|
+
dropping ``coord/health/checks/foo.py`` into the package is the whole
|
|
11
|
+
installation step. ``checks/__init__.py`` deliberately imports nothing.
|
|
12
|
+
|
|
13
|
+
The other half of the contract is fail-soft. :func:`run_all` wraps every
|
|
14
|
+
probe in a bare ``except Exception`` and converts a raised probe into an
|
|
15
|
+
``unknown`` result carrying the error text. A health engine that dies on
|
|
16
|
+
its weakest check reports nothing, which is worse than reporting the rest
|
|
17
|
+
plus one ``?``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
import importlib
|
|
24
|
+
import pkgutil
|
|
25
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from coord.health.models import (
|
|
30
|
+
SCOPES,
|
|
31
|
+
CheckResult,
|
|
32
|
+
HealthContext,
|
|
33
|
+
Severity,
|
|
34
|
+
unknown_result,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# What a probe costs to run. ``cheap`` probes are local syscalls/subprocesses
|
|
38
|
+
# and the whole cheap set is budgeted under ~2s, because this eventually runs
|
|
39
|
+
# on a timer on every agent. ``network`` probes (the one PyPI simple-index
|
|
40
|
+
# fetch, the ``claude -p /usage`` round trip) are skipped when the caller
|
|
41
|
+
# passes ``allow_network=False``.
|
|
42
|
+
COST_CHEAP = "cheap"
|
|
43
|
+
COST_NETWORK = "network"
|
|
44
|
+
|
|
45
|
+
ProbeFn = Callable[[HealthContext], CheckResult | Sequence[CheckResult] | None]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Check:
|
|
50
|
+
"""A self-contained unit of fleet-degradation signal."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
scope: str
|
|
54
|
+
probe: ProbeFn
|
|
55
|
+
title: str = ""
|
|
56
|
+
description: str = ""
|
|
57
|
+
cost: str = COST_CHEAP
|
|
58
|
+
# Display/run ordering. Lives on the check so a new module can slot
|
|
59
|
+
# itself into the report without anyone editing a renderer's list.
|
|
60
|
+
order: int = 100
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if self.scope not in SCOPES:
|
|
64
|
+
raise ValueError(f"check {self.id!r}: scope must be one of {SCOPES}, got {self.scope!r}")
|
|
65
|
+
if self.cost not in (COST_CHEAP, COST_NETWORK):
|
|
66
|
+
raise ValueError(f"check {self.id!r}: cost must be 'cheap' or 'network'")
|
|
67
|
+
if not self.title:
|
|
68
|
+
object.__setattr__(self, "title", self.id)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_REGISTRY: dict[str, Check] = {}
|
|
72
|
+
_discovered = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register(chk: Check) -> Check:
|
|
76
|
+
"""Add *chk* to the registry. Re-registering the same id replaces it
|
|
77
|
+
(module reload under pytest must not raise)."""
|
|
78
|
+
_REGISTRY[chk.id] = chk
|
|
79
|
+
return chk
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check(
|
|
83
|
+
*,
|
|
84
|
+
id: str, # noqa: A002 — matches the field name in the issue's spec
|
|
85
|
+
scope: str,
|
|
86
|
+
title: str = "",
|
|
87
|
+
description: str = "",
|
|
88
|
+
cost: str = COST_CHEAP,
|
|
89
|
+
order: int = 100,
|
|
90
|
+
) -> Callable[[ProbeFn], ProbeFn]:
|
|
91
|
+
"""Decorator form: register the decorated function as a check's probe.
|
|
92
|
+
|
|
93
|
+
The probe returns one :class:`CheckResult`, a sequence of them (one per
|
|
94
|
+
disk / per checkout / ...), or ``None`` for "nothing to report here".
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def _wrap(fn: ProbeFn) -> ProbeFn:
|
|
98
|
+
register(
|
|
99
|
+
Check(
|
|
100
|
+
id=id,
|
|
101
|
+
scope=scope,
|
|
102
|
+
probe=fn,
|
|
103
|
+
title=title,
|
|
104
|
+
description=description,
|
|
105
|
+
cost=cost,
|
|
106
|
+
order=order,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return fn
|
|
110
|
+
|
|
111
|
+
return _wrap
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def discover(force: bool = False) -> None:
|
|
115
|
+
"""Import every module under ``coord.health.checks`` so its decorators run.
|
|
116
|
+
|
|
117
|
+
This is the *entire* registration mechanism. There is no list to edit.
|
|
118
|
+
"""
|
|
119
|
+
global _discovered
|
|
120
|
+
if _discovered and not force:
|
|
121
|
+
return
|
|
122
|
+
from coord.health import checks as _checks_pkg
|
|
123
|
+
|
|
124
|
+
for mod in pkgutil.iter_modules(_checks_pkg.__path__):
|
|
125
|
+
if mod.name.startswith("_"):
|
|
126
|
+
continue
|
|
127
|
+
importlib.import_module(f"{_checks_pkg.__name__}.{mod.name}")
|
|
128
|
+
_discovered = True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def all_checks() -> list[Check]:
|
|
132
|
+
"""Every registered check, in stable report order."""
|
|
133
|
+
discover()
|
|
134
|
+
return sorted(_REGISTRY.values(), key=lambda c: (c.order, c.id))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get(check_id: str) -> Check | None:
|
|
138
|
+
discover()
|
|
139
|
+
return _REGISTRY.get(check_id)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_check(chk: Check, ctx: HealthContext) -> list[CheckResult]:
|
|
143
|
+
"""Run one check, fail-soft.
|
|
144
|
+
|
|
145
|
+
A probe that raises — any exception, including ``KeyboardInterrupt``'s
|
|
146
|
+
non-``Exception`` siblings excluded — yields a single ``unknown`` result
|
|
147
|
+
naming the error, and the run continues.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
out = chk.probe(ctx)
|
|
151
|
+
except Exception as exc: # noqa: BLE001 — fail soft is the requirement
|
|
152
|
+
return [
|
|
153
|
+
unknown_result(
|
|
154
|
+
chk.id,
|
|
155
|
+
scope=chk.scope,
|
|
156
|
+
title=chk.title,
|
|
157
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
158
|
+
)
|
|
159
|
+
]
|
|
160
|
+
if out is None:
|
|
161
|
+
return []
|
|
162
|
+
results = [out] if isinstance(out, CheckResult) else list(out)
|
|
163
|
+
# A probe that forgets its own title/scope shouldn't produce rows the
|
|
164
|
+
# renderer can't label. Backfill from the check definition.
|
|
165
|
+
return [
|
|
166
|
+
dataclasses.replace(r, title=chk.title) if r.title == r.check_id else r
|
|
167
|
+
for r in results
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class HealthReport:
|
|
173
|
+
"""The full outcome of one registry run."""
|
|
174
|
+
|
|
175
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
176
|
+
skipped: list[str] = field(default_factory=list)
|
|
177
|
+
duration_secs: float = 0.0
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def severity(self) -> Severity:
|
|
181
|
+
from coord.health.models import worst
|
|
182
|
+
|
|
183
|
+
return worst([r.severity for r in self.results])
|
|
184
|
+
|
|
185
|
+
def counts(self) -> dict[str, int]:
|
|
186
|
+
out = {s.value: 0 for s in Severity}
|
|
187
|
+
for r in self.results:
|
|
188
|
+
out[r.severity.value] += 1
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
def to_dict(self) -> dict[str, Any]:
|
|
192
|
+
"""The ``coord health --json`` contract (H-3/H-4 consume this)."""
|
|
193
|
+
return {
|
|
194
|
+
"schema": 1,
|
|
195
|
+
"severity": self.severity.value,
|
|
196
|
+
"counts": self.counts(),
|
|
197
|
+
"skipped": list(self.skipped),
|
|
198
|
+
"duration_secs": round(self.duration_secs, 3),
|
|
199
|
+
"results": [r.to_dict() for r in self.results],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_all(
|
|
204
|
+
ctx: HealthContext,
|
|
205
|
+
*,
|
|
206
|
+
scopes: Iterable[str] | None = None,
|
|
207
|
+
only: Iterable[str] | None = None,
|
|
208
|
+
) -> HealthReport:
|
|
209
|
+
"""Run the registry against *ctx*.
|
|
210
|
+
|
|
211
|
+
* ``scopes`` — restrict to these scopes (``coord health --local`` passes
|
|
212
|
+
``("machine", "checkout")``; ``fleet`` probes arrive in H-3).
|
|
213
|
+
* ``only`` — restrict to these check ids.
|
|
214
|
+
* ``ctx.allow_network`` False skips ``cost="network"`` checks, recording
|
|
215
|
+
their ids in :attr:`HealthReport.skipped` so "we didn't look" never
|
|
216
|
+
reads as "nothing wrong".
|
|
217
|
+
* ``thresholds.disabled_checks`` skips by operator config, same way.
|
|
218
|
+
"""
|
|
219
|
+
import time # noqa: PLC0415 — local so a frozen-clock test can patch it
|
|
220
|
+
|
|
221
|
+
started = time.monotonic()
|
|
222
|
+
scope_filter = set(scopes) if scopes is not None else None
|
|
223
|
+
only_filter = set(only) if only is not None else None
|
|
224
|
+
disabled = set(getattr(ctx.thresholds, "disabled_checks", ()) or ())
|
|
225
|
+
|
|
226
|
+
report = HealthReport()
|
|
227
|
+
for chk in all_checks():
|
|
228
|
+
if scope_filter is not None and chk.scope not in scope_filter:
|
|
229
|
+
continue
|
|
230
|
+
if only_filter is not None and chk.id not in only_filter:
|
|
231
|
+
continue
|
|
232
|
+
if chk.id in disabled:
|
|
233
|
+
report.skipped.append(f"{chk.id} (disabled in coordinator.yml)")
|
|
234
|
+
continue
|
|
235
|
+
if chk.cost == COST_NETWORK and not ctx.allow_network:
|
|
236
|
+
report.skipped.append(f"{chk.id} (network probe, --no-network)")
|
|
237
|
+
continue
|
|
238
|
+
report.results.extend(run_check(chk, ctx))
|
|
239
|
+
report.duration_secs = time.monotonic() - started
|
|
240
|
+
return report
|
coord/health/render.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Text rendering for ``coord health`` (#1628).
|
|
2
|
+
|
|
3
|
+
**This module never decides anything.** It reads ``result.severity`` and
|
|
4
|
+
``result.headroom`` and lays them out; it does not look at ``result.values``
|
|
5
|
+
except to pass it through, and it does not know what any particular check
|
|
6
|
+
measures. That is the whole reason a check can be added without editing a
|
|
7
|
+
renderer — and the reason H-3's board projection and H-4's TUI/web renderers
|
|
8
|
+
can be written against the same :meth:`CheckResult.to_dict` contract without
|
|
9
|
+
re-deriving "is 86% used bad?" three more times.
|
|
10
|
+
|
|
11
|
+
If you find yourself wanting to special-case a check id in here, the thing
|
|
12
|
+
you actually want is another rendered field on ``CheckResult`` (like
|
|
13
|
+
``threshold`` or ``trend``), populated by the probe.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from coord.health.models import CheckResult, Severity
|
|
19
|
+
from coord.health.registry import HealthReport
|
|
20
|
+
|
|
21
|
+
# Minimum column widths. Columns grow to fit the widest row in the report
|
|
22
|
+
# (see :func:`render_report`) rather than truncating — a clipped repo name or
|
|
23
|
+
# path in a health report costs more than a ragged right edge.
|
|
24
|
+
_LABEL_WIDTH = 20
|
|
25
|
+
_SEVERITY_WIDTH = 5
|
|
26
|
+
_HEADROOM_WIDTH = 46
|
|
27
|
+
# Don't let one pathological label push every other column off the screen.
|
|
28
|
+
_MAX_LABEL_WIDTH = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_result(result: CheckResult, *, label_width: int = _LABEL_WIDTH) -> str:
|
|
32
|
+
"""One report line: ``<label> <SEVERITY> <headroom> <threshold>``."""
|
|
33
|
+
label = result.label.ljust(label_width)
|
|
34
|
+
severity = result.severity.label.ljust(_SEVERITY_WIDTH)
|
|
35
|
+
headroom = result.headroom
|
|
36
|
+
trailer_parts = []
|
|
37
|
+
if result.trend:
|
|
38
|
+
trailer_parts.append(result.trend)
|
|
39
|
+
# The threshold reminder is only useful next to a number that is near it.
|
|
40
|
+
# On an OK line it is clutter, so it rides along only when something is up.
|
|
41
|
+
if result.threshold and result.severity is not Severity.OK:
|
|
42
|
+
trailer_parts.append(result.threshold)
|
|
43
|
+
trailer = " ".join(trailer_parts)
|
|
44
|
+
line = f"{label} {severity} {headroom}"
|
|
45
|
+
if trailer:
|
|
46
|
+
line = f"{line.ljust(label_width + _SEVERITY_WIDTH + _HEADROOM_WIDTH + 4)} {trailer}"
|
|
47
|
+
return line.rstrip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_report(report: HealthReport, *, verbose: bool = False) -> str:
|
|
51
|
+
"""The full ``coord health`` body.
|
|
52
|
+
|
|
53
|
+
``verbose`` adds each result's ``detail`` (the "fix: ..." line) as an
|
|
54
|
+
indented continuation; without it only the headroom is shown, which is
|
|
55
|
+
what makes the report scannable at a glance.
|
|
56
|
+
"""
|
|
57
|
+
label_width = min(
|
|
58
|
+
_MAX_LABEL_WIDTH,
|
|
59
|
+
max([_LABEL_WIDTH, *(len(r.label) for r in report.results)]),
|
|
60
|
+
)
|
|
61
|
+
lines: list[str] = []
|
|
62
|
+
for result in report.results:
|
|
63
|
+
lines.append(render_result(result, label_width=label_width))
|
|
64
|
+
if result.detail and (verbose or result.severity is not Severity.OK):
|
|
65
|
+
lines.append(f"{' ' * (label_width + 2)} {result.detail}")
|
|
66
|
+
if not lines:
|
|
67
|
+
lines.append("no checks ran")
|
|
68
|
+
for skipped in report.skipped:
|
|
69
|
+
lines.append(f"{'skipped'.ljust(label_width)} - {skipped}")
|
|
70
|
+
lines.append(render_summary(report))
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_summary(report: HealthReport) -> str:
|
|
75
|
+
"""The trailer line — machine-greppable, same shape as GRAPH_HEALTH's."""
|
|
76
|
+
counts = report.counts()
|
|
77
|
+
return (
|
|
78
|
+
f"HEALTH: {report.severity.label} "
|
|
79
|
+
f"crit={counts['crit']} warn={counts['warn']} "
|
|
80
|
+
f"unknown={counts['unknown']} ok={counts['ok']} "
|
|
81
|
+
f"in {report.duration_secs:.2f}s"
|
|
82
|
+
)
|
coord/health/units.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tiny rendering helpers shared by probes (#1628).
|
|
2
|
+
|
|
3
|
+
These live next to the probes, not in the renderer, on purpose: a probe owns
|
|
4
|
+
its ``headroom`` string end to end (see ``coord.health.models``), so the
|
|
5
|
+
byte/duration formatting it needs has to be reachable without importing a
|
|
6
|
+
renderer. A renderer importing *this* is fine; a probe importing a renderer
|
|
7
|
+
is the fork we're preventing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_GIB = 1024.0 ** 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def human_bytes(n: float) -> str:
|
|
18
|
+
"""``78G`` / ``512M`` / ``0B`` — short enough for a one-line report."""
|
|
19
|
+
n = float(n)
|
|
20
|
+
sign = "-" if n < 0 else ""
|
|
21
|
+
n = abs(n)
|
|
22
|
+
for unit, size in (("T", 1024.0 ** 4), ("G", _GIB), ("M", 1024.0 ** 2), ("K", 1024.0)):
|
|
23
|
+
if n >= size:
|
|
24
|
+
value = n / size
|
|
25
|
+
# 43.2G reads better than 43G below 10; above that the decimal is noise.
|
|
26
|
+
return f"{sign}{value:.1f}{unit}" if value < 10 else f"{sign}{value:.0f}{unit}"
|
|
27
|
+
return f"{sign}{n:.0f}B"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gib(n: float) -> float:
|
|
31
|
+
"""Bytes → GiB, as a float."""
|
|
32
|
+
return float(n) / _GIB
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def human_hours(seconds: float) -> str:
|
|
36
|
+
"""``128.8h`` — the unit the graph-staleness incident was reported in."""
|
|
37
|
+
return f"{seconds / 3600.0:.1f}h"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def expand(path: str | Path, home: str | Path) -> Path:
|
|
41
|
+
"""``~``/``~/x`` → under *home*, everything else verbatim.
|
|
42
|
+
|
|
43
|
+
Deliberately NOT ``Path.expanduser()``: probes must expand against the
|
|
44
|
+
context's home so a test can point the whole engine at a tmp dir without
|
|
45
|
+
monkeypatching ``Path.home`` globally.
|
|
46
|
+
"""
|
|
47
|
+
s = str(path)
|
|
48
|
+
if s == "~":
|
|
49
|
+
return Path(home)
|
|
50
|
+
if s.startswith("~/"):
|
|
51
|
+
return Path(home) / s[2:]
|
|
52
|
+
return Path(s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def shorten_path(path: str, home: str) -> str:
|
|
56
|
+
"""``/home/john/src/vimcode`` → ``~/src/vimcode`` when it's under *home*."""
|
|
57
|
+
p, h = str(path), str(home).rstrip("/")
|
|
58
|
+
if h and (p == h or p.startswith(h + "/")):
|
|
59
|
+
return "~" + p[len(h):]
|
|
60
|
+
return p
|
coord/hooks.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Session lifecycle hooks — triggered at round completion and session end."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from coord import github_ops
|
|
9
|
+
from coord.config import Config
|
|
10
|
+
from coord.models import CLOSES_ISSUE_TYPES, Board
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HookResult:
|
|
15
|
+
hook: str
|
|
16
|
+
ok: bool
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_hooks(event: str, config: Config, board: Board) -> list[HookResult]:
|
|
21
|
+
"""Execute all hooks registered for *event*. Returns results."""
|
|
22
|
+
hook_names = getattr(config.hooks, event, [])
|
|
23
|
+
results: list[HookResult] = []
|
|
24
|
+
for name in hook_names:
|
|
25
|
+
fn = HOOK_REGISTRY.get(name)
|
|
26
|
+
if fn is None:
|
|
27
|
+
results.append(HookResult(hook=name, ok=False, message=f"unknown hook: {name}"))
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
msg = fn(config, board)
|
|
31
|
+
results.append(HookResult(hook=name, ok=True, message=msg))
|
|
32
|
+
except Exception as e:
|
|
33
|
+
results.append(HookResult(hook=name, ok=False, message=str(e)))
|
|
34
|
+
return results
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_round_complete(board: Board) -> bool:
|
|
38
|
+
"""True when no active assignments remain (all finished or failed)."""
|
|
39
|
+
return len(board.active) == 0 and len(board.completed) > 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Built-in hooks ──────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _close_merged_issues(config: Config, board: Board) -> str:
|
|
46
|
+
"""Close issues whose assignments completed successfully.
|
|
47
|
+
|
|
48
|
+
#1196 (hole 3): gated on :data:`coord.models.CLOSES_ISSUE_TYPES`, the
|
|
49
|
+
same gate ``merge_queue.py`` and the PR-body keyword already honour. An
|
|
50
|
+
opt-in hook (``KNOWN_HOOKS``, defaults empty — not enabled in this
|
|
51
|
+
repo's own config) must not contradict the #1077 invariant: a
|
|
52
|
+
"mock-author"/"test-author"/"audit" assignment's ``issue_number`` is the
|
|
53
|
+
milestone's *tracking issue* (often an epic), not something it resolved
|
|
54
|
+
— closing it here regardless of type was a loaded gun. Also routes
|
|
55
|
+
through :func:`coord.github_ops.close_issue` — the #1196 open-children
|
|
56
|
+
chokepoint — instead of a raw ``gh issue close`` call, so an epic with
|
|
57
|
+
open children is refused here too, not just on the merge path.
|
|
58
|
+
"""
|
|
59
|
+
closed = []
|
|
60
|
+
for a in board.completed:
|
|
61
|
+
if a.status != "done":
|
|
62
|
+
continue
|
|
63
|
+
if a.type not in CLOSES_ISSUE_TYPES:
|
|
64
|
+
continue
|
|
65
|
+
repo = config.repo(a.repo_name)
|
|
66
|
+
if repo is None:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
github_ops.close_issue(
|
|
70
|
+
repo.github,
|
|
71
|
+
a.issue_number,
|
|
72
|
+
comment=f"Closed by coordinator: assignment {a.assignment_id} completed.",
|
|
73
|
+
)
|
|
74
|
+
closed.append(f"{repo.github}#{a.issue_number}")
|
|
75
|
+
except RuntimeError:
|
|
76
|
+
pass
|
|
77
|
+
if not closed:
|
|
78
|
+
return "no issues to close"
|
|
79
|
+
return f"closed {len(closed)} issue(s): {', '.join(closed)}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _summary_report(config: Config, board: Board) -> str:
|
|
83
|
+
"""Generate a session/round summary."""
|
|
84
|
+
done = [a for a in board.completed if a.status == "done"]
|
|
85
|
+
failed = [a for a in board.completed if a.status == "failed"]
|
|
86
|
+
lines = [
|
|
87
|
+
f"Round {board.round_number} summary:",
|
|
88
|
+
f" completed: {len(done)} assignment(s)",
|
|
89
|
+
f" failed: {len(failed)} assignment(s)",
|
|
90
|
+
f" active: {len(board.active)} assignment(s) still running",
|
|
91
|
+
]
|
|
92
|
+
if done:
|
|
93
|
+
lines.append(" done:")
|
|
94
|
+
for a in done:
|
|
95
|
+
lines.append(f" - {a.repo_name} #{a.issue_number}: {a.issue_title}")
|
|
96
|
+
if failed:
|
|
97
|
+
lines.append(" failed:")
|
|
98
|
+
for a in failed:
|
|
99
|
+
lines.append(f" - {a.repo_name} #{a.issue_number}: {a.issue_title}")
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
HOOK_REGISTRY: dict[str, Callable[[Config, Board], str]] = {
|
|
104
|
+
"close_merged_issues": _close_merged_issues,
|
|
105
|
+
"summary_report": _summary_report,
|
|
106
|
+
}
|
coord/housekeeping.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""#762: bound DB growth by archiving stale terminal board rows.
|
|
2
|
+
|
|
3
|
+
The ``coord serve`` board grew unbounded (every assignment ever dispatched stayed
|
|
4
|
+
in ``assignments`` forever), bloating the ``/board`` projection until it overran
|
|
5
|
+
the TUI's fetch timeout and blanked the whole board. Part 1 caps the *wire*
|
|
6
|
+
(``coord.dao.board_projection``); this module bounds the *storage*: it **moves**
|
|
7
|
+
(never deletes) terminal assignments older than the archive window — plus their
|
|
8
|
+
notifications — into ``assignments_archive`` / ``notifications_archive`` so the
|
|
9
|
+
hot tables stay small while the cost/token/timing history is preserved for
|
|
10
|
+
analytics.
|
|
11
|
+
|
|
12
|
+
Two guarantees make the sweep safe:
|
|
13
|
+
|
|
14
|
+
* It reuses :func:`coord.dao.compute_board_keep_ids` (the *same* keep logic as the
|
|
15
|
+
board projection) with a **wider** window than the projection, so the protected
|
|
16
|
+
set here is always a superset of what the projection keeps — archiving can never
|
|
17
|
+
drop a row the board still shows.
|
|
18
|
+
* It only ever archives rows whose status is terminal, as a belt-and-suspenders
|
|
19
|
+
guard on top of the keep set.
|
|
20
|
+
|
|
21
|
+
The sweep runs automatically on a low-cadence daemon tick and on demand via
|
|
22
|
+
``coord housekeeping`` (which routes through the daemon — the canonical DB lives
|
|
23
|
+
there).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sqlite3
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from coord.dao import (
|
|
33
|
+
TERMINAL_STATUSES,
|
|
34
|
+
_KEEP_INDEX_COLUMNS,
|
|
35
|
+
compute_board_keep_ids,
|
|
36
|
+
)
|
|
37
|
+
from coord.db import get_connection
|
|
38
|
+
|
|
39
|
+
# Terminal rows older than this many days (and not referenced by anything live)
|
|
40
|
+
# are eligible to move to the archive. Deliberately wider than the board
|
|
41
|
+
# projection window (``COORD_BOARD_RETENTION_DAYS``, default 14) so the live
|
|
42
|
+
# table always retains everything the wire shows, with margin for any logic that
|
|
43
|
+
# reaches back through ``build_board``. 0 disables archiving.
|
|
44
|
+
_DEFAULT_ARCHIVE_RETENTION_DAYS = 30
|
|
45
|
+
|
|
46
|
+
_ASSIGNMENTS = "assignments"
|
|
47
|
+
_ASSIGNMENTS_ARCHIVE = "assignments_archive"
|
|
48
|
+
_NOTIFICATIONS = "notifications"
|
|
49
|
+
_NOTIFICATIONS_ARCHIVE = "notifications_archive"
|
|
50
|
+
_BATCH = 400 # keep IN(...) clauses well under SQLite's 999-variable limit
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _archive_retention_days() -> int:
|
|
54
|
+
try:
|
|
55
|
+
return int(
|
|
56
|
+
os.environ.get(
|
|
57
|
+
"COORD_ARCHIVE_RETENTION_DAYS", _DEFAULT_ARCHIVE_RETENTION_DAYS
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return _DEFAULT_ARCHIVE_RETENTION_DAYS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _archive_cutoff(now: float | None = None) -> float | None:
|
|
65
|
+
days = _archive_retention_days()
|
|
66
|
+
if days <= 0:
|
|
67
|
+
return None
|
|
68
|
+
return (time.time() if now is None else now) - days * 86400.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _columns(conn: sqlite3.Connection, table: str) -> list[tuple[str, str]]:
|
|
72
|
+
"""Return ``[(name, type), ...]`` for *table* (empty if it doesn't exist)."""
|
|
73
|
+
return [
|
|
74
|
+
(r[1], r[2] or "TEXT")
|
|
75
|
+
for r in conn.execute(f"PRAGMA table_info({table})").fetchall() # noqa: S608
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ensure_archive_mirror(conn: sqlite3.Connection, src: str, dst: str) -> list[str]:
|
|
80
|
+
"""Create/extend *dst* so it has every column of *src* (no constraints — the
|
|
81
|
+
archive is dumb storage). Robust to future ``ALTER TABLE`` on *src*.
|
|
82
|
+
|
|
83
|
+
Returns the shared column-name list to use for the copy.
|
|
84
|
+
"""
|
|
85
|
+
src_cols = _columns(conn, src)
|
|
86
|
+
dst_existing = {name for name, _ in _columns(conn, dst)}
|
|
87
|
+
if not dst_existing:
|
|
88
|
+
coldefs = ", ".join(f'"{name}" {ctype}' for name, ctype in src_cols)
|
|
89
|
+
conn.execute(f"CREATE TABLE {dst} ({coldefs})") # noqa: S608
|
|
90
|
+
else:
|
|
91
|
+
for name, ctype in src_cols:
|
|
92
|
+
if name not in dst_existing:
|
|
93
|
+
conn.execute(f'ALTER TABLE {dst} ADD COLUMN "{name}" {ctype}') # noqa: S608
|
|
94
|
+
return [name for name, _ in src_cols]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _move_rows(
|
|
98
|
+
conn: sqlite3.Connection,
|
|
99
|
+
src: str,
|
|
100
|
+
dst: str,
|
|
101
|
+
key_col: str,
|
|
102
|
+
ids: list[str],
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Copy then delete rows of *src* whose *key_col* is in *ids* (batched)."""
|
|
105
|
+
cols = _ensure_archive_mirror(conn, src, dst)
|
|
106
|
+
collist = ", ".join(f'"{c}"' for c in cols)
|
|
107
|
+
for i in range(0, len(ids), _BATCH):
|
|
108
|
+
batch = ids[i : i + _BATCH]
|
|
109
|
+
placeholders = ",".join("?" for _ in batch)
|
|
110
|
+
conn.execute(
|
|
111
|
+
f"INSERT INTO {dst} ({collist}) SELECT {collist} FROM {src} " # noqa: S608
|
|
112
|
+
f"WHERE {key_col} IN ({placeholders})",
|
|
113
|
+
batch,
|
|
114
|
+
)
|
|
115
|
+
conn.execute(
|
|
116
|
+
f"DELETE FROM {src} WHERE {key_col} IN ({placeholders})", # noqa: S608
|
|
117
|
+
batch,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def sweep(*, dry_run: bool = False, now: float | None = None) -> dict:
|
|
122
|
+
"""Archive stale terminal assignments + their notifications.
|
|
123
|
+
|
|
124
|
+
Returns ``{"archived_assignments": N, "archived_notifications": M,
|
|
125
|
+
"dry_run": bool, "retention_days": D}``. ``archived_*`` are the counts that
|
|
126
|
+
were (or, for ``dry_run``, would be) moved. A no-op returns zeros.
|
|
127
|
+
|
|
128
|
+
Conservative by construction: nothing active, recent (within the archive
|
|
129
|
+
window), queued-for-merge, latest-of-an-open-issue, or review-linked to any
|
|
130
|
+
such row is ever moved.
|
|
131
|
+
"""
|
|
132
|
+
cutoff = _archive_cutoff(now)
|
|
133
|
+
result = {
|
|
134
|
+
"archived_assignments": 0,
|
|
135
|
+
"archived_notifications": 0,
|
|
136
|
+
"dry_run": dry_run,
|
|
137
|
+
"retention_days": _archive_retention_days(),
|
|
138
|
+
}
|
|
139
|
+
if cutoff is None:
|
|
140
|
+
return result # archiving disabled
|
|
141
|
+
|
|
142
|
+
conn = get_connection()
|
|
143
|
+
index = [
|
|
144
|
+
dict(r)
|
|
145
|
+
for r in conn.execute(
|
|
146
|
+
f"SELECT {_KEEP_INDEX_COLUMNS} FROM {_ASSIGNMENTS}" # noqa: S608
|
|
147
|
+
).fetchall()
|
|
148
|
+
]
|
|
149
|
+
mq_ids = {
|
|
150
|
+
r["assignment_id"]
|
|
151
|
+
for r in conn.execute(
|
|
152
|
+
f"SELECT assignment_id FROM merge_queue" # noqa: S608
|
|
153
|
+
).fetchall()
|
|
154
|
+
if r["assignment_id"]
|
|
155
|
+
}
|
|
156
|
+
open_keys = {
|
|
157
|
+
(r["repo_name"], r["number"])
|
|
158
|
+
for r in conn.execute(
|
|
159
|
+
"SELECT repo_name, number FROM issues WHERE LOWER(state) != 'closed'"
|
|
160
|
+
).fetchall()
|
|
161
|
+
}
|
|
162
|
+
protected = compute_board_keep_ids(index, mq_ids, open_keys, cutoff)
|
|
163
|
+
|
|
164
|
+
candidates = [
|
|
165
|
+
r["assignment_id"]
|
|
166
|
+
for r in index
|
|
167
|
+
if r["assignment_id"]
|
|
168
|
+
and r["assignment_id"] not in protected
|
|
169
|
+
and (r["status"] or "").lower() in TERMINAL_STATUSES
|
|
170
|
+
]
|
|
171
|
+
candidate_set = set(candidates)
|
|
172
|
+
|
|
173
|
+
# Notifications to archive: those belonging to an archived assignment, plus
|
|
174
|
+
# old notifications not referencing a still-protected assignment.
|
|
175
|
+
notif_ids = [
|
|
176
|
+
r["assignment_id"]
|
|
177
|
+
for r in conn.execute(
|
|
178
|
+
f"SELECT assignment_id, posted_at FROM {_NOTIFICATIONS}" # noqa: S608
|
|
179
|
+
).fetchall()
|
|
180
|
+
if r["assignment_id"]
|
|
181
|
+
and (
|
|
182
|
+
r["assignment_id"] in candidate_set
|
|
183
|
+
or (
|
|
184
|
+
(r["posted_at"] is not None and r["posted_at"] < cutoff)
|
|
185
|
+
and r["assignment_id"] not in protected
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
result["archived_assignments"] = len(candidates)
|
|
191
|
+
result["archived_notifications"] = len(notif_ids)
|
|
192
|
+
if dry_run or (not candidates and not notif_ids):
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
with conn:
|
|
196
|
+
if notif_ids:
|
|
197
|
+
_move_rows(
|
|
198
|
+
conn, _NOTIFICATIONS, _NOTIFICATIONS_ARCHIVE, "assignment_id", notif_ids
|
|
199
|
+
)
|
|
200
|
+
if candidates:
|
|
201
|
+
_move_rows(
|
|
202
|
+
conn, _ASSIGNMENTS, _ASSIGNMENTS_ARCHIVE, "assignment_id", candidates
|
|
203
|
+
)
|
|
204
|
+
return result
|