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
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Session/daemon lifecycle commands: `notify`, `resume`, `done`, `web`,
|
|
2
|
+
`serve`, `housekeeping`. Extracted from coord/cli.py (#747)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
from coord.commands._common import (
|
|
15
|
+
SERVE_PORT,
|
|
16
|
+
_CONFIG_OPTION,
|
|
17
|
+
_load_config,
|
|
18
|
+
server_extra_guard,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_housekeeping_result(resp: dict) -> None:
|
|
23
|
+
dry = resp.get("dry_run")
|
|
24
|
+
archived_a = resp.get("archived_assignments", 0)
|
|
25
|
+
archived_n = resp.get("archived_notifications", 0)
|
|
26
|
+
days = resp.get("retention_days")
|
|
27
|
+
if not archived_a and not archived_n:
|
|
28
|
+
click.echo(
|
|
29
|
+
f"housekeeping: nothing to archive (no terminal rows older than {days}d)."
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
verb = "would archive" if dry else "archived"
|
|
33
|
+
suffix = " (dry-run — nothing moved)" if dry else ""
|
|
34
|
+
click.echo(
|
|
35
|
+
f"housekeeping: {verb} {archived_a} assignment(s) + "
|
|
36
|
+
f"{archived_n} notification(s) (terminal, older than {days}d).{suffix}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.command(
|
|
41
|
+
"housekeeping",
|
|
42
|
+
help=(
|
|
43
|
+
"#762: archive stale terminal board rows so the /board payload + DB stay "
|
|
44
|
+
"bounded (an unbounded board overran the TUI fetch timeout and blanked "
|
|
45
|
+
"the board).\n\n"
|
|
46
|
+
"Moves terminal assignments older than COORD_ARCHIVE_RETENTION_DAYS "
|
|
47
|
+
"(default 30) + their notifications into assignments_archive / "
|
|
48
|
+
"notifications_archive — it NEVER deletes, and never touches active, "
|
|
49
|
+
"recent, merge-queued, open-issue-latest, or review-linked rows. Routes "
|
|
50
|
+
"through the daemon (the canonical DB lives there)."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.option(
|
|
56
|
+
"--dry-run",
|
|
57
|
+
is_flag=True,
|
|
58
|
+
help="Report what would be archived without moving anything.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def housekeeping(dry_run: bool) -> None:
|
|
63
|
+
"""#762: archive stale terminal board rows (active/recent/referenced kept)."""
|
|
64
|
+
from coord.board_service import daemon_reroute_target # noqa: PLC0415
|
|
65
|
+
|
|
66
|
+
_svc = daemon_reroute_target("COORD_HOUSEKEEPING_ON_DAEMON")
|
|
67
|
+
if _svc is not None:
|
|
68
|
+
from coord.client import post_record # noqa: PLC0415
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
resp = post_record(_svc, "/housekeeping", {"dry_run": dry_run}, timeout=180.0)
|
|
72
|
+
except Exception as exc: # noqa: BLE001
|
|
73
|
+
click.echo(f"error: housekeeping via daemon failed: {exc}", err=True)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
_print_housekeeping_result(resp)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
from coord import housekeeping as _hk # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
_print_housekeeping_result(_hk.sweep(dry_run=dry_run))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.command(help="Poll agents and post completion/failure comments on GitHub.")
|
|
84
|
+
@_CONFIG_OPTION
|
|
85
|
+
def notify(config_path: Path) -> None:
|
|
86
|
+
# #906: `coord notify` reads dispatched assignments and writes back
|
|
87
|
+
# mark_notified/save_plan/update_claude_session_id — all local-DB
|
|
88
|
+
# operations that are empty/no-op on a thin client. Route the whole
|
|
89
|
+
# command to the daemon so it runs against the canonical DB + real agent
|
|
90
|
+
# fleet. COORD_NOTIFY_ON_DAEMON guards the daemon against re-routing to
|
|
91
|
+
# itself (same pattern as coord merge / reconcile-merges / diagnose /
|
|
92
|
+
# housekeeping).
|
|
93
|
+
from coord.board_service import daemon_reroute_target # noqa: PLC0415
|
|
94
|
+
|
|
95
|
+
_svc = daemon_reroute_target("COORD_NOTIFY_ON_DAEMON")
|
|
96
|
+
if _svc is not None:
|
|
97
|
+
from coord.client import post_record # noqa: PLC0415
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
resp = post_record(_svc, "/notify", {}, timeout=180.0)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
click.echo(f"error: notify via daemon failed: {exc}", err=True)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
output = resp.get("output") or ""
|
|
105
|
+
if output:
|
|
106
|
+
click.echo(output, nl=False)
|
|
107
|
+
if resp.get("error"):
|
|
108
|
+
click.echo(f"error: {resp['error']}", err=True)
|
|
109
|
+
code = resp.get("exit_code") or 0
|
|
110
|
+
if code:
|
|
111
|
+
sys.exit(int(code))
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
from coord.board_service import read_board, write_board
|
|
115
|
+
from coord.hooks import is_round_complete, run_hooks
|
|
116
|
+
from coord.notify import run as run_notify
|
|
117
|
+
|
|
118
|
+
cfg = _load_config(config_path)
|
|
119
|
+
posted, stuck, needs_attention, stalled, liveness = run_notify(cfg)
|
|
120
|
+
if not posted and not stuck and not needs_attention and not stalled and not liveness:
|
|
121
|
+
click.echo("No new transitions to notify.")
|
|
122
|
+
return
|
|
123
|
+
if posted:
|
|
124
|
+
click.echo(f"Posted {len(posted)} completion/failure comment(s):")
|
|
125
|
+
for t in posted:
|
|
126
|
+
click.echo(
|
|
127
|
+
f" [{t.event}] {t.machine_name} → {t.repo_name} "
|
|
128
|
+
f"#{t.issue_number} (assignment {t.assignment_id}, exit {t.exit_code})"
|
|
129
|
+
)
|
|
130
|
+
if stuck:
|
|
131
|
+
click.echo(f"Posted {len(stuck)} stuck detection(s):")
|
|
132
|
+
for s in stuck:
|
|
133
|
+
click.echo(
|
|
134
|
+
f" [stuck] {s.machine_name} → {s.repo_name} "
|
|
135
|
+
f"#{s.issue_number} (assignment {s.assignment_id})"
|
|
136
|
+
)
|
|
137
|
+
click.echo(f" {s.stuck_message}")
|
|
138
|
+
if needs_attention:
|
|
139
|
+
click.echo(f"Posted {len(needs_attention)} needs-attention detection(s):")
|
|
140
|
+
for n in needs_attention:
|
|
141
|
+
click.echo(
|
|
142
|
+
f" [needs-attention:{n.reason}] {n.machine_name} → {n.repo_name} "
|
|
143
|
+
f"#{n.issue_number} (assignment {n.assignment_id})"
|
|
144
|
+
)
|
|
145
|
+
click.echo(f" {n.detail}")
|
|
146
|
+
if stalled:
|
|
147
|
+
click.echo(f"Posted {len(stalled)} stalled-pipeline detection(s):")
|
|
148
|
+
for s in stalled:
|
|
149
|
+
click.echo(
|
|
150
|
+
f" [stalled:{s.reason}] {s.machine_name} → {s.repo_name} "
|
|
151
|
+
f"#{s.issue_number} (assignment {s.assignment_id})"
|
|
152
|
+
)
|
|
153
|
+
click.echo(f" {s.detail}")
|
|
154
|
+
if liveness:
|
|
155
|
+
click.echo(f"Posted {len(liveness)} liveness-auditor stall detection(s):")
|
|
156
|
+
for lv in liveness:
|
|
157
|
+
click.echo(
|
|
158
|
+
f" [liveness] {lv.machine_name} → {lv.repo_name} "
|
|
159
|
+
f"#{lv.issue_number} (assignment {lv.assignment_id}, "
|
|
160
|
+
f"{lv.consecutive_blocked} consecutive blocked verdicts)"
|
|
161
|
+
)
|
|
162
|
+
board = read_board()
|
|
163
|
+
|
|
164
|
+
if is_round_complete(board) and cfg.hooks.on_round_complete:
|
|
165
|
+
click.echo("\nRound complete — running hooks:")
|
|
166
|
+
for result in run_hooks("on_round_complete", cfg, board):
|
|
167
|
+
status = "ok" if result.ok else "FAILED"
|
|
168
|
+
click.echo(f" [{status}] {result.hook}: {result.message}")
|
|
169
|
+
|
|
170
|
+
write_board(board)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@click.command(help="Recover board state after a crash or restart.")
|
|
174
|
+
@_CONFIG_OPTION
|
|
175
|
+
def resume(config_path: Path) -> None:
|
|
176
|
+
from coord.board_service import is_remote, read_board, write_board
|
|
177
|
+
from coord.reconcile import reconcile
|
|
178
|
+
|
|
179
|
+
cfg = _load_config(config_path)
|
|
180
|
+
if not is_remote():
|
|
181
|
+
# Informational only (local mode): distinguish "no board saved yet" from
|
|
182
|
+
# "loaded an existing board" — read_board() below does the actual
|
|
183
|
+
# local/remote read.
|
|
184
|
+
from coord.state import load_board as _peek_local_board # noqa: PLC0415
|
|
185
|
+
|
|
186
|
+
if _peek_local_board() is None:
|
|
187
|
+
click.echo("No saved board found. Rebuilding from dispatched ledger...")
|
|
188
|
+
board = read_board()
|
|
189
|
+
|
|
190
|
+
click.echo(f"Board round: {board.round_number}")
|
|
191
|
+
click.echo(f" active: {len(board.active)} assignment(s)")
|
|
192
|
+
click.echo(f" completed: {len(board.completed)} assignment(s)")
|
|
193
|
+
|
|
194
|
+
if board.active:
|
|
195
|
+
click.echo("\nReconciling with agent servers...")
|
|
196
|
+
changed = reconcile(board, cfg)
|
|
197
|
+
if changed:
|
|
198
|
+
click.echo(f" {len(changed)} assignment(s) finished since last check:")
|
|
199
|
+
from coord.merge_queue import enqueue as _mq_enqueue
|
|
200
|
+
for aid in changed:
|
|
201
|
+
a = board.find_by_id(aid)
|
|
202
|
+
if a:
|
|
203
|
+
click.echo(f" {a.machine_name} → {a.repo_name} #{a.issue_number}: [{a.status}]")
|
|
204
|
+
if a.status == "done":
|
|
205
|
+
repo_cfg = cfg.repo(a.repo_name)
|
|
206
|
+
if repo_cfg is not None and a.branch:
|
|
207
|
+
entry = _mq_enqueue(
|
|
208
|
+
a,
|
|
209
|
+
repo_github=repo_cfg.github,
|
|
210
|
+
target_branch=repo_cfg.default_branch,
|
|
211
|
+
)
|
|
212
|
+
if entry is not None:
|
|
213
|
+
click.echo(
|
|
214
|
+
f" → enqueued for merge ({entry.branch} → {entry.target_branch})"
|
|
215
|
+
)
|
|
216
|
+
elif a.status == "done" and not a.branch:
|
|
217
|
+
click.echo(
|
|
218
|
+
" → no branch captured; skip merge enqueue"
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
click.echo(" all active assignments still running")
|
|
222
|
+
|
|
223
|
+
removed = board.gc()
|
|
224
|
+
if removed:
|
|
225
|
+
click.echo(f"\nGC: pruned {removed} old completed assignment(s)")
|
|
226
|
+
|
|
227
|
+
write_board(board)
|
|
228
|
+
click.echo(f"\nBoard saved ({len(board.active)} active, {len(board.completed)} completed)")
|
|
229
|
+
|
|
230
|
+
if board.active:
|
|
231
|
+
click.echo("\nActive assignments:")
|
|
232
|
+
for a in board.active:
|
|
233
|
+
click.echo(f" {a.machine_name} → {a.repo_name} #{a.issue_number}: {a.issue_title}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@click.command(help="End the session — run housekeeping hooks and show summary.")
|
|
237
|
+
@_CONFIG_OPTION
|
|
238
|
+
def done(config_path: Path) -> None:
|
|
239
|
+
from coord.board_service import read_board, write_board
|
|
240
|
+
from coord.hooks import run_hooks
|
|
241
|
+
|
|
242
|
+
cfg = _load_config(config_path)
|
|
243
|
+
board = read_board()
|
|
244
|
+
|
|
245
|
+
if board.active:
|
|
246
|
+
click.echo(
|
|
247
|
+
f"warning: {len(board.active)} assignment(s) still active. "
|
|
248
|
+
f"They will continue running on their agent servers.",
|
|
249
|
+
err=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if cfg.hooks.on_session_end:
|
|
253
|
+
click.echo("Running session-end hooks:")
|
|
254
|
+
for result in run_hooks("on_session_end", cfg, board):
|
|
255
|
+
status = "ok" if result.ok else "FAILED"
|
|
256
|
+
click.echo(f" [{status}] {result.hook}: {result.message}")
|
|
257
|
+
else:
|
|
258
|
+
from coord.hooks import _summary_report
|
|
259
|
+
click.echo(_summary_report(cfg, board))
|
|
260
|
+
|
|
261
|
+
# Repo housekeeping: pull latest and run configured commands
|
|
262
|
+
hostname = socket.gethostname().split(".")[0]
|
|
263
|
+
local_machine = next(
|
|
264
|
+
(m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
|
|
265
|
+
None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if local_machine:
|
|
269
|
+
for repo in cfg.repos:
|
|
270
|
+
if not repo.housekeeping:
|
|
271
|
+
continue
|
|
272
|
+
repo_path_str = local_machine.repo_path(repo.name)
|
|
273
|
+
if not repo_path_str:
|
|
274
|
+
click.echo(f" {repo.name}: no local path configured, skipping housekeeping")
|
|
275
|
+
continue
|
|
276
|
+
repo_path = Path(repo_path_str).expanduser()
|
|
277
|
+
if not repo_path.exists():
|
|
278
|
+
click.echo(f" {repo.name}: path {repo_path} does not exist, skipping")
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# Pull latest
|
|
282
|
+
click.echo(f"\n{repo.name}: pulling latest...")
|
|
283
|
+
try:
|
|
284
|
+
subprocess.run(
|
|
285
|
+
["git", "pull", "--ff-only"],
|
|
286
|
+
cwd=str(repo_path), check=True, capture_output=True, text=True,
|
|
287
|
+
)
|
|
288
|
+
except subprocess.CalledProcessError as e:
|
|
289
|
+
click.echo(f" git pull failed: {e.stderr.strip()}", err=True)
|
|
290
|
+
# Continue with housekeeping anyway — might still work
|
|
291
|
+
|
|
292
|
+
# Run housekeeping commands
|
|
293
|
+
for cmd in repo.housekeeping:
|
|
294
|
+
click.echo(f" running: {cmd}")
|
|
295
|
+
try:
|
|
296
|
+
result = subprocess.run(
|
|
297
|
+
cmd, shell=True, cwd=str(repo_path),
|
|
298
|
+
capture_output=True, text=True, timeout=300,
|
|
299
|
+
)
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
click.echo(f" failed (exit {result.returncode}): {result.stderr.strip()}", err=True)
|
|
302
|
+
else:
|
|
303
|
+
click.echo(f" done")
|
|
304
|
+
except subprocess.TimeoutExpired:
|
|
305
|
+
click.echo(f" timed out after 300s", err=True)
|
|
306
|
+
except Exception as e:
|
|
307
|
+
click.echo(f" error: {e}", err=True)
|
|
308
|
+
else:
|
|
309
|
+
click.echo("\nCould not determine local machine — skipping repo housekeeping")
|
|
310
|
+
|
|
311
|
+
write_board(board)
|
|
312
|
+
|
|
313
|
+
# Write session end summary — use the usage module so the output matches `coord usage`.
|
|
314
|
+
import datetime
|
|
315
|
+
from coord.state import write_session_end, load_session
|
|
316
|
+
from coord.usage import build_session_usage, format_usage_report
|
|
317
|
+
|
|
318
|
+
sess = load_session()
|
|
319
|
+
started_at: float | None = None
|
|
320
|
+
if sess and sess.get("started_at"):
|
|
321
|
+
try:
|
|
322
|
+
dt = datetime.datetime.fromisoformat(
|
|
323
|
+
sess["started_at"].rstrip("Z").replace("Z", "+00:00")
|
|
324
|
+
)
|
|
325
|
+
started_at = dt.replace(tzinfo=datetime.timezone.utc).timestamp()
|
|
326
|
+
except (ValueError, AttributeError):
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
330
|
+
session_usage = build_session_usage(all_assignments, started_at=started_at)
|
|
331
|
+
total_cost = session_usage.total_cost_usd
|
|
332
|
+
|
|
333
|
+
click.echo("")
|
|
334
|
+
click.echo(format_usage_report(session_usage))
|
|
335
|
+
|
|
336
|
+
completed_ids = [a.assignment_id for a in board.completed if a.assignment_id]
|
|
337
|
+
issues_closed = list(set(a.issue_number for a in board.completed))
|
|
338
|
+
write_session_end(
|
|
339
|
+
completed_ids=completed_ids,
|
|
340
|
+
issues_closed=issues_closed,
|
|
341
|
+
total_cost_usd=total_cost,
|
|
342
|
+
)
|
|
343
|
+
click.echo(f"\nSession saved (${total_cost:.2f} total cost)")
|
|
344
|
+
|
|
345
|
+
click.echo("\nSession ended. Board saved.")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@click.command(help="Start the web dashboard (port 7434).")
|
|
349
|
+
@_CONFIG_OPTION
|
|
350
|
+
@click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
|
|
351
|
+
@click.option("--port", "bind_port", default=7434, show_default=True, type=int)
|
|
352
|
+
@click.option(
|
|
353
|
+
"--token",
|
|
354
|
+
"token",
|
|
355
|
+
default=None,
|
|
356
|
+
envvar="COORD_WEB_TOKEN",
|
|
357
|
+
help=(
|
|
358
|
+
"Bearer token gating the /ws/terminal PTY bridge (#1065); clients "
|
|
359
|
+
"connect with ?token=<token> (browsers can't set custom WS upgrade "
|
|
360
|
+
"headers). Resolves flag > $COORD_WEB_TOKEN > ~/.coord/web_token. "
|
|
361
|
+
"Prefer the file/env (a --token on the command line leaks via `ps`). "
|
|
362
|
+
"Unset -> open (tailnet ACL only)."
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
@click.option(
|
|
366
|
+
"--fixture",
|
|
367
|
+
"fixture_path",
|
|
368
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
369
|
+
default=None,
|
|
370
|
+
help=(
|
|
371
|
+
"#1538: serve a deterministic seeded board from a JSON fixture instead "
|
|
372
|
+
"of ~/.coord/coord.db — the web twin of coord-tui's make_test_app. "
|
|
373
|
+
"Every endpoint answers through the REAL compute_pipeline/serialization; "
|
|
374
|
+
"writes are recorded (GET /api/fixture/actions), never executed. No DB, "
|
|
375
|
+
"no fleet, no network, no money. See coord/dashboard/fixture.py."
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
@click.option(
|
|
379
|
+
"--dist",
|
|
380
|
+
"dist_path",
|
|
381
|
+
type=click.Path(exists=False, file_okay=False, path_type=Path),
|
|
382
|
+
default=None,
|
|
383
|
+
envvar="COORD_WEB_DIST",
|
|
384
|
+
help=(
|
|
385
|
+
"#1543: serve the built React webapp from this directory instead of "
|
|
386
|
+
"the one bundled inside the installed package "
|
|
387
|
+
"(coord/dashboard/webapp/dist). Resolves flag > $COORD_WEB_DIST. "
|
|
388
|
+
"Lets a build hook keep a checkout's dist/ in sync with merged main "
|
|
389
|
+
"and have it go live here without touching ~/.coord-venv, so a "
|
|
390
|
+
"webapp change never upgrades coord-agent/coord-serve's shared "
|
|
391
|
+
"venv or needs a PyPI release. Missing/empty falls back to the "
|
|
392
|
+
"legacy single-file dashboard, same as the bundled default. See "
|
|
393
|
+
"docs/PHONE_WEBAPP.md."
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
def web(
|
|
397
|
+
config_path: Path,
|
|
398
|
+
bind_host: str,
|
|
399
|
+
bind_port: int,
|
|
400
|
+
token: str | None,
|
|
401
|
+
fixture_path: Path | None,
|
|
402
|
+
dist_path: Path | None,
|
|
403
|
+
) -> None:
|
|
404
|
+
# #1237: function-local + guarded — see _start_agent_server for why.
|
|
405
|
+
with server_extra_guard("web"):
|
|
406
|
+
import uvicorn
|
|
407
|
+
from coord.dashboard.server import build_app
|
|
408
|
+
from coord.dashboard.terminal import resolve_web_token
|
|
409
|
+
|
|
410
|
+
fixture = None
|
|
411
|
+
if fixture_path is not None:
|
|
412
|
+
from coord.config import ConfigError # noqa: PLC0415
|
|
413
|
+
from coord.dashboard.fixture import FixtureError, load_fixture # noqa: PLC0415
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
fixture = load_fixture(fixture_path)
|
|
417
|
+
except FixtureError as exc:
|
|
418
|
+
raise click.ClickException(str(exc)) from exc
|
|
419
|
+
# A fixture server must start on a machine with no coord.db and no
|
|
420
|
+
# coordinator.yml at all (#1538 acceptance), so a missing/invalid
|
|
421
|
+
# config is not fatal here — the fixture's own `config` block, or
|
|
422
|
+
# Config defaults, take over. Deliberately `config.load` rather than
|
|
423
|
+
# `_load_config`: the latter exits the process on ConfigError, fetches
|
|
424
|
+
# a thin client's config over the network, and snapshots the result to
|
|
425
|
+
# the DB — all three are exactly what fixture mode must not do.
|
|
426
|
+
from coord.config import load as _load_config_file # noqa: PLC0415
|
|
427
|
+
|
|
428
|
+
fallback = None
|
|
429
|
+
try:
|
|
430
|
+
fallback = _load_config_file(config_path)
|
|
431
|
+
except (ConfigError, OSError):
|
|
432
|
+
pass
|
|
433
|
+
cfg = fixture.config(fallback)
|
|
434
|
+
else:
|
|
435
|
+
cfg = _load_config(config_path)
|
|
436
|
+
|
|
437
|
+
token = resolve_web_token(token)
|
|
438
|
+
app = build_app(cfg, token=token, fixture=fixture, dist_path=dist_path)
|
|
439
|
+
click.echo(f"coord web: dashboard at http://{bind_host}:{bind_port}")
|
|
440
|
+
if dist_path is not None:
|
|
441
|
+
click.echo(f" serving webapp bundle from {dist_path} (#1543)")
|
|
442
|
+
if fixture is not None:
|
|
443
|
+
click.echo(
|
|
444
|
+
f" fixture mode: seeded board from {fixture_path} — reads are "
|
|
445
|
+
"deterministic, writes are RECORDED not executed "
|
|
446
|
+
"(GET /api/fixture/actions)."
|
|
447
|
+
)
|
|
448
|
+
if not token:
|
|
449
|
+
click.echo(
|
|
450
|
+
" warning: no bearer token — the /ws/terminal PTY bridge is open "
|
|
451
|
+
"to anyone who can reach this port. Fine for dev; set one for a "
|
|
452
|
+
"production dashboard (echo <secret> > ~/.coord/web_token).",
|
|
453
|
+
err=True,
|
|
454
|
+
)
|
|
455
|
+
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@click.command(
|
|
459
|
+
help=(
|
|
460
|
+
"Start the portable control-center daemon (#584, port 7435). Serves the "
|
|
461
|
+
"board (GET /board) + config (GET /config) and records results (POST "
|
|
462
|
+
"/result, /completion, #590) against the one shared ~/.coord/coord.db, so "
|
|
463
|
+
"any Tailscale machine renders and drives the same board. Run this on "
|
|
464
|
+
"the always-on host that owns the DB. Optional bearer token (flag > "
|
|
465
|
+
"$COORD_SERVE_TOKEN > ~/.coord/serve_token)."
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@_CONFIG_OPTION
|
|
471
|
+
@click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
|
|
472
|
+
@click.option("--port", "bind_port", default=SERVE_PORT, show_default=True, type=int)
|
|
473
|
+
@click.option(
|
|
474
|
+
"--token",
|
|
475
|
+
"token",
|
|
476
|
+
default=None,
|
|
477
|
+
envvar="COORD_SERVE_TOKEN",
|
|
478
|
+
help=(
|
|
479
|
+
"Shared bearer token; clients must send Authorization: Bearer <token>. "
|
|
480
|
+
"Resolves flag > $COORD_SERVE_TOKEN > ~/.coord/serve_token. Prefer the "
|
|
481
|
+
"file/env (a --token on the command line leaks via `ps`). Unset → open "
|
|
482
|
+
"(tailnet ACL only)."
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def serve(config_path: Path, bind_host: str, bind_port: int, token: str | None) -> None:
|
|
488
|
+
# #1237: function-local + guarded — see _start_agent_server for why.
|
|
489
|
+
with server_extra_guard("serve"):
|
|
490
|
+
import uvicorn
|
|
491
|
+
|
|
492
|
+
from coord.dao import SqliteStore
|
|
493
|
+
from coord.db import DB_PATH
|
|
494
|
+
from coord.serve_app import build_app as build_serve_app
|
|
495
|
+
from coord.serve_app import resolve_serve_token
|
|
496
|
+
|
|
497
|
+
cfg = _load_config(config_path)
|
|
498
|
+
token = resolve_serve_token(token)
|
|
499
|
+
store = SqliteStore(DB_PATH)
|
|
500
|
+
app = build_serve_app(store, cfg, token=token)
|
|
501
|
+
auth = "bearer-token" if token else "OPEN (tailnet ACL only)"
|
|
502
|
+
click.echo(
|
|
503
|
+
f"coord serve: control center at http://{bind_host}:{bind_port} "
|
|
504
|
+
f"(config={cfg.path}, db={DB_PATH}, auth={auth})"
|
|
505
|
+
)
|
|
506
|
+
if not token:
|
|
507
|
+
click.echo(
|
|
508
|
+
" warning: no bearer token — endpoints are open to anyone who can "
|
|
509
|
+
"reach this port. Fine for dev; the production daemon should set one "
|
|
510
|
+
"(echo <secret> > ~/.coord/serve_token). See AGENT_OPERATIONS.md.",
|
|
511
|
+
err=True,
|
|
512
|
+
)
|
|
513
|
+
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|