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,1894 @@
|
|
|
1
|
+
"""Web dashboard HTTP server — lightweight UI for phone-accessible coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
16
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
17
|
+
from starlette.staticfiles import StaticFiles
|
|
18
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
19
|
+
|
|
20
|
+
from coord import __version__
|
|
21
|
+
from coord.config import Config
|
|
22
|
+
from coord.dashboard.fixture import FixtureServer
|
|
23
|
+
from coord.dashboard.terminal import (
|
|
24
|
+
SessionAttacher,
|
|
25
|
+
TmuxSessionAttacher,
|
|
26
|
+
resolve_session_target,
|
|
27
|
+
)
|
|
28
|
+
from coord.dispatch import AGENT_PORT
|
|
29
|
+
from coord.events import (
|
|
30
|
+
ASSIGNMENT_COMPLETED,
|
|
31
|
+
ASSIGNMENT_FAILED,
|
|
32
|
+
BOARD_UPDATED,
|
|
33
|
+
EventSource,
|
|
34
|
+
build_events_route,
|
|
35
|
+
)
|
|
36
|
+
from coord.board_service import read_board, write_board
|
|
37
|
+
from coord.models import Assignment
|
|
38
|
+
from coord.network import check_all, fetch_status
|
|
39
|
+
from coord.openapi import build_spec, dataclass_schema, openapi_and_docs_routes
|
|
40
|
+
from coord.pipeline import PipelineView
|
|
41
|
+
from coord.state import load_proposals
|
|
42
|
+
|
|
43
|
+
DASHBOARD_DIR = Path(__file__).parent
|
|
44
|
+
# Built React webapp lives here after `npm run build` inside coord/dashboard/webapp/.
|
|
45
|
+
# When this directory is absent the server falls back to the legacy index.html so
|
|
46
|
+
# that existing behaviour (and the test suite) is completely unaffected.
|
|
47
|
+
WEBAPP_DIST = DASHBOARD_DIR / "webapp" / "dist"
|
|
48
|
+
|
|
49
|
+
# How often (seconds) the background poller queries agent servers.
|
|
50
|
+
_POLL_INTERVAL = 30.0
|
|
51
|
+
# How long (seconds) an assignment must be running with no agent record before
|
|
52
|
+
# it is flagged as possibly stuck.
|
|
53
|
+
_STUCK_THRESHOLD = 300.0 # 5 minutes
|
|
54
|
+
|
|
55
|
+
# #1217 fix iteration 1: api_sessions' per-machine tmux sweep timing knobs.
|
|
56
|
+
# A sweep taking at least this long looks like it hit the SSH ConnectTimeout
|
|
57
|
+
# (i.e. the machine is unreachable) rather than a normal fast tmux query.
|
|
58
|
+
_SESSIONS_SLOW_THRESHOLD = 3.0 # seconds; a healthy sweep is normally <1s
|
|
59
|
+
# Once a machine looks unreachable, skip re-probing it for this long — caps
|
|
60
|
+
# how often we pay the full SSH ConnectTimeout for a chronically down machine
|
|
61
|
+
# on every ~4s dashboard poll.
|
|
62
|
+
_SESSIONS_COOLDOWN = 20.0 # seconds before a down machine is re-probed
|
|
63
|
+
|
|
64
|
+
# #2066: PipelineView.current_stage values that represent genuinely finished
|
|
65
|
+
# work with no pending action — safe for api_pipeline's recency cutoff to age
|
|
66
|
+
# out. Every other current_stage ("coding", "review_running", "review_done",
|
|
67
|
+
# "smoke_running", "smoke_passed", "merge_ready", "merging") is live pipeline
|
|
68
|
+
# state — either still in progress or waiting on a human gate click — and
|
|
69
|
+
# must never be dropped by age alone, however stale its timestamps look.
|
|
70
|
+
# See coord/pipeline.py's compute_pipeline() for the full current_stage set.
|
|
71
|
+
_PIPELINE_QUIESCENT_STAGES = frozenset(
|
|
72
|
+
{"done", "merged", "failed", "review_failed", "smoke_failed"}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Bug 1 fix: distinct event type for cancelled assignments so they are not
|
|
76
|
+
# bucketed as FAILED on the client. Not yet in coord.events — defined here
|
|
77
|
+
# until a shared constants refactor can move it.
|
|
78
|
+
ASSIGNMENT_CANCELLED = "assignment_cancelled"
|
|
79
|
+
# #448: advisory (0-commit clean exit) is neither a green completion nor a
|
|
80
|
+
# red failure — it's a "needs attention" state. Route it to a distinct
|
|
81
|
+
# event so the dashboard can style it appropriately (warning, not failure).
|
|
82
|
+
ASSIGNMENT_ADVISORY = "assignment_advisory"
|
|
83
|
+
# #846: an assignment running past its wall-clock threshold, or thrashing
|
|
84
|
+
# through fix/review rounds without converging (coord.notify.attention_signal
|
|
85
|
+
# — same detection core as the coordinator's GitHub-comment backstop).
|
|
86
|
+
# Detection + surfacing only.
|
|
87
|
+
ASSIGNMENT_NEEDS_ATTENTION = "assignment_needs_attention"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fetch_agent_status(host: str, port: int = AGENT_PORT, timeout: float = 5.0) -> dict | None:
|
|
91
|
+
"""Synchronous agent /status fetch — safe to call from a thread executor."""
|
|
92
|
+
try:
|
|
93
|
+
resp = httpx.get(f"http://{host}:{port}/status", timeout=timeout)
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
return resp.json()
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _poll_once(
|
|
101
|
+
config: Config,
|
|
102
|
+
event_source: EventSource,
|
|
103
|
+
seen_terminal: set[str],
|
|
104
|
+
orphaned_since: dict[str, float],
|
|
105
|
+
*,
|
|
106
|
+
board=None,
|
|
107
|
+
now: float | None = None,
|
|
108
|
+
stuck_threshold: float = _STUCK_THRESHOLD,
|
|
109
|
+
needs_attention_seen: set[str] | None = None,
|
|
110
|
+
) -> list[dict]:
|
|
111
|
+
"""One iteration of the background agent poller.
|
|
112
|
+
|
|
113
|
+
Queries each machine's agent server, publishes ``assignment_completed`` /
|
|
114
|
+
``assignment_failed`` / ``assignment_cancelled`` SSE events on transitions,
|
|
115
|
+
and returns a list of possibly-stuck assignment info dicts.
|
|
116
|
+
|
|
117
|
+
Also publishes ``ASSIGNMENT_NEEDS_ATTENTION`` (#846) — a live counterpart
|
|
118
|
+
to the coordinator's GitHub-comment backstop — the first time a running
|
|
119
|
+
assignment trips the shared ``coord.notify.attention_signal`` wall-clock
|
|
120
|
+
/ non-convergence check. *needs_attention_seen* is caller-owned dedupe
|
|
121
|
+
state (mirrors *seen_terminal*) so the toast fires once per assignment,
|
|
122
|
+
not every poll interval; pass ``None`` to skip this check entirely (e.g.
|
|
123
|
+
from callers that don't track dedupe state, such as older tests).
|
|
124
|
+
|
|
125
|
+
Extracted to module level so unit tests can drive it directly without
|
|
126
|
+
standing up a full HTTP server.
|
|
127
|
+
"""
|
|
128
|
+
if board is None:
|
|
129
|
+
board = read_board()
|
|
130
|
+
if now is None:
|
|
131
|
+
now = time.time()
|
|
132
|
+
|
|
133
|
+
running = {
|
|
134
|
+
a.assignment_id: a
|
|
135
|
+
for a in board.active
|
|
136
|
+
if a.status == "running"
|
|
137
|
+
and a.assignment_id
|
|
138
|
+
and a.assignment_id not in seen_terminal
|
|
139
|
+
}
|
|
140
|
+
if not running:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
if needs_attention_seen is not None:
|
|
144
|
+
from coord.notify import attention_signal # noqa: PLC0415
|
|
145
|
+
|
|
146
|
+
for aid, assignment in running.items():
|
|
147
|
+
if aid in needs_attention_seen:
|
|
148
|
+
continue
|
|
149
|
+
reason, detail = attention_signal(
|
|
150
|
+
assignment_type=assignment.type,
|
|
151
|
+
status=assignment.status,
|
|
152
|
+
dispatched_at=assignment.dispatched_at,
|
|
153
|
+
review_iteration=assignment.review_iteration,
|
|
154
|
+
config=config,
|
|
155
|
+
now=now,
|
|
156
|
+
provider_name=assignment.provider_name,
|
|
157
|
+
review_of_assignment_id=assignment.review_of_assignment_id,
|
|
158
|
+
)
|
|
159
|
+
if reason is None:
|
|
160
|
+
continue
|
|
161
|
+
needs_attention_seen.add(aid)
|
|
162
|
+
event_source.publish(ASSIGNMENT_NEEDS_ATTENTION, {
|
|
163
|
+
"assignment_id": aid,
|
|
164
|
+
"repo_name": assignment.repo_name,
|
|
165
|
+
"issue_number": assignment.issue_number,
|
|
166
|
+
"issue_title": assignment.issue_title,
|
|
167
|
+
"machine_name": assignment.machine_name,
|
|
168
|
+
"reason": reason,
|
|
169
|
+
"detail": detail,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
machines_by_name = {m.name: m for m in config.machines}
|
|
173
|
+
needed_machines = {a.machine_name for a in running.values()}
|
|
174
|
+
|
|
175
|
+
loop = asyncio.get_running_loop()
|
|
176
|
+
agent_data: dict[str, dict] = {}
|
|
177
|
+
for mname in needed_machines:
|
|
178
|
+
machine = machines_by_name.get(mname)
|
|
179
|
+
if machine:
|
|
180
|
+
data = await loop.run_in_executor(
|
|
181
|
+
None, _fetch_agent_status, machine.host
|
|
182
|
+
)
|
|
183
|
+
if data:
|
|
184
|
+
agent_data[mname] = data
|
|
185
|
+
|
|
186
|
+
possibly_stuck: list[dict] = []
|
|
187
|
+
|
|
188
|
+
for aid, assignment in running.items():
|
|
189
|
+
mname = assignment.machine_name
|
|
190
|
+
data = agent_data.get(mname)
|
|
191
|
+
if data is None:
|
|
192
|
+
# Agent unreachable — don't flag as stuck yet.
|
|
193
|
+
orphaned_since.pop(aid, None)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
active_ids = {e.get("id") for e in data.get("active", []) if e.get("id")}
|
|
197
|
+
completed_by_id = {
|
|
198
|
+
e.get("id"): e
|
|
199
|
+
for e in data.get("completed", [])
|
|
200
|
+
if e.get("id")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if aid in active_ids:
|
|
204
|
+
# Still running — clear any orphaned flag.
|
|
205
|
+
orphaned_since.pop(aid, None)
|
|
206
|
+
elif aid in completed_by_id:
|
|
207
|
+
# Terminal transition detected.
|
|
208
|
+
seen_terminal.add(aid)
|
|
209
|
+
orphaned_since.pop(aid, None)
|
|
210
|
+
entry = completed_by_id[aid]
|
|
211
|
+
stats: dict = {}
|
|
212
|
+
for k in ("num_turns", "total_cost_usd", "exit_code", "last_tool", "stop_reason"):
|
|
213
|
+
v = entry.get(k)
|
|
214
|
+
if v is not None:
|
|
215
|
+
stats[k] = v
|
|
216
|
+
payload = {
|
|
217
|
+
"assignment_id": aid,
|
|
218
|
+
"repo_name": assignment.repo_name,
|
|
219
|
+
"issue_number": assignment.issue_number,
|
|
220
|
+
"issue_title": assignment.issue_title,
|
|
221
|
+
"machine_name": mname,
|
|
222
|
+
"stats": stats,
|
|
223
|
+
"status": entry.get("status"), # attached so client can inspect
|
|
224
|
+
}
|
|
225
|
+
status = entry.get("status")
|
|
226
|
+
# Bug 1 fix: three-way branch — cancelled must not fire FAILED.
|
|
227
|
+
# #448: advisory routes to a distinct event so the dashboard does
|
|
228
|
+
# not paint a 0-commit clean exit as a failure.
|
|
229
|
+
if status == "done":
|
|
230
|
+
event_source.publish(ASSIGNMENT_COMPLETED, payload)
|
|
231
|
+
elif status == "cancelled":
|
|
232
|
+
event_source.publish(ASSIGNMENT_CANCELLED, payload)
|
|
233
|
+
elif status == "advisory":
|
|
234
|
+
payload["zero_commit_reason"] = entry.get("zero_commit_reason")
|
|
235
|
+
event_source.publish(ASSIGNMENT_ADVISORY, payload)
|
|
236
|
+
else: # "failed" and any other unexpected terminal status
|
|
237
|
+
payload["exit_code"] = entry.get("exit_code")
|
|
238
|
+
event_source.publish(ASSIGNMENT_FAILED, payload)
|
|
239
|
+
else:
|
|
240
|
+
# Not in active OR completed on the agent.
|
|
241
|
+
dispatched_ago = now - (assignment.dispatched_at or 0)
|
|
242
|
+
if dispatched_ago > stuck_threshold:
|
|
243
|
+
if aid not in orphaned_since:
|
|
244
|
+
orphaned_since[aid] = now
|
|
245
|
+
possibly_stuck.append({
|
|
246
|
+
"assignment_id": aid,
|
|
247
|
+
"repo_name": assignment.repo_name,
|
|
248
|
+
"issue_number": assignment.issue_number,
|
|
249
|
+
"machine_name": mname,
|
|
250
|
+
"dispatched_ago_seconds": int(dispatched_ago),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
# Prune orphaned_since entries that are no longer in the running set.
|
|
254
|
+
for aid in list(orphaned_since):
|
|
255
|
+
if aid not in running:
|
|
256
|
+
del orphaned_since[aid]
|
|
257
|
+
|
|
258
|
+
if needs_attention_seen is not None:
|
|
259
|
+
for aid in list(needs_attention_seen):
|
|
260
|
+
if aid not in running:
|
|
261
|
+
needs_attention_seen.discard(aid)
|
|
262
|
+
|
|
263
|
+
return possibly_stuck
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def openapi_spec() -> dict:
|
|
267
|
+
"""#757: the dashboard's OpenAPI 3 document.
|
|
268
|
+
|
|
269
|
+
``GET /api/board`` and ``GET /api/pipeline`` are fully specified via
|
|
270
|
+
:func:`coord.openapi.dataclass_schema` over ``coord.models.Assignment`` /
|
|
271
|
+
``coord.pipeline.PipelineView``. #1550's TS codegen (``scripts/codegen.py``)
|
|
272
|
+
reads its ``components/schemas`` straight from *this* spec — not from the
|
|
273
|
+
dataclasses directly — so the generated TS types describe exactly what the
|
|
274
|
+
server declares it serves, and the existing ``declared_routes(app.routes)
|
|
275
|
+
== spec_routes(spec)`` test (``tests/test_openapi.py``) transitively
|
|
276
|
+
guarantees they can't drift from the real route table either. The
|
|
277
|
+
action-style ``POST /api/pipeline/action`` endpoint documents its
|
|
278
|
+
``action`` enum but leaves the response loosely typed since each action
|
|
279
|
+
returns a distinct ad-hoc shape.
|
|
280
|
+
|
|
281
|
+
Public (no leading underscore) because ``scripts/codegen.py`` imports it
|
|
282
|
+
as its source of truth; ``coord/agent_app.py`` and ``coord/serve_app.py``
|
|
283
|
+
keep their own ``_openapi_spec()`` private since nothing outside those
|
|
284
|
+
modules consumes them (yet).
|
|
285
|
+
"""
|
|
286
|
+
components: dict = {}
|
|
287
|
+
assignment_ref = dataclass_schema(Assignment, components)
|
|
288
|
+
pipeline_view_ref = dataclass_schema(PipelineView, components)
|
|
289
|
+
board_response = {
|
|
290
|
+
"type": "object",
|
|
291
|
+
"properties": {
|
|
292
|
+
"round_number": {"type": "integer"},
|
|
293
|
+
"active": {"type": "array", "items": assignment_ref},
|
|
294
|
+
"completed": {"type": "array", "items": assignment_ref},
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
ok_response = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
|
|
298
|
+
session_response = {
|
|
299
|
+
"type": "object",
|
|
300
|
+
"properties": {
|
|
301
|
+
"session_id": {"type": "string", "description": "assignment_id (== the /ws/terminal/{session_id} path param)"},
|
|
302
|
+
"session_name": {"type": "string", "description": "the tmux session name, coord-<session_id>"},
|
|
303
|
+
"machine": {"type": ["string", "null"], "description": "machine name from coordinator.yml"},
|
|
304
|
+
"host": {"type": ["string", "null"], "description": "the machine's Tailscale host"},
|
|
305
|
+
"repo": {"type": ["string", "null"]},
|
|
306
|
+
"issue": {"type": ["integer", "null"]},
|
|
307
|
+
"issue_title": {"type": ["string", "null"]},
|
|
308
|
+
"stage": {"type": ["string", "null"], "description": "assignment type — work/review/smoke/fix/plan/merge/..."},
|
|
309
|
+
"status": {"type": ["string", "null"], "description": "assignment status — running/done/failed/advisory/..."},
|
|
310
|
+
"attached": {"type": "boolean", "description": "is a client currently attached to the tmux session"},
|
|
311
|
+
"pane_dead": {"type": "boolean", "description": "claude has exited but the tmux session is still up"},
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
paths = {
|
|
315
|
+
"/": {
|
|
316
|
+
"get": {
|
|
317
|
+
"summary": "Dashboard SPA (or legacy single-file UI) index page",
|
|
318
|
+
"responses": {"200": {"description": "text/html"}},
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"/api/board": {
|
|
322
|
+
"get": {
|
|
323
|
+
"summary": "Recent board state: active assignments + last 20 completed",
|
|
324
|
+
"responses": {
|
|
325
|
+
"200": {
|
|
326
|
+
"description": "OK",
|
|
327
|
+
"content": {"application/json": {"schema": board_response}},
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
"/api/machines": {
|
|
333
|
+
"get": {
|
|
334
|
+
"summary": "Machine reachability + live agent /status per machine",
|
|
335
|
+
"responses": {"200": {"description": "OK"}},
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
"/api/proposals": {
|
|
339
|
+
"get": {
|
|
340
|
+
"summary": "Pending brain proposals awaiting approve/reject",
|
|
341
|
+
"responses": {"200": {"description": "OK"}},
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
"/api/approve": {
|
|
345
|
+
"post": {
|
|
346
|
+
"summary": "Dispatch one or more proposals by id",
|
|
347
|
+
"requestBody": {
|
|
348
|
+
"required": True,
|
|
349
|
+
"content": {
|
|
350
|
+
"application/json": {
|
|
351
|
+
"schema": {
|
|
352
|
+
"type": "object",
|
|
353
|
+
"properties": {
|
|
354
|
+
"ids": {"type": "array", "items": {"type": "integer"}},
|
|
355
|
+
"briefings": {"type": "object"},
|
|
356
|
+
},
|
|
357
|
+
"required": ["ids"],
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
"responses": {
|
|
363
|
+
"200": {"description": "OK"},
|
|
364
|
+
"400": {"description": "ids must be a non-empty list"},
|
|
365
|
+
"404": {"description": "No matching proposals"},
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
"/api/reject": {
|
|
370
|
+
"post": {
|
|
371
|
+
"summary": "Discard one or more proposals by id",
|
|
372
|
+
"requestBody": {
|
|
373
|
+
"required": True,
|
|
374
|
+
"content": {
|
|
375
|
+
"application/json": {
|
|
376
|
+
"schema": {
|
|
377
|
+
"type": "object",
|
|
378
|
+
"properties": {
|
|
379
|
+
"ids": {"type": "array", "items": {"type": "integer"}},
|
|
380
|
+
},
|
|
381
|
+
"required": ["ids"],
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
"responses": {
|
|
387
|
+
"200": {"description": "OK"},
|
|
388
|
+
"400": {"description": "ids must be a non-empty list"},
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
"/api/diff/{id}": {
|
|
393
|
+
"get": {
|
|
394
|
+
"summary": "PR/branch diff for an assignment (gh pr diff, falls back to compare)",
|
|
395
|
+
"parameters": [_dashboard_path_param("id", "assignment id")],
|
|
396
|
+
"responses": {
|
|
397
|
+
"200": {"description": "OK"},
|
|
398
|
+
"404": {"description": "Assignment/branch/repo not found"},
|
|
399
|
+
"500": {"description": "gh lookup failed"},
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
"/api/chat": {
|
|
404
|
+
"post": {
|
|
405
|
+
"summary": "Stream a chat reply about current board state (SSE)",
|
|
406
|
+
"requestBody": {
|
|
407
|
+
"required": True,
|
|
408
|
+
"content": {
|
|
409
|
+
"application/json": {
|
|
410
|
+
"schema": {
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {"message": {"type": "string"}},
|
|
413
|
+
"required": ["message"],
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
"responses": {
|
|
419
|
+
"200": {"description": "text/event-stream"},
|
|
420
|
+
"400": {"description": "message required / unsupported provider"},
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
"/api/sessions": {
|
|
425
|
+
"get": {
|
|
426
|
+
"summary": (
|
|
427
|
+
"Live coord-* interactive tmux sessions the phone can attach "
|
|
428
|
+
"to via GET /ws/terminal/{session_id} (#1066)"
|
|
429
|
+
),
|
|
430
|
+
"responses": {
|
|
431
|
+
"200": {
|
|
432
|
+
"description": "OK",
|
|
433
|
+
"content": {
|
|
434
|
+
"application/json": {
|
|
435
|
+
"schema": {"type": "array", "items": session_response}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
"/api/pipeline": {
|
|
443
|
+
"get": {
|
|
444
|
+
"summary": "PipelineView for every type='work' assignment",
|
|
445
|
+
"description": (
|
|
446
|
+
"#2066: bounded by default to active work plus terminal work "
|
|
447
|
+
"finished within COORD_BOARD_RETENTION_DAYS (default 14). Pass "
|
|
448
|
+
"?include=all to get the full, unbounded history. Sorted "
|
|
449
|
+
"newest-first by finished_at (falling back to dispatched_at)."
|
|
450
|
+
),
|
|
451
|
+
"parameters": [
|
|
452
|
+
{
|
|
453
|
+
"name": "include",
|
|
454
|
+
"in": "query",
|
|
455
|
+
"required": False,
|
|
456
|
+
"schema": {"type": "string", "enum": ["all"]},
|
|
457
|
+
"description": "Pass 'all' to bypass the default recency window.",
|
|
458
|
+
}
|
|
459
|
+
],
|
|
460
|
+
"responses": {
|
|
461
|
+
"200": {
|
|
462
|
+
"description": "OK",
|
|
463
|
+
"content": {
|
|
464
|
+
"application/json": {
|
|
465
|
+
"schema": {"type": "array", "items": pipeline_view_ref}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
"/api/pipeline/action": {
|
|
473
|
+
"post": {
|
|
474
|
+
"summary": "Advance an assignment through a pipeline gate",
|
|
475
|
+
"requestBody": {
|
|
476
|
+
"required": True,
|
|
477
|
+
"content": {
|
|
478
|
+
"application/json": {
|
|
479
|
+
"schema": {
|
|
480
|
+
"type": "object",
|
|
481
|
+
"properties": {
|
|
482
|
+
"assignment_id": {"type": "string"},
|
|
483
|
+
"action": {
|
|
484
|
+
"type": "string",
|
|
485
|
+
"enum": [
|
|
486
|
+
"dispatch_review", "dispatch_smoke", "enqueue",
|
|
487
|
+
"merge", "post_findings", "unstick",
|
|
488
|
+
"test-verdict", "record-review-verdict",
|
|
489
|
+
"retry", "dispatch_fix",
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
"required": ["assignment_id", "action"],
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
"responses": {
|
|
499
|
+
"200": {
|
|
500
|
+
"description": "OK",
|
|
501
|
+
"content": {"application/json": {"schema": ok_response}},
|
|
502
|
+
},
|
|
503
|
+
"400": {"description": "Missing/unknown field"},
|
|
504
|
+
"404": {"description": "Assignment not found"},
|
|
505
|
+
"501": {"description": "Action not yet implemented"},
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
"/events": {
|
|
510
|
+
"get": {
|
|
511
|
+
"summary": "Server-sent-event stream of board/assignment events",
|
|
512
|
+
"responses": {"200": {"description": "text/event-stream"}},
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
return build_spec(
|
|
517
|
+
title="coord dashboard",
|
|
518
|
+
version=__version__,
|
|
519
|
+
description="Phone-accessible coordination dashboard (React webapp + legacy single-file UI).",
|
|
520
|
+
paths=paths,
|
|
521
|
+
components=components,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _dashboard_path_param(name: str, description: str = "") -> dict:
|
|
526
|
+
return {
|
|
527
|
+
"name": name,
|
|
528
|
+
"in": "path",
|
|
529
|
+
"required": True,
|
|
530
|
+
"schema": {"type": "string"},
|
|
531
|
+
"description": description,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def build_app(
|
|
536
|
+
config: Config,
|
|
537
|
+
*,
|
|
538
|
+
token: str | None = None,
|
|
539
|
+
session_attacher: SessionAttacher | None = None,
|
|
540
|
+
fixture: FixtureServer | None = None,
|
|
541
|
+
dist_path: Path | None = None,
|
|
542
|
+
) -> Starlette:
|
|
543
|
+
"""Build the dashboard Starlette app bound to a Config.
|
|
544
|
+
|
|
545
|
+
``token``: the ``/ws/terminal/{session_id}`` bridge's bearer token
|
|
546
|
+
(see :func:`coord.dashboard.terminal.resolve_web_token`). ``None`` means
|
|
547
|
+
the endpoint runs open (dev default) -- fine on a tailnet-only box, but
|
|
548
|
+
the production dashboard should set one, same convention as `coord
|
|
549
|
+
serve`'s ``resolve_serve_token``.
|
|
550
|
+
|
|
551
|
+
``session_attacher``: injectable seam for the ssh/tmux PTY spawn behind
|
|
552
|
+
the terminal bridge (#1065 acceptance) -- defaults to the real
|
|
553
|
+
:class:`~coord.dashboard.terminal.TmuxSessionAttacher`; tests pass a fake.
|
|
554
|
+
|
|
555
|
+
``fixture`` (#1538): a :class:`~coord.dashboard.fixture.FixtureServer`
|
|
556
|
+
puts the app in **seeded-board mode** — every read is answered from the
|
|
557
|
+
fixture instead of ``~/.coord/coord.db`` / the fleet, and every write is
|
|
558
|
+
recorded rather than executed. The routes, handlers and serialization are
|
|
559
|
+
unchanged; only the data source is swapped, so an acceptance suite built
|
|
560
|
+
on this is still testing the real contract. ``None`` (the default) is the
|
|
561
|
+
ordinary live dashboard, byte-for-byte as before.
|
|
562
|
+
|
|
563
|
+
``dist_path`` (#1543): override where the built React webapp is read
|
|
564
|
+
from — ``coord web --dist PATH`` / ``$COORD_WEB_DIST``. Lets the bundle
|
|
565
|
+
be served from outside the installed package (e.g. a checkout a build
|
|
566
|
+
hook keeps in sync with merged ``main``) so a webapp change goes live
|
|
567
|
+
without upgrading ``~/.coord-venv`` — see docs/PHONE_WEBAPP.md. ``None``
|
|
568
|
+
(the default) keeps the historical behaviour of serving
|
|
569
|
+
``coord/dashboard/webapp/dist`` from inside the installed package.
|
|
570
|
+
"""
|
|
571
|
+
attacher: SessionAttacher = session_attacher or TmuxSessionAttacher()
|
|
572
|
+
_fixture = fixture
|
|
573
|
+
# Resolved once per app build. WEBAPP_DIST is read as a module global
|
|
574
|
+
# (not captured as a default arg) so tests that
|
|
575
|
+
# `patch("coord.dashboard.server.WEBAPP_DIST", ...)` keep working when
|
|
576
|
+
# dist_path is left unset (the CLI default).
|
|
577
|
+
webapp_dist = Path(dist_path) if dist_path is not None else WEBAPP_DIST
|
|
578
|
+
|
|
579
|
+
def _read_board():
|
|
580
|
+
"""The board for this request — seeded fixture or the live DB/daemon.
|
|
581
|
+
|
|
582
|
+
Fixture mode rebuilds the Board from the raw payload on every call, so
|
|
583
|
+
a handler that mutates what it is handed (``unstick`` →
|
|
584
|
+
``mark_failed_by_id``) can't leak that into the next request.
|
|
585
|
+
"""
|
|
586
|
+
if _fixture is not None:
|
|
587
|
+
return _fixture.board()
|
|
588
|
+
return read_board()
|
|
589
|
+
|
|
590
|
+
def _write_board(board) -> None: # noqa: ANN001
|
|
591
|
+
"""Persist *board* — a no-op in fixture mode (writes never execute)."""
|
|
592
|
+
if _fixture is not None:
|
|
593
|
+
return
|
|
594
|
+
write_board(board)
|
|
595
|
+
|
|
596
|
+
# ── Real-time event bus ────────────────────────────────────────────────
|
|
597
|
+
event_source = EventSource()
|
|
598
|
+
|
|
599
|
+
# Assignments whose terminal transition has already been published via SSE
|
|
600
|
+
# so that repeated polls don't re-fire the same toast.
|
|
601
|
+
_seen_terminal: set[str] = set()
|
|
602
|
+
# assignment_id → timestamp when we first noticed it orphaned.
|
|
603
|
+
_orphaned_since: dict[str, float] = {}
|
|
604
|
+
# #846: assignment_ids that already fired an ASSIGNMENT_NEEDS_ATTENTION
|
|
605
|
+
# toast, so the live poller doesn't re-publish every _POLL_INTERVAL.
|
|
606
|
+
_needs_attention_seen: set[str] = set()
|
|
607
|
+
|
|
608
|
+
# #1217 fix iteration 1: api_sessions' fleet tmux sweep gets its OWN bounded
|
|
609
|
+
# executor rather than sharing the asyncio loop's default executor (which
|
|
610
|
+
# `loop.run_in_executor(None, ...)` submits to). The Home.tsx phone client
|
|
611
|
+
# polls /api/sessions every 4s starting the instant the page loads; each
|
|
612
|
+
# poll fans out one blocking subprocess call per configured machine
|
|
613
|
+
# (bounded at 5s inside `list_coord_tmux_sessions`, batch-mode SSH
|
|
614
|
+
# ConnectTimeout=4). A machine that's down takes the full ~4-5s on EVERY
|
|
615
|
+
# poll, and 4s < 5s means a new sweep task for that machine can be
|
|
616
|
+
# submitted before the previous one finishes — so tasks for a chronically
|
|
617
|
+
# unreachable machine back up faster than they drain. On the shared
|
|
618
|
+
# default executor that backlog eventually starves every other consumer
|
|
619
|
+
# of `run_in_executor(None, ...)` in the process (the background agent
|
|
620
|
+
# poller, the terminal WS PTY-read loop, ...), which is exactly the
|
|
621
|
+
# dashboard-wide hang the operator hit. A dedicated executor contains the
|
|
622
|
+
# backlog to this endpoint; the offline-cooldown cache below stops the
|
|
623
|
+
# backlog from growing in the first place.
|
|
624
|
+
_sessions_executor = ThreadPoolExecutor(
|
|
625
|
+
max_workers=max(4, len(config.machines) * 4),
|
|
626
|
+
thread_name_prefix="coord-sessions-sweep",
|
|
627
|
+
)
|
|
628
|
+
# machine name -> monotonic timestamp of the last sweep that looked like a
|
|
629
|
+
# timeout/unreachable host (took close to the 5s subprocess cap). While a
|
|
630
|
+
# machine is within cooldown, skip spawning a new sweep thread for it
|
|
631
|
+
# entirely and report "no sessions" immediately, instead of re-paying the
|
|
632
|
+
# full SSH ConnectTimeout on every ~4s dashboard poll.
|
|
633
|
+
_sessions_offline_since: dict[str, float] = {}
|
|
634
|
+
|
|
635
|
+
async def _background_poller() -> None:
|
|
636
|
+
"""Runs forever; polls agents every _POLL_INTERVAL seconds."""
|
|
637
|
+
await asyncio.sleep(10) # Short initial delay so the server is ready
|
|
638
|
+
while True:
|
|
639
|
+
try:
|
|
640
|
+
possibly_stuck = await _poll_once(
|
|
641
|
+
config, event_source, _seen_terminal, _orphaned_since,
|
|
642
|
+
needs_attention_seen=_needs_attention_seen,
|
|
643
|
+
)
|
|
644
|
+
event_source.publish(BOARD_UPDATED, {
|
|
645
|
+
"possibly_stuck": possibly_stuck,
|
|
646
|
+
"timestamp": time.time(),
|
|
647
|
+
})
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
651
|
+
|
|
652
|
+
async def _play_event_script() -> None:
|
|
653
|
+
"""Publish the fixture's scripted SSE sequence once (#1538).
|
|
654
|
+
|
|
655
|
+
Each entry's ``after`` is a delay relative to the previous one, so the
|
|
656
|
+
fixture reads as a timeline. This is what makes live-update behaviour
|
|
657
|
+
testable: the acceptance suite subscribes to ``/events`` and then hits
|
|
658
|
+
``POST /api/fixture/events/replay`` to run the script deterministically
|
|
659
|
+
instead of racing the server's startup — which is why the script does
|
|
660
|
+
NOT play at startup unless the fixture opts in with
|
|
661
|
+
``autoplay_events``.
|
|
662
|
+
"""
|
|
663
|
+
for scripted in _fixture.events:
|
|
664
|
+
if scripted.after:
|
|
665
|
+
await asyncio.sleep(scripted.after)
|
|
666
|
+
event_source.publish(scripted.type, scripted.data)
|
|
667
|
+
|
|
668
|
+
from contextlib import asynccontextmanager
|
|
669
|
+
|
|
670
|
+
@asynccontextmanager
|
|
671
|
+
async def _lifespan(app): # noqa: ANN001
|
|
672
|
+
# Fixture mode never polls the fleet (no network, no money); the
|
|
673
|
+
# scripted event sequence takes the background poller's place, and
|
|
674
|
+
# only auto-plays when the fixture explicitly asks for it.
|
|
675
|
+
if _fixture is None:
|
|
676
|
+
asyncio.create_task(_background_poller())
|
|
677
|
+
elif _fixture.events and _fixture.autoplay_events:
|
|
678
|
+
asyncio.create_task(_play_event_script())
|
|
679
|
+
yield
|
|
680
|
+
_sessions_executor.shutdown(wait=False, cancel_futures=True)
|
|
681
|
+
|
|
682
|
+
async def index(request: Request) -> HTMLResponse:
|
|
683
|
+
# Serve the built React webapp when available; fall back to the legacy
|
|
684
|
+
# single-file dashboard so existing behaviour is entirely unchanged.
|
|
685
|
+
spa_index = webapp_dist / "index.html"
|
|
686
|
+
html = spa_index.read_text() if spa_index.exists() else (DASHBOARD_DIR / "index.html").read_text()
|
|
687
|
+
return HTMLResponse(html)
|
|
688
|
+
|
|
689
|
+
async def api_board(request: Request) -> JSONResponse:
|
|
690
|
+
board = _read_board()
|
|
691
|
+
from dataclasses import asdict
|
|
692
|
+
return JSONResponse({
|
|
693
|
+
"round_number": board.round_number,
|
|
694
|
+
"active": [asdict(a) for a in board.active],
|
|
695
|
+
"completed": [asdict(a) for a in board.completed[-20:]],
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
async def api_machines(request: Request) -> JSONResponse:
|
|
699
|
+
if _fixture is not None:
|
|
700
|
+
# Seeded reachability — never probe the fleet in fixture mode.
|
|
701
|
+
return JSONResponse(_fixture.machines())
|
|
702
|
+
statuses = check_all(config.machines, timeout=3.0)
|
|
703
|
+
result = []
|
|
704
|
+
for s in statuses:
|
|
705
|
+
machine_data = {
|
|
706
|
+
"name": s.machine.name,
|
|
707
|
+
"host": s.machine.host,
|
|
708
|
+
"repos": s.machine.repos,
|
|
709
|
+
"state": s.state,
|
|
710
|
+
"reason": s.reason,
|
|
711
|
+
"latency_ms": s.latency_ms,
|
|
712
|
+
}
|
|
713
|
+
if s.is_online:
|
|
714
|
+
status_result = fetch_status(s.machine, timeout=3.0)
|
|
715
|
+
if status_result.ok:
|
|
716
|
+
machine_data["assignments"] = status_result.data
|
|
717
|
+
else:
|
|
718
|
+
machine_data["assignments"] = None
|
|
719
|
+
machine_data["status_error"] = status_result.error
|
|
720
|
+
result.append(machine_data)
|
|
721
|
+
return JSONResponse(result)
|
|
722
|
+
|
|
723
|
+
async def api_sessions(request: Request) -> JSONResponse:
|
|
724
|
+
"""GET /api/sessions — live coord-* interactive sessions the phone can
|
|
725
|
+
attach to via GET /ws/terminal/{session_id} (#1066).
|
|
726
|
+
|
|
727
|
+
Sources the roster from the same fleet session substrate `coord
|
|
728
|
+
sessions` itself reads — :func:`coord.interactive.list_coord_tmux_sessions`
|
|
729
|
+
(milestone #32 / substrate #28) — rather than inventing a parallel tmux
|
|
730
|
+
discovery path, then enriches each session against the board, the same
|
|
731
|
+
source :func:`~coord.dashboard.terminal.resolve_session_target` (#1065)
|
|
732
|
+
uses to route the actual WS attach, so the two paths can't drift.
|
|
733
|
+
|
|
734
|
+
Fleet-wide (#1217): sweeps *every* configured machine, not just the
|
|
735
|
+
local host `coord web` happens to run on — reusing the exact pattern
|
|
736
|
+
`coord sessions --remote` already proves
|
|
737
|
+
(``list_coord_tmux_sessions(host=TmuxHost(ssh_target=machine.host,
|
|
738
|
+
batch=True))`` per machine; ``batch=True`` so a down/unreachable host
|
|
739
|
+
fails fast instead of hanging on an ssh passphrase prompt). The
|
|
740
|
+
dashboard host itself is probed with ``TmuxHost(None)`` (a plain local
|
|
741
|
+
tmux call, no ssh round-trip to itself). Per-host sweeps run
|
|
742
|
+
concurrently via ``asyncio.gather`` over ``run_in_executor`` calls, so
|
|
743
|
+
wall-clock is bounded by the slowest single host's timeout, not the
|
|
744
|
+
sum across the fleet — important since each host sweep already has
|
|
745
|
+
its own 5s cap inside ``list_coord_tmux_sessions`` and this endpoint
|
|
746
|
+
is polled every ~4s from the phone. On a session-name collision across
|
|
747
|
+
hosts (shouldn't happen in practice — session names embed the
|
|
748
|
+
assignment id) the local host wins, mirroring `coord sessions
|
|
749
|
+
--remote`'s "local always wins" rule.
|
|
750
|
+
|
|
751
|
+
Each session is tagged with the machine it was actually discovered
|
|
752
|
+
on: the board assignment's `machine_name` when the session matches
|
|
753
|
+
one (the common case), falling back to the sweep's source machine for
|
|
754
|
+
orphaned/unmatched sessions (`coord terminal new` panes, stale
|
|
755
|
+
sessions with no board row) — previously these reported `machine:
|
|
756
|
+
null` even though the sweep knew exactly which host they came from.
|
|
757
|
+
|
|
758
|
+
tmux discovery shells out (bounded by a 5s timeout inside
|
|
759
|
+
``list_coord_tmux_sessions``) so each host's sweep runs off the event
|
|
760
|
+
loop thread via ``run_in_executor`` — but on a **dedicated** executor
|
|
761
|
+
(``_sessions_executor``, sized to the fleet) rather than the shared
|
|
762
|
+
default one, and a machine that recently looked unreachable is
|
|
763
|
+
skipped for a cooldown window instead of being re-probed every ~4s
|
|
764
|
+
(see the comment above ``_sessions_executor``'s definition for why:
|
|
765
|
+
#1217 iteration 1 fixed a dashboard-wide hang caused by exactly this
|
|
766
|
+
fan-out saturating the process's shared default executor).
|
|
767
|
+
"""
|
|
768
|
+
if _fixture is not None:
|
|
769
|
+
# Seeded roster — no tmux, no ssh fan-out in fixture mode.
|
|
770
|
+
return JSONResponse(_fixture.sessions())
|
|
771
|
+
|
|
772
|
+
from coord.interactive import (
|
|
773
|
+
TMUX_SESSION_PREFIX,
|
|
774
|
+
TmuxHost,
|
|
775
|
+
_get_local_short_hostname,
|
|
776
|
+
list_coord_tmux_sessions,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
loop = asyncio.get_running_loop()
|
|
780
|
+
local_hn = _get_local_short_hostname()
|
|
781
|
+
|
|
782
|
+
def _is_local_machine(machine) -> bool:
|
|
783
|
+
return (
|
|
784
|
+
machine.name.lower() == local_hn
|
|
785
|
+
or machine.host.split(".")[0].lower() == local_hn
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
def _sweep_one(machine):
|
|
789
|
+
is_local = _is_local_machine(machine)
|
|
790
|
+
host = (
|
|
791
|
+
TmuxHost(None)
|
|
792
|
+
if is_local
|
|
793
|
+
else TmuxHost(ssh_target=machine.host, batch=True)
|
|
794
|
+
)
|
|
795
|
+
start = time.monotonic()
|
|
796
|
+
try:
|
|
797
|
+
found = list_coord_tmux_sessions(host=host)
|
|
798
|
+
except Exception: # noqa: BLE001 — a down/unreachable machine just contributes nothing
|
|
799
|
+
found = []
|
|
800
|
+
elapsed = time.monotonic() - start
|
|
801
|
+
# Only track cooldown for remote machines — the local sweep never
|
|
802
|
+
# goes over SSH and a slow local tmux call shouldn't suppress it.
|
|
803
|
+
if not is_local:
|
|
804
|
+
if elapsed >= _SESSIONS_SLOW_THRESHOLD:
|
|
805
|
+
_sessions_offline_since[machine.name] = time.monotonic()
|
|
806
|
+
else:
|
|
807
|
+
_sessions_offline_since.pop(machine.name, None)
|
|
808
|
+
return machine, found, is_local
|
|
809
|
+
|
|
810
|
+
async def _cached_empty(machine, is_local):
|
|
811
|
+
return machine, [], is_local
|
|
812
|
+
|
|
813
|
+
tasks = []
|
|
814
|
+
for m in config.machines:
|
|
815
|
+
is_local = _is_local_machine(m)
|
|
816
|
+
since = _sessions_offline_since.get(m.name)
|
|
817
|
+
if (
|
|
818
|
+
not is_local
|
|
819
|
+
and since is not None
|
|
820
|
+
and (time.monotonic() - since) < _SESSIONS_COOLDOWN
|
|
821
|
+
):
|
|
822
|
+
tasks.append(_cached_empty(m, is_local))
|
|
823
|
+
else:
|
|
824
|
+
tasks.append(loop.run_in_executor(_sessions_executor, _sweep_one, m))
|
|
825
|
+
|
|
826
|
+
sweeps = await asyncio.gather(*tasks)
|
|
827
|
+
# Local host(s) first so they win any session-name collision, matching
|
|
828
|
+
# `coord sessions --remote`'s "local always wins" rule. Stable sort
|
|
829
|
+
# preserves config.machines order within each group.
|
|
830
|
+
sweeps = sorted(sweeps, key=lambda t: not t[2])
|
|
831
|
+
|
|
832
|
+
board = _read_board()
|
|
833
|
+
assignments_by_id = {
|
|
834
|
+
a.assignment_id: a
|
|
835
|
+
for a in (*board.active, *board.completed)
|
|
836
|
+
if a.assignment_id
|
|
837
|
+
}
|
|
838
|
+
machines_by_name = {m.name: m for m in config.machines}
|
|
839
|
+
|
|
840
|
+
sessions = []
|
|
841
|
+
seen_names: set[str] = set()
|
|
842
|
+
for source_machine, raw_sessions, _is_local in sweeps:
|
|
843
|
+
for s in raw_sessions:
|
|
844
|
+
session_name = s.get("session_name", "")
|
|
845
|
+
if session_name in seen_names:
|
|
846
|
+
continue
|
|
847
|
+
seen_names.add(session_name)
|
|
848
|
+
session_id = session_name[len(TMUX_SESSION_PREFIX):]
|
|
849
|
+
assignment = assignments_by_id.get(session_id)
|
|
850
|
+
machine_name = (
|
|
851
|
+
assignment.machine_name if assignment else source_machine.name
|
|
852
|
+
)
|
|
853
|
+
machine_cfg = machines_by_name.get(machine_name)
|
|
854
|
+
sessions.append({
|
|
855
|
+
"session_id": session_id,
|
|
856
|
+
"session_name": session_name,
|
|
857
|
+
"machine": machine_name,
|
|
858
|
+
"host": machine_cfg.host if machine_cfg else source_machine.host,
|
|
859
|
+
"repo": assignment.repo_name if assignment else None,
|
|
860
|
+
"issue": assignment.issue_number if assignment else None,
|
|
861
|
+
"issue_title": assignment.issue_title if assignment else None,
|
|
862
|
+
"stage": assignment.type if assignment else None,
|
|
863
|
+
"status": assignment.status if assignment else None,
|
|
864
|
+
"attached": bool(s.get("attached", False)),
|
|
865
|
+
"pane_dead": s.get("pane_dead") == "1",
|
|
866
|
+
})
|
|
867
|
+
return JSONResponse(sessions)
|
|
868
|
+
|
|
869
|
+
async def api_proposals(request: Request) -> JSONResponse:
|
|
870
|
+
proposals = _fixture.proposals() if _fixture is not None else load_proposals()
|
|
871
|
+
from dataclasses import asdict
|
|
872
|
+
return JSONResponse([asdict(p) for p in proposals])
|
|
873
|
+
|
|
874
|
+
async def api_approve(request: Request) -> JSONResponse:
|
|
875
|
+
from coord.dispatch import dispatch, post_briefing, compute_do_not_touch
|
|
876
|
+
from coord.state import (
|
|
877
|
+
clear_proposals, load_dispatched, load_proposals as load_p,
|
|
878
|
+
record_dispatched,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
try:
|
|
882
|
+
body = await request.json()
|
|
883
|
+
except ValueError:
|
|
884
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
885
|
+
|
|
886
|
+
ids = body.get("ids", [])
|
|
887
|
+
if not ids or not isinstance(ids, list):
|
|
888
|
+
return JSONResponse({"error": "ids must be a non-empty list"}, status_code=400)
|
|
889
|
+
|
|
890
|
+
briefing_overrides = body.get("briefings", {})
|
|
891
|
+
|
|
892
|
+
if _fixture is not None:
|
|
893
|
+
# Recorded, not executed — the shape below matches the live path's
|
|
894
|
+
# per-proposal results array so the client can't tell the
|
|
895
|
+
# difference, but nothing is dispatched and no money is spent.
|
|
896
|
+
selected = [p for p in _fixture.proposals() if p.id in ids]
|
|
897
|
+
if not selected:
|
|
898
|
+
return JSONResponse({"error": "no matching proposals"}, status_code=404)
|
|
899
|
+
_fixture.record("/api/approve", body, action="approve")
|
|
900
|
+
return JSONResponse({
|
|
901
|
+
"results": [
|
|
902
|
+
{"id": p.id, "assignment_id": f"fixture-{p.id}", "ok": True}
|
|
903
|
+
for p in selected
|
|
904
|
+
]
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
proposals = load_p()
|
|
908
|
+
selected = [p for p in proposals if p.id in ids]
|
|
909
|
+
if not selected:
|
|
910
|
+
return JSONResponse({"error": "no matching proposals"}, status_code=404)
|
|
911
|
+
|
|
912
|
+
for p in selected:
|
|
913
|
+
override = briefing_overrides.get(str(p.id))
|
|
914
|
+
if override is not None:
|
|
915
|
+
p.briefing = override
|
|
916
|
+
|
|
917
|
+
from coord.claim import claim_message, find_work_claim
|
|
918
|
+
|
|
919
|
+
in_flight = load_dispatched()
|
|
920
|
+
board_for_claim = _read_board()
|
|
921
|
+
results = []
|
|
922
|
+
for p in selected:
|
|
923
|
+
repo = config.repo(p.repo_name)
|
|
924
|
+
if repo is not None:
|
|
925
|
+
claim = find_work_claim(
|
|
926
|
+
p.issue_number, p.repo_name, repo.github, board_for_claim
|
|
927
|
+
)
|
|
928
|
+
if claim is not None:
|
|
929
|
+
results.append({
|
|
930
|
+
"id": p.id, "ok": False,
|
|
931
|
+
"error": claim_message(claim),
|
|
932
|
+
"claimed": True,
|
|
933
|
+
})
|
|
934
|
+
continue
|
|
935
|
+
try:
|
|
936
|
+
response = dispatch(p, config)
|
|
937
|
+
assignment_id = response.get("id", "pending")
|
|
938
|
+
if repo:
|
|
939
|
+
record_dispatched(
|
|
940
|
+
assignment_id=assignment_id,
|
|
941
|
+
proposal=p,
|
|
942
|
+
repo_github=repo.github,
|
|
943
|
+
provider_name=response.get("_provider_name"),
|
|
944
|
+
)
|
|
945
|
+
do_not_touch = compute_do_not_touch(p, peers=selected, in_flight=in_flight)
|
|
946
|
+
try:
|
|
947
|
+
post_briefing(p, config, assignment_id=assignment_id, do_not_touch=do_not_touch)
|
|
948
|
+
except Exception:
|
|
949
|
+
pass
|
|
950
|
+
results.append({"id": p.id, "assignment_id": assignment_id, "ok": True})
|
|
951
|
+
except Exception as e:
|
|
952
|
+
results.append({"id": p.id, "ok": False, "error": str(e)})
|
|
953
|
+
|
|
954
|
+
clear_proposals()
|
|
955
|
+
board = _read_board()
|
|
956
|
+
board.round_number += 1
|
|
957
|
+
_write_board(board)
|
|
958
|
+
return JSONResponse({"results": results})
|
|
959
|
+
|
|
960
|
+
async def api_chat(request: Request) -> StreamingResponse:
|
|
961
|
+
try:
|
|
962
|
+
body = await request.json()
|
|
963
|
+
except ValueError:
|
|
964
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
965
|
+
|
|
966
|
+
message = body.get("message", "").strip()
|
|
967
|
+
if not message:
|
|
968
|
+
return JSONResponse({"error": "message required"}, status_code=400)
|
|
969
|
+
|
|
970
|
+
if _fixture is not None:
|
|
971
|
+
# Recorded, not executed — never spawn a provider subprocess in
|
|
972
|
+
# fixture mode. Same SSE envelope the live path streams.
|
|
973
|
+
_fixture.record("/api/chat", body, action="chat")
|
|
974
|
+
reply = _fixture.chat_reply
|
|
975
|
+
|
|
976
|
+
async def canned():
|
|
977
|
+
yield f"data: {json.dumps({'text': reply})}\n\n"
|
|
978
|
+
yield "data: [DONE]\n\n"
|
|
979
|
+
|
|
980
|
+
return StreamingResponse(canned(), media_type="text/event-stream")
|
|
981
|
+
|
|
982
|
+
board = _read_board()
|
|
983
|
+
from dataclasses import asdict
|
|
984
|
+
board_context = json.dumps({
|
|
985
|
+
"round_number": board.round_number,
|
|
986
|
+
"active": [asdict(a) for a in board.active],
|
|
987
|
+
"completed": [asdict(a) for a in board.completed[-10:]],
|
|
988
|
+
}, indent=2)
|
|
989
|
+
|
|
990
|
+
system = (
|
|
991
|
+
"You are the coordinator assistant for a multi-machine Claude Code system. "
|
|
992
|
+
"Answer questions about the current board state, assignments, and machines. "
|
|
993
|
+
"Be concise.\n\n"
|
|
994
|
+
f"Current board state:\n{board_context}"
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
# Resolve the coordinator's default provider so the dashboard chat
|
|
998
|
+
# honours the configured backend rather than hard-coding "claude".
|
|
999
|
+
# Uses resolve_default_provider (shared with brain.py) which also
|
|
1000
|
+
# enforces the human_attended_only guard — raises ValueError if the
|
|
1001
|
+
# configured default is a human-attended-only backend such as
|
|
1002
|
+
# ClaudePtyProvider, preventing unattended use of those providers.
|
|
1003
|
+
from coord.providers import resolve_default_provider # noqa: PLC0415
|
|
1004
|
+
|
|
1005
|
+
try:
|
|
1006
|
+
_provider = resolve_default_provider(config.providers, config.models)
|
|
1007
|
+
except ValueError as exc:
|
|
1008
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
1009
|
+
# output_format=None → no --output-format flag; dashboard streams
|
|
1010
|
+
# plain-text lines, not a JSON envelope.
|
|
1011
|
+
_chat_cmd = _provider.oneshot_command(system_prompt=system, output_format=None)
|
|
1012
|
+
|
|
1013
|
+
async def stream():
|
|
1014
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1015
|
+
*_chat_cmd,
|
|
1016
|
+
stdin=asyncio.subprocess.PIPE,
|
|
1017
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1018
|
+
stderr=asyncio.subprocess.PIPE,
|
|
1019
|
+
)
|
|
1020
|
+
proc.stdin.write(message.encode())
|
|
1021
|
+
proc.stdin.close()
|
|
1022
|
+
|
|
1023
|
+
async for line in proc.stdout:
|
|
1024
|
+
text = line.decode("utf-8", errors="replace")
|
|
1025
|
+
yield f"data: {json.dumps({'text': text})}\n\n"
|
|
1026
|
+
|
|
1027
|
+
await proc.wait()
|
|
1028
|
+
yield "data: [DONE]\n\n"
|
|
1029
|
+
|
|
1030
|
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
1031
|
+
|
|
1032
|
+
async def api_reject(request: Request) -> JSONResponse:
|
|
1033
|
+
from coord.state import load_proposals as load_p, save_proposals as save_p
|
|
1034
|
+
|
|
1035
|
+
try:
|
|
1036
|
+
body = await request.json()
|
|
1037
|
+
except ValueError:
|
|
1038
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
1039
|
+
|
|
1040
|
+
ids = body.get("ids", [])
|
|
1041
|
+
if not ids or not isinstance(ids, list):
|
|
1042
|
+
return JSONResponse({"error": "ids must be a non-empty list"}, status_code=400)
|
|
1043
|
+
|
|
1044
|
+
if _fixture is not None:
|
|
1045
|
+
seeded = _fixture.proposals()
|
|
1046
|
+
remaining = [p for p in seeded if p.id not in ids]
|
|
1047
|
+
_fixture.record("/api/reject", body, action="reject")
|
|
1048
|
+
return JSONResponse(
|
|
1049
|
+
{"removed": len(seeded) - len(remaining), "remaining": len(remaining)}
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
proposals = load_p()
|
|
1053
|
+
remaining = [p for p in proposals if p.id not in ids]
|
|
1054
|
+
removed = len(proposals) - len(remaining)
|
|
1055
|
+
if remaining:
|
|
1056
|
+
save_p(remaining)
|
|
1057
|
+
else:
|
|
1058
|
+
from coord.state import clear_proposals
|
|
1059
|
+
clear_proposals()
|
|
1060
|
+
return JSONResponse({"removed": removed, "remaining": len(remaining)})
|
|
1061
|
+
|
|
1062
|
+
async def api_diff(request: Request) -> JSONResponse:
|
|
1063
|
+
assignment_id = request.path_params["id"]
|
|
1064
|
+
board = _read_board()
|
|
1065
|
+
assignment = board.find_by_id(assignment_id)
|
|
1066
|
+
if assignment is None:
|
|
1067
|
+
return JSONResponse({"error": "assignment not found"}, status_code=404)
|
|
1068
|
+
if not assignment.branch:
|
|
1069
|
+
return JSONResponse({"error": "no branch recorded"}, status_code=404)
|
|
1070
|
+
|
|
1071
|
+
if _fixture is not None:
|
|
1072
|
+
# Seeded diff text — never shell out to `gh` in fixture mode.
|
|
1073
|
+
return JSONResponse(
|
|
1074
|
+
{"diff": _fixture.diff(assignment_id), "source": "fixture"}
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
repo = config.repo(assignment.repo_name)
|
|
1078
|
+
if repo is None:
|
|
1079
|
+
return JSONResponse({"error": "unknown repo"}, status_code=404)
|
|
1080
|
+
|
|
1081
|
+
try:
|
|
1082
|
+
from coord.github_ops import _gh
|
|
1083
|
+
raw = _gh(
|
|
1084
|
+
"pr", "diff", "--repo", repo.github,
|
|
1085
|
+
assignment.branch,
|
|
1086
|
+
)
|
|
1087
|
+
return JSONResponse({"diff": raw, "source": "pr"})
|
|
1088
|
+
except RuntimeError:
|
|
1089
|
+
pass
|
|
1090
|
+
|
|
1091
|
+
try:
|
|
1092
|
+
from coord.github_ops import _gh
|
|
1093
|
+
raw = _gh(
|
|
1094
|
+
"api", f"repos/{repo.github}/compare/{repo.default_branch}...{assignment.branch}",
|
|
1095
|
+
"--jq", ".files[].patch // empty",
|
|
1096
|
+
)
|
|
1097
|
+
return JSONResponse({"diff": raw, "source": "compare"})
|
|
1098
|
+
except RuntimeError as e:
|
|
1099
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
1100
|
+
|
|
1101
|
+
async def api_pipeline(request: Request) -> JSONResponse:
|
|
1102
|
+
"""GET /api/pipeline — return PipelineView for every type='work' assignment.
|
|
1103
|
+
|
|
1104
|
+
Fixture mode (#1538) swaps the three data sources below — board, merge
|
|
1105
|
+
queue, cached review findings — and pins ``now``; the computation and
|
|
1106
|
+
serialization underneath are the same ``compute_pipeline`` + ``asdict``
|
|
1107
|
+
the live dashboard runs, which is what keeps a fixture-backed
|
|
1108
|
+
acceptance suite honest.
|
|
1109
|
+
|
|
1110
|
+
#2066: bounded by default to active work + recently-finished terminal
|
|
1111
|
+
work, and sorted newest-first — the unbounded response used to grow
|
|
1112
|
+
~14 rows/day forever (711 rows, 2 of them live, when filed). The
|
|
1113
|
+
cutoff itself (``coord.dao._board_retention_cutoff`` /
|
|
1114
|
+
``COORD_BOARD_RETENTION_DAYS``, default 14 days) is reused from
|
|
1115
|
+
``/board``'s DAO layer per #773's precedent, but *what* gets aged is
|
|
1116
|
+
deliberately not a straight port of ``coord.dao.compute_board_keep_ids``:
|
|
1117
|
+
that function judges a row's own ``status`` against
|
|
1118
|
+
``TERMINAL_STATUSES``, then closes the kept set over
|
|
1119
|
+
``review_of_assignment_id`` links so an in-flight review/smoke never
|
|
1120
|
+
strands its parent work row. Here we instead filter on
|
|
1121
|
+
``PipelineView.current_stage`` *after* ``compute_pipeline`` has
|
|
1122
|
+
already inspected the linked review/smoke assignment — a work
|
|
1123
|
+
assignment's own ``status`` flips to ``"done"`` the moment coding
|
|
1124
|
+
finishes, independent of whether its review or smoke is still
|
|
1125
|
+
running (``coord/notify.py`` documents this as the normal, possibly
|
|
1126
|
+
long-lived intermediate state #846 ``needs_attention`` exists to
|
|
1127
|
+
catch), so gating on ``a.status`` alone silently aged out exactly the
|
|
1128
|
+
stalled-review rows that matter most. Filtering on ``current_stage``
|
|
1129
|
+
gets the same protection as the DAO's closure — and additionally
|
|
1130
|
+
covers "finished this sub-stage, awaiting a human gate click"
|
|
1131
|
+
states (``review_done``, ``smoke_passed``, ``merge_ready``,
|
|
1132
|
+
``merging``) that the closure alone wouldn't, since those have no
|
|
1133
|
+
non-terminal linked assignment to close over. We deliberately do
|
|
1134
|
+
**not** port the DAO's third exemption — "latest assignment of a
|
|
1135
|
+
still-open issue" — the issue that drove this fix
|
|
1136
|
+
(claude-coordinator#772) is itself an *open* issue with a long-dead
|
|
1137
|
+
assignment, so keeping every open issue's latest row alive forever
|
|
1138
|
+
would defeat the bound.
|
|
1139
|
+
|
|
1140
|
+
``?include=all`` opts back into the full, unbounded history.
|
|
1141
|
+
|
|
1142
|
+
The recency signal is ``PipelineView.finished_at`` — the max across
|
|
1143
|
+
the work/review/smoke assignments (#1218) — when present, falling
|
|
1144
|
+
back to the work assignment's own ``dispatched_at``; a row with
|
|
1145
|
+
neither is kept rather than guessed away (same conservative rule as
|
|
1146
|
+
``compute_board_keep_ids``). Using ``pv.finished_at`` rather than the
|
|
1147
|
+
raw ``a.finished_at`` matters independently of whatever turns out to
|
|
1148
|
+
be causing some rows' ``finished_at`` to read back ``None``
|
|
1149
|
+
(#2066's still-open second half): ``a.finished_at`` freezes at
|
|
1150
|
+
coding-done and doesn't advance as review/smoke progress, while
|
|
1151
|
+
``dispatched_at`` is unconditionally stamped at dispatch time by
|
|
1152
|
+
every code path, so the bound holds even for a row whose
|
|
1153
|
+
``finished_at`` never got recorded.
|
|
1154
|
+
"""
|
|
1155
|
+
from dataclasses import asdict
|
|
1156
|
+
|
|
1157
|
+
from coord.dao import _board_retention_cutoff
|
|
1158
|
+
from coord.pipeline import compute_pipeline
|
|
1159
|
+
from coord.merge_queue import load_queue
|
|
1160
|
+
from coord.state import load_assignment_review_findings
|
|
1161
|
+
|
|
1162
|
+
board = _read_board()
|
|
1163
|
+
mq_items = _fixture.merge_queue() if _fixture is not None else load_queue()
|
|
1164
|
+
pipeline_now = _fixture.now if _fixture is not None else None
|
|
1165
|
+
now = pipeline_now if pipeline_now is not None else time.time()
|
|
1166
|
+
|
|
1167
|
+
include_all = request.query_params.get("include") == "all"
|
|
1168
|
+
cutoff = None if include_all else _board_retention_cutoff(now)
|
|
1169
|
+
|
|
1170
|
+
# Build a lookup of review assignment id per work assignment_id so we can
|
|
1171
|
+
# fetch the review findings body with one pass instead of N nested loops.
|
|
1172
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
1173
|
+
review_by_work: dict[str, str] = {} # work_aid → review aid
|
|
1174
|
+
for a in all_assignments:
|
|
1175
|
+
if a.type == "review" and a.review_of_assignment_id and a.assignment_id:
|
|
1176
|
+
review_by_work[a.review_of_assignment_id] = a.assignment_id
|
|
1177
|
+
merge_queue_ids = {m.assignment_id for m in mq_items if m.assignment_id}
|
|
1178
|
+
|
|
1179
|
+
pipelines: list[PipelineView] = []
|
|
1180
|
+
for a in all_assignments:
|
|
1181
|
+
if a.type not in ("work", None, ""):
|
|
1182
|
+
continue
|
|
1183
|
+
# Exclude assignments with no id (shouldn't normally happen).
|
|
1184
|
+
if not a.assignment_id:
|
|
1185
|
+
continue
|
|
1186
|
+
# No review findings yet — current_stage/finished_at (used for the
|
|
1187
|
+
# cutoff decision below) don't depend on it, so defer the DB call
|
|
1188
|
+
# until we know the row survives the filter.
|
|
1189
|
+
pv = compute_pipeline(
|
|
1190
|
+
a, board, mq_items, config,
|
|
1191
|
+
review_findings_body=None,
|
|
1192
|
+
now=pipeline_now,
|
|
1193
|
+
)
|
|
1194
|
+
if (
|
|
1195
|
+
cutoff is not None
|
|
1196
|
+
and pv.current_stage in _PIPELINE_QUIESCENT_STAGES
|
|
1197
|
+
and a.assignment_id not in merge_queue_ids
|
|
1198
|
+
):
|
|
1199
|
+
ts = pv.finished_at if pv.finished_at is not None else a.dispatched_at
|
|
1200
|
+
if ts is not None and ts < cutoff:
|
|
1201
|
+
continue
|
|
1202
|
+
rev_aid = review_by_work.get(a.assignment_id)
|
|
1203
|
+
if rev_aid:
|
|
1204
|
+
found = (
|
|
1205
|
+
_fixture.review_findings(rev_aid)
|
|
1206
|
+
if _fixture is not None
|
|
1207
|
+
else load_assignment_review_findings(rev_aid)
|
|
1208
|
+
)
|
|
1209
|
+
if found:
|
|
1210
|
+
_, pv.review_findings_body = found
|
|
1211
|
+
pipelines.append(pv)
|
|
1212
|
+
|
|
1213
|
+
# Newest-first: today's running work above a July failure, not the
|
|
1214
|
+
# reverse (#2066 step 3). Same finished_at-then-dispatched_at signal
|
|
1215
|
+
# as the cutoff above; a genuinely undatable row sorts last.
|
|
1216
|
+
dispatched_by_id = {a.assignment_id: a.dispatched_at for a in all_assignments}
|
|
1217
|
+
pipelines.sort(
|
|
1218
|
+
key=lambda pv: (
|
|
1219
|
+
pv.finished_at
|
|
1220
|
+
if pv.finished_at is not None
|
|
1221
|
+
else dispatched_by_id.get(pv.assignment_id)
|
|
1222
|
+
)
|
|
1223
|
+
or 0.0,
|
|
1224
|
+
reverse=True,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
return JSONResponse([asdict(pv) for pv in pipelines])
|
|
1228
|
+
|
|
1229
|
+
# Actions whose live handler returns a fixed-shape success envelope. The
|
|
1230
|
+
# fixture branch below reproduces that envelope exactly (`ok: true` plus
|
|
1231
|
+
# whatever fields the client reads) so a seeded acceptance run exercises
|
|
1232
|
+
# the same client code as production — while the side effect that would
|
|
1233
|
+
# have cost money is only written to the recorded-action log.
|
|
1234
|
+
def _fixture_action(action, body, assignment, board) -> JSONResponse: # noqa: ANN001
|
|
1235
|
+
"""POST /api/pipeline/action in fixture mode — record, never execute.
|
|
1236
|
+
|
|
1237
|
+
Validation that can be answered from the fixture alone (unknown
|
|
1238
|
+
action, verdict enums, missing review row, merge-queue membership) is
|
|
1239
|
+
replicated so the error contract holds. Checks that depend on live
|
|
1240
|
+
config or the fleet (reviewer-machine availability, merge gates) are
|
|
1241
|
+
deliberately not — a fixture asserts the *client* contract, and a
|
|
1242
|
+
seeded board has no fleet to be unavailable.
|
|
1243
|
+
"""
|
|
1244
|
+
aid = assignment.assignment_id or ""
|
|
1245
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
1246
|
+
review_a = next(
|
|
1247
|
+
(
|
|
1248
|
+
a for a in all_assignments
|
|
1249
|
+
if a.review_of_assignment_id == aid and a.type == "review"
|
|
1250
|
+
),
|
|
1251
|
+
None,
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
# Reject before recording: an invalid request never happened.
|
|
1255
|
+
if action == "retry":
|
|
1256
|
+
return JSONResponse(
|
|
1257
|
+
{"ok": False, "error": "'retry' is not yet implemented in the dashboard"},
|
|
1258
|
+
status_code=501,
|
|
1259
|
+
)
|
|
1260
|
+
if action == "test-verdict":
|
|
1261
|
+
verdict = body.get("verdict")
|
|
1262
|
+
if verdict not in ("pass", "fail", "skip"):
|
|
1263
|
+
return JSONResponse(
|
|
1264
|
+
{"error": "verdict must be one of ['fail', 'pass', 'skip']"},
|
|
1265
|
+
status_code=400,
|
|
1266
|
+
)
|
|
1267
|
+
elif action == "record-review-verdict":
|
|
1268
|
+
if body.get("verdict") not in ("approve", "request-changes"):
|
|
1269
|
+
return JSONResponse(
|
|
1270
|
+
{"error": "verdict must be one of ['approve', 'request-changes']"},
|
|
1271
|
+
status_code=400,
|
|
1272
|
+
)
|
|
1273
|
+
if not body.get("body"):
|
|
1274
|
+
return JSONResponse(
|
|
1275
|
+
{"error": "body is required for record-review-verdict"},
|
|
1276
|
+
status_code=400,
|
|
1277
|
+
)
|
|
1278
|
+
if review_a is None:
|
|
1279
|
+
return JSONResponse(
|
|
1280
|
+
{"error": "no review assignment found for this work assignment"},
|
|
1281
|
+
status_code=404,
|
|
1282
|
+
)
|
|
1283
|
+
elif action == "post_findings":
|
|
1284
|
+
if review_a is None:
|
|
1285
|
+
return JSONResponse({"error": "no review assignment found"}, status_code=404)
|
|
1286
|
+
elif action == "merge":
|
|
1287
|
+
if not any(m.assignment_id == aid for m in _fixture.merge_queue()):
|
|
1288
|
+
return JSONResponse({"error": "not in merge queue"}, status_code=404)
|
|
1289
|
+
elif action == "dispatch_fix":
|
|
1290
|
+
if body.get("parent_type", "work") not in ("work", "review"):
|
|
1291
|
+
return JSONResponse(
|
|
1292
|
+
{
|
|
1293
|
+
"error": "parent_type must be 'work' or 'review', got "
|
|
1294
|
+
f"{body.get('parent_type')!r}"
|
|
1295
|
+
},
|
|
1296
|
+
status_code=400,
|
|
1297
|
+
)
|
|
1298
|
+
if not assignment.branch:
|
|
1299
|
+
return JSONResponse(
|
|
1300
|
+
{"ok": False, "error": "work assignment has no branch to fix"},
|
|
1301
|
+
status_code=400,
|
|
1302
|
+
)
|
|
1303
|
+
elif action not in (
|
|
1304
|
+
"dispatch_review", "dispatch_smoke", "enqueue", "unstick",
|
|
1305
|
+
):
|
|
1306
|
+
return JSONResponse({"error": f"unknown action: {action!r}"}, status_code=400)
|
|
1307
|
+
|
|
1308
|
+
_fixture.record("/api/pipeline/action", body, action=action)
|
|
1309
|
+
|
|
1310
|
+
if action in ("dispatch_review", "dispatch_smoke"):
|
|
1311
|
+
kind = "review" if action == "dispatch_review" else "smoke"
|
|
1312
|
+
return JSONResponse({
|
|
1313
|
+
"ok": True,
|
|
1314
|
+
"machine_name": assignment.machine_name,
|
|
1315
|
+
"assignment_id": f"fixture-{kind}-{aid}",
|
|
1316
|
+
})
|
|
1317
|
+
if action == "dispatch_fix":
|
|
1318
|
+
return JSONResponse({
|
|
1319
|
+
"ok": True,
|
|
1320
|
+
"machine_name": assignment.machine_name,
|
|
1321
|
+
"assignment_id": f"fixture-fix-{aid}",
|
|
1322
|
+
"branch": assignment.branch,
|
|
1323
|
+
})
|
|
1324
|
+
if action == "merge":
|
|
1325
|
+
return JSONResponse({
|
|
1326
|
+
"ok": True,
|
|
1327
|
+
"events": [
|
|
1328
|
+
{"kind": "merged", "message": f"fixture: would merge {aid}"}
|
|
1329
|
+
],
|
|
1330
|
+
})
|
|
1331
|
+
if action == "post_findings":
|
|
1332
|
+
return JSONResponse({"ok": True, "detail": "posted"})
|
|
1333
|
+
if action == "unstick":
|
|
1334
|
+
return JSONResponse({"ok": True, "cancelled_on_agent": False})
|
|
1335
|
+
if action == "test-verdict":
|
|
1336
|
+
state = {"pass": "passed", "fail": "failed", "skip": "skipped"}[
|
|
1337
|
+
body["verdict"]
|
|
1338
|
+
]
|
|
1339
|
+
return JSONResponse({"ok": True, "test_state": state})
|
|
1340
|
+
# enqueue, record-review-verdict
|
|
1341
|
+
return JSONResponse({"ok": True})
|
|
1342
|
+
|
|
1343
|
+
async def api_pipeline_action(request: Request) -> JSONResponse:
|
|
1344
|
+
"""POST /api/pipeline/action — advance an assignment through a gate.
|
|
1345
|
+
|
|
1346
|
+
Body: {"assignment_id": "...", "action": "..."}
|
|
1347
|
+
|
|
1348
|
+
Supported actions: dispatch_review, dispatch_smoke, enqueue, merge,
|
|
1349
|
+
retry (501), dispatch_fix (501).
|
|
1350
|
+
|
|
1351
|
+
In fixture mode (#1538) every one of these is **recorded, not
|
|
1352
|
+
executed** — see :func:`_fixture_action`.
|
|
1353
|
+
"""
|
|
1354
|
+
try:
|
|
1355
|
+
body = await request.json()
|
|
1356
|
+
except ValueError:
|
|
1357
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
1358
|
+
|
|
1359
|
+
assignment_id = body.get("assignment_id")
|
|
1360
|
+
action = body.get("action")
|
|
1361
|
+
if not assignment_id or not action:
|
|
1362
|
+
return JSONResponse(
|
|
1363
|
+
{"error": "assignment_id and action are required"}, status_code=400
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
board = _read_board()
|
|
1367
|
+
assignment = board.find_by_id(assignment_id)
|
|
1368
|
+
if assignment is None:
|
|
1369
|
+
return JSONResponse({"error": "assignment not found"}, status_code=404)
|
|
1370
|
+
|
|
1371
|
+
if _fixture is not None:
|
|
1372
|
+
return _fixture_action(action, body, assignment, board)
|
|
1373
|
+
|
|
1374
|
+
if action == "dispatch_review":
|
|
1375
|
+
from coord.review import dispatch_review
|
|
1376
|
+
|
|
1377
|
+
try:
|
|
1378
|
+
result = dispatch_review(assignment, board, config)
|
|
1379
|
+
except Exception as exc:
|
|
1380
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1381
|
+
if result:
|
|
1382
|
+
_write_board(board)
|
|
1383
|
+
return JSONResponse({
|
|
1384
|
+
"ok": True,
|
|
1385
|
+
"machine_name": result.machine_name,
|
|
1386
|
+
"assignment_id": result.assignment_id,
|
|
1387
|
+
})
|
|
1388
|
+
# #1627: report the specific guard dispatch_review hit instead of
|
|
1389
|
+
# a generic guess — it's recorded on the assignment itself.
|
|
1390
|
+
return JSONResponse({
|
|
1391
|
+
"ok": False,
|
|
1392
|
+
"error": assignment.review_dispatch_reason
|
|
1393
|
+
or "could not find a suitable reviewer machine (check reviews config and machine availability)",
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
elif action == "dispatch_smoke":
|
|
1397
|
+
from coord.smoke import dispatch_smoke
|
|
1398
|
+
|
|
1399
|
+
try:
|
|
1400
|
+
result = dispatch_smoke(assignment, board, config)
|
|
1401
|
+
except Exception as exc:
|
|
1402
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1403
|
+
if result:
|
|
1404
|
+
_write_board(board)
|
|
1405
|
+
return JSONResponse({
|
|
1406
|
+
"ok": True,
|
|
1407
|
+
"machine_name": result.machine_name,
|
|
1408
|
+
"assignment_id": result.assignment_id,
|
|
1409
|
+
})
|
|
1410
|
+
return JSONResponse({
|
|
1411
|
+
"ok": False,
|
|
1412
|
+
"error": "no smoke test needed or no capable machine matched the diff",
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
elif action == "enqueue":
|
|
1416
|
+
repo = config.repo(assignment.repo_name)
|
|
1417
|
+
if repo is None:
|
|
1418
|
+
return JSONResponse({"error": "unknown repo"}, status_code=404)
|
|
1419
|
+
from coord import github_ops as _gh_ops
|
|
1420
|
+
from coord import merge_queue as mq
|
|
1421
|
+
|
|
1422
|
+
# #946: this was the third (dashboard-only) enqueue path left
|
|
1423
|
+
# ungated after the daemon (`enqueue_approved_work`) and `coord
|
|
1424
|
+
# merge`'s auto-enqueue loop were fixed to use the shared
|
|
1425
|
+
# `passes_merge_gates` predicate. Gate here too — untested /
|
|
1426
|
+
# unreviewed work must never enter the merge queue through any
|
|
1427
|
+
# path. `force: true` in the request body is the explicit
|
|
1428
|
+
# escape hatch, mirroring `--force-merge` at merge time.
|
|
1429
|
+
#
|
|
1430
|
+
# #2085: `assignment` is a raw work Assignment — it has no
|
|
1431
|
+
# `branch_head_sha`/`branch_patch_id` attribute at all, so
|
|
1432
|
+
# handing it straight to `passes_merge_gates` made
|
|
1433
|
+
# `has_approved_review`'s #821 freshness check permanently
|
|
1434
|
+
# UNCONFIRMABLE here: every review carrying a real
|
|
1435
|
+
# `review_head_sha` (virtually every modern approval) failed
|
|
1436
|
+
# closed, so pressing "Enqueue" in the Phone Control Center on
|
|
1437
|
+
# perfectly fresh, approved work reported "has not passed the
|
|
1438
|
+
# required review/smoke gates" and demanded `force: true`. This
|
|
1439
|
+
# was the one raw-Assignment gate call site missed when the
|
|
1440
|
+
# other four (`coord.gates.build_gate_report`,
|
|
1441
|
+
# `enqueue_approved_work`, `coord.notify`'s stalled-dispatch
|
|
1442
|
+
# recovery, `coord.diagnose`'s stage-work recovery) were routed
|
|
1443
|
+
# through `mq.live_gate_entry`. Same target_branch that
|
|
1444
|
+
# `enqueue` below merges into, so the gate is evaluated against
|
|
1445
|
+
# exactly the branch pair the merge would use.
|
|
1446
|
+
force = bool(body.get("force"))
|
|
1447
|
+
target_branch = repo.default_branch
|
|
1448
|
+
if not force:
|
|
1449
|
+
gate_entry = mq.live_gate_entry(
|
|
1450
|
+
assignment, repo.github, target_branch, _gh_ops
|
|
1451
|
+
)
|
|
1452
|
+
if not mq.passes_merge_gates(
|
|
1453
|
+
gate_entry, config, board, gh_ops=_gh_ops
|
|
1454
|
+
):
|
|
1455
|
+
return JSONResponse({
|
|
1456
|
+
"ok": False,
|
|
1457
|
+
"error": (
|
|
1458
|
+
"assignment has not passed the required review/smoke "
|
|
1459
|
+
"gates — pass force: true to enqueue anyway"
|
|
1460
|
+
),
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
try:
|
|
1464
|
+
if force:
|
|
1465
|
+
# Bypass the gate entirely — don't pass config/board, or
|
|
1466
|
+
# enqueue()'s own (unconditional) gate check would still
|
|
1467
|
+
# reject it despite the explicit override above.
|
|
1468
|
+
entry = mq.enqueue(assignment, repo.github, target_branch)
|
|
1469
|
+
else:
|
|
1470
|
+
entry = mq.enqueue(
|
|
1471
|
+
assignment, repo.github, target_branch,
|
|
1472
|
+
config=config, board=board, gh_ops=_gh_ops,
|
|
1473
|
+
)
|
|
1474
|
+
except Exception as exc:
|
|
1475
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1476
|
+
if entry is None:
|
|
1477
|
+
return JSONResponse({"ok": False, "error": "could not enqueue (already in queue?)"})
|
|
1478
|
+
return JSONResponse({"ok": True})
|
|
1479
|
+
|
|
1480
|
+
elif action == "merge":
|
|
1481
|
+
from coord import github_ops as _gh_ops
|
|
1482
|
+
from coord.merge_queue import PENDING, load_queue, process, save_queue
|
|
1483
|
+
|
|
1484
|
+
items = load_queue()
|
|
1485
|
+
target = next(
|
|
1486
|
+
(x for x in items if x.assignment_id == assignment_id), None
|
|
1487
|
+
)
|
|
1488
|
+
if target is None:
|
|
1489
|
+
return JSONResponse({"error": "not in merge queue"}, status_code=404)
|
|
1490
|
+
if target.state != PENDING:
|
|
1491
|
+
return JSONResponse(
|
|
1492
|
+
{"error": f"queue entry state is {target.state!r}, expected 'pending'"},
|
|
1493
|
+
status_code=400,
|
|
1494
|
+
)
|
|
1495
|
+
# Process only the single entry (target is in `items` by reference;
|
|
1496
|
+
# process() mutates it in place, then we save the full queue).
|
|
1497
|
+
events = process([target], _gh_ops)
|
|
1498
|
+
save_queue(items)
|
|
1499
|
+
return JSONResponse(
|
|
1500
|
+
{
|
|
1501
|
+
"ok": True,
|
|
1502
|
+
"events": [
|
|
1503
|
+
{"kind": e.kind, "message": e.message} for e in events
|
|
1504
|
+
],
|
|
1505
|
+
}
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
elif action == "post_findings":
|
|
1509
|
+
# Find the review assignment linked to this work assignment and
|
|
1510
|
+
# attempt to post its findings.
|
|
1511
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
1512
|
+
review_assignment = next(
|
|
1513
|
+
(
|
|
1514
|
+
a for a in all_assignments
|
|
1515
|
+
if a.review_of_assignment_id == assignment_id and a.type == "review"
|
|
1516
|
+
),
|
|
1517
|
+
None,
|
|
1518
|
+
)
|
|
1519
|
+
if review_assignment is None:
|
|
1520
|
+
return JSONResponse({"error": "no review assignment found"}, status_code=404)
|
|
1521
|
+
if review_assignment.review_posted_at is not None:
|
|
1522
|
+
return JSONResponse({"ok": True, "detail": "already posted"})
|
|
1523
|
+
from coord.notify import post_orphaned_review_findings # noqa: PLC0415
|
|
1524
|
+
|
|
1525
|
+
posted = post_orphaned_review_findings(config)
|
|
1526
|
+
ok = review_assignment.assignment_id in posted
|
|
1527
|
+
return JSONResponse(
|
|
1528
|
+
{"ok": ok, "detail": "posted" if ok else "not posted (agent offline or no structured findings)"}
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
elif action == "unstick":
|
|
1532
|
+
# Cancel on the agent server (best-effort) then mark failed on the
|
|
1533
|
+
# board. Used for assignments that are running in the DB but have
|
|
1534
|
+
# silently disappeared from the agent's active list.
|
|
1535
|
+
machine = next(
|
|
1536
|
+
(m for m in config.machines if m.name == assignment.machine_name),
|
|
1537
|
+
None,
|
|
1538
|
+
)
|
|
1539
|
+
cancelled_on_agent = False
|
|
1540
|
+
if machine is not None:
|
|
1541
|
+
try:
|
|
1542
|
+
resp = httpx.post(
|
|
1543
|
+
f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
|
|
1544
|
+
timeout=10.0,
|
|
1545
|
+
)
|
|
1546
|
+
cancelled_on_agent = resp.status_code in (200, 202)
|
|
1547
|
+
except Exception:
|
|
1548
|
+
pass
|
|
1549
|
+
# Mark failed in the board regardless of agent response.
|
|
1550
|
+
board.mark_failed_by_id(assignment_id, finished_at=time.time())
|
|
1551
|
+
_write_board(board)
|
|
1552
|
+
return JSONResponse({"ok": True, "cancelled_on_agent": cancelled_on_agent})
|
|
1553
|
+
|
|
1554
|
+
elif action == "test-verdict":
|
|
1555
|
+
# Record a human Test-gate verdict for a work assignment.
|
|
1556
|
+
# Body: {assignment_id, verdict: "pass"|"fail"|"skip", reason?}
|
|
1557
|
+
verdict = body.get("verdict")
|
|
1558
|
+
reason = body.get("reason") or None
|
|
1559
|
+
_VALID_VERDICTS = {"pass", "fail", "skip"}
|
|
1560
|
+
if verdict not in _VALID_VERDICTS:
|
|
1561
|
+
return JSONResponse(
|
|
1562
|
+
{"error": f"verdict must be one of {sorted(_VALID_VERDICTS)!r}"},
|
|
1563
|
+
status_code=400,
|
|
1564
|
+
)
|
|
1565
|
+
# Map short form to the canonical test_state values used by the TUI
|
|
1566
|
+
# and reconcile gating logic.
|
|
1567
|
+
test_state_map = {"pass": "passed", "fail": "failed", "skip": "skipped"}
|
|
1568
|
+
test_state = test_state_map[verdict]
|
|
1569
|
+
test_reason = reason if verdict == "fail" else None
|
|
1570
|
+
# Mirror to legacy smoke_test column for the smoke-stage scoring in
|
|
1571
|
+
# pipeline.py (predates the human Test gate — same mirror as cli.py).
|
|
1572
|
+
smoke_test: str | None = verdict if verdict in ("pass", "fail") else None
|
|
1573
|
+
smoke_test_reason: str | None = reason if verdict == "fail" else None
|
|
1574
|
+
from coord.state import record_test_verdict as _record_test_verdict
|
|
1575
|
+
|
|
1576
|
+
try:
|
|
1577
|
+
_record_test_verdict(
|
|
1578
|
+
assignment_id=assignment_id,
|
|
1579
|
+
test_state=test_state,
|
|
1580
|
+
test_reason=test_reason,
|
|
1581
|
+
smoke_test=smoke_test,
|
|
1582
|
+
smoke_test_reason=smoke_test_reason,
|
|
1583
|
+
)
|
|
1584
|
+
except Exception as exc:
|
|
1585
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1586
|
+
return JSONResponse({"ok": True, "test_state": test_state})
|
|
1587
|
+
|
|
1588
|
+
elif action == "record-review-verdict":
|
|
1589
|
+
# Persist a parsed review verdict + findings body to the DB cache so
|
|
1590
|
+
# the phone can record results from a manual review session without
|
|
1591
|
+
# going through the full notify/auto_loop path.
|
|
1592
|
+
# Body: {assignment_id, verdict: "approve"|"request-changes", body}
|
|
1593
|
+
# NOTE: assignment_id here is the WORK assignment id (as exposed by
|
|
1594
|
+
# GET /api/pipeline). We must look up the linked review assignment
|
|
1595
|
+
# before writing, since _persist_review_findings writes to the review
|
|
1596
|
+
# row and compute_pipeline reads findings back from the review row.
|
|
1597
|
+
verdict = body.get("verdict")
|
|
1598
|
+
findings_body = body.get("body")
|
|
1599
|
+
_VALID_REVIEW_VERDICTS = {"approve", "request-changes"}
|
|
1600
|
+
if verdict not in _VALID_REVIEW_VERDICTS:
|
|
1601
|
+
return JSONResponse(
|
|
1602
|
+
{"error": f"verdict must be one of {sorted(_VALID_REVIEW_VERDICTS)!r}"},
|
|
1603
|
+
status_code=400,
|
|
1604
|
+
)
|
|
1605
|
+
if not findings_body:
|
|
1606
|
+
return JSONResponse(
|
|
1607
|
+
{"error": "body is required for record-review-verdict"},
|
|
1608
|
+
status_code=400,
|
|
1609
|
+
)
|
|
1610
|
+
# Look up the review assignment linked to this work assignment.
|
|
1611
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
1612
|
+
review_a = next(
|
|
1613
|
+
(
|
|
1614
|
+
a for a in all_assignments
|
|
1615
|
+
if a.review_of_assignment_id == assignment_id and a.type == "review"
|
|
1616
|
+
),
|
|
1617
|
+
None,
|
|
1618
|
+
)
|
|
1619
|
+
if review_a is None:
|
|
1620
|
+
return JSONResponse(
|
|
1621
|
+
{"error": "no review assignment found for this work assignment"},
|
|
1622
|
+
status_code=404,
|
|
1623
|
+
)
|
|
1624
|
+
from coord.notify import _persist_review_findings # noqa: PLC0415
|
|
1625
|
+
|
|
1626
|
+
try:
|
|
1627
|
+
_persist_review_findings(review_a.assignment_id, verdict, findings_body)
|
|
1628
|
+
except Exception as exc:
|
|
1629
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1630
|
+
return JSONResponse({"ok": True})
|
|
1631
|
+
|
|
1632
|
+
elif action == "retry":
|
|
1633
|
+
return JSONResponse(
|
|
1634
|
+
{"ok": False, "error": "'retry' is not yet implemented in the dashboard"},
|
|
1635
|
+
status_code=501,
|
|
1636
|
+
)
|
|
1637
|
+
|
|
1638
|
+
elif action == "dispatch_fix":
|
|
1639
|
+
parent_type = body.get("parent_type", "work")
|
|
1640
|
+
if parent_type not in ("work", "review"):
|
|
1641
|
+
return JSONResponse(
|
|
1642
|
+
{"error": f"parent_type must be 'work' or 'review', got {parent_type!r}"},
|
|
1643
|
+
status_code=400,
|
|
1644
|
+
)
|
|
1645
|
+
if not assignment.branch:
|
|
1646
|
+
return JSONResponse(
|
|
1647
|
+
{"ok": False, "error": "work assignment has no branch to fix"},
|
|
1648
|
+
status_code=400,
|
|
1649
|
+
)
|
|
1650
|
+
from coord.review import dispatch_headless_fix # noqa: PLC0415
|
|
1651
|
+
|
|
1652
|
+
try:
|
|
1653
|
+
result = dispatch_headless_fix(
|
|
1654
|
+
assignment, board, config, parent_type=parent_type
|
|
1655
|
+
)
|
|
1656
|
+
except Exception as exc:
|
|
1657
|
+
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
|
|
1658
|
+
if result:
|
|
1659
|
+
_write_board(board)
|
|
1660
|
+
return JSONResponse({
|
|
1661
|
+
"ok": True,
|
|
1662
|
+
"machine_name": result.machine_name,
|
|
1663
|
+
"assignment_id": result.assignment_id,
|
|
1664
|
+
"branch": result.branch,
|
|
1665
|
+
})
|
|
1666
|
+
return JSONResponse({
|
|
1667
|
+
"ok": False,
|
|
1668
|
+
"error": (
|
|
1669
|
+
"could not dispatch fix — no capable machine, branch missing, "
|
|
1670
|
+
"findings unresolvable, or max review iterations reached"
|
|
1671
|
+
),
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
else:
|
|
1675
|
+
return JSONResponse(
|
|
1676
|
+
{"error": f"unknown action: {action!r}"}, status_code=400
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
async def terminal_ws(websocket: WebSocket) -> None:
|
|
1680
|
+
"""Human-attended PTY<->WebSocket bridge for a live tmux session (#1065).
|
|
1681
|
+
|
|
1682
|
+
ToS §3.7 / #437: relays a live human only -- browser keystrokes (binary
|
|
1683
|
+
frames) to the PTY's stdin, PTY stdout back as binary frames, plus
|
|
1684
|
+
JSON text control messages:
|
|
1685
|
+
|
|
1686
|
+
* ``{"type": "resize", "cols": .., "rows": ..}`` -- propagate a
|
|
1687
|
+
terminal resize to the PTY (``TIOCSWINSZ``).
|
|
1688
|
+
* ``{"type": "copy-mode", "action": "enter"|"exit"|"page-up"|\
|
|
1689
|
+
"page-down"|"top"|"bottom"}`` -- drive tmux copy-mode via
|
|
1690
|
+
``tmux send-keys -X`` / ``tmux copy-mode`` so the phone's Scroll
|
|
1691
|
+
button can reach pane history without knowing the user's prefix
|
|
1692
|
+
key or mode-keys setting (#1299).
|
|
1693
|
+
|
|
1694
|
+
No autonomous injection or scraping happens here.
|
|
1695
|
+
|
|
1696
|
+
Auth: requires ``?token=`` to match the dashboard's configured bearer
|
|
1697
|
+
token (browsers can't set custom headers on a WS upgrade, so it can't
|
|
1698
|
+
travel as an ``Authorization`` header like the REST API's). No token
|
|
1699
|
+
configured on the server => open, matching `coord serve`'s
|
|
1700
|
+
``resolve_serve_token`` convention. A token is configured but missing
|
|
1701
|
+
/ wrong on the request => the connection is accepted and then closed
|
|
1702
|
+
immediately with 4401 (see the accept-then-close note below); no PTY
|
|
1703
|
+
is ever attached, so nothing is relayed to an unauthenticated client.
|
|
1704
|
+
|
|
1705
|
+
Accept-then-close (#1071 live-smoke fix): both rejection paths below
|
|
1706
|
+
MUST ``accept()`` the handshake before ``close(code=...)``. Per the
|
|
1707
|
+
ASGI/WebSocket spec an application close code can only be delivered
|
|
1708
|
+
over an *accepted* connection -- closing pre-accept aborts the HTTP
|
|
1709
|
+
upgrade instead, which reaches the browser as a plain ``403`` with no
|
|
1710
|
+
code attached. The client (`webapp/src/components/Terminal.tsx`)
|
|
1711
|
+
tells "this session is gone for good" (4404, a terminal state) apart
|
|
1712
|
+
from "transient drop, reconnect with backoff" purely by the close
|
|
1713
|
+
code, so a pre-accept close made every unknown session look like a
|
|
1714
|
+
transient drop and the client retried it forever. Accepting first
|
|
1715
|
+
costs one extra round trip on an already-failing request and makes
|
|
1716
|
+
the close code actually arrive.
|
|
1717
|
+
"""
|
|
1718
|
+
# Consume the ASGI "websocket.connect" event before we can accept()
|
|
1719
|
+
# or close() the handshake.
|
|
1720
|
+
await websocket.receive()
|
|
1721
|
+
await websocket.accept()
|
|
1722
|
+
|
|
1723
|
+
if token and websocket.query_params.get("token") != token:
|
|
1724
|
+
await websocket.close(code=4401)
|
|
1725
|
+
return
|
|
1726
|
+
|
|
1727
|
+
session_id = websocket.path_params["session_id"]
|
|
1728
|
+
board = _read_board()
|
|
1729
|
+
target = resolve_session_target(session_id, board, config)
|
|
1730
|
+
if target is None:
|
|
1731
|
+
await websocket.close(code=4404)
|
|
1732
|
+
return
|
|
1733
|
+
host, session_name = target
|
|
1734
|
+
|
|
1735
|
+
try:
|
|
1736
|
+
attached = await attacher.attach(host, session_name)
|
|
1737
|
+
except Exception:
|
|
1738
|
+
await websocket.close(code=1011)
|
|
1739
|
+
return
|
|
1740
|
+
|
|
1741
|
+
async def _pump_output() -> None:
|
|
1742
|
+
try:
|
|
1743
|
+
while True:
|
|
1744
|
+
chunk = await attached.read()
|
|
1745
|
+
if not chunk:
|
|
1746
|
+
break
|
|
1747
|
+
await websocket.send_bytes(chunk)
|
|
1748
|
+
except Exception:
|
|
1749
|
+
pass
|
|
1750
|
+
|
|
1751
|
+
reader_task = asyncio.create_task(_pump_output())
|
|
1752
|
+
try:
|
|
1753
|
+
while True:
|
|
1754
|
+
message = await websocket.receive()
|
|
1755
|
+
if message["type"] == "websocket.disconnect":
|
|
1756
|
+
break
|
|
1757
|
+
data = message.get("bytes")
|
|
1758
|
+
if data is not None:
|
|
1759
|
+
attached.write(data)
|
|
1760
|
+
continue
|
|
1761
|
+
text = message.get("text")
|
|
1762
|
+
if text is None:
|
|
1763
|
+
continue
|
|
1764
|
+
try:
|
|
1765
|
+
payload = json.loads(text)
|
|
1766
|
+
except (json.JSONDecodeError, TypeError):
|
|
1767
|
+
continue
|
|
1768
|
+
if payload.get("type") == "resize":
|
|
1769
|
+
try:
|
|
1770
|
+
cols = int(payload.get("cols", 0))
|
|
1771
|
+
rows = int(payload.get("rows", 0))
|
|
1772
|
+
except (TypeError, ValueError):
|
|
1773
|
+
continue
|
|
1774
|
+
if cols > 0 and rows > 0:
|
|
1775
|
+
attached.resize(cols, rows)
|
|
1776
|
+
elif payload.get("type") == "copy-mode":
|
|
1777
|
+
action = payload.get("action")
|
|
1778
|
+
if isinstance(action, str):
|
|
1779
|
+
await attached.copy_mode(action)
|
|
1780
|
+
except WebSocketDisconnect:
|
|
1781
|
+
pass
|
|
1782
|
+
finally:
|
|
1783
|
+
reader_task.cancel()
|
|
1784
|
+
# Detach only -- NEVER kill the underlying tmux session (#1065).
|
|
1785
|
+
attached.detach()
|
|
1786
|
+
|
|
1787
|
+
routes = [
|
|
1788
|
+
Route("/", index, methods=["GET"]),
|
|
1789
|
+
Route("/api/board", api_board, methods=["GET"]),
|
|
1790
|
+
Route("/api/machines", api_machines, methods=["GET"]),
|
|
1791
|
+
Route("/api/sessions", api_sessions, methods=["GET"]),
|
|
1792
|
+
Route("/api/proposals", api_proposals, methods=["GET"]),
|
|
1793
|
+
Route("/api/approve", api_approve, methods=["POST"]),
|
|
1794
|
+
Route("/api/reject", api_reject, methods=["POST"]),
|
|
1795
|
+
Route("/api/diff/{id}", api_diff, methods=["GET"]),
|
|
1796
|
+
Route("/api/chat", api_chat, methods=["POST"]),
|
|
1797
|
+
Route("/api/pipeline", api_pipeline, methods=["GET"]),
|
|
1798
|
+
Route("/api/pipeline/action", api_pipeline_action, methods=["POST"]),
|
|
1799
|
+
build_events_route(event_source),
|
|
1800
|
+
WebSocketRoute("/ws/terminal/{session_id}", terminal_ws),
|
|
1801
|
+
]
|
|
1802
|
+
# #757: served OpenAPI 3 spec + Swagger UI docs page.
|
|
1803
|
+
routes.extend(openapi_and_docs_routes(openapi_spec()))
|
|
1804
|
+
|
|
1805
|
+
# ── Fixture-mode introspection (#1538) ─────────────────────────────────
|
|
1806
|
+
# Registered ONLY under `coord web --fixture`, so the live dashboard's
|
|
1807
|
+
# route table and OpenAPI inventory are byte-for-byte unchanged. These are
|
|
1808
|
+
# the assertion surface: what would have been dispatched, and a
|
|
1809
|
+
# deterministic trigger for the fixture's scripted SSE sequence.
|
|
1810
|
+
if _fixture is not None:
|
|
1811
|
+
|
|
1812
|
+
async def api_fixture_actions(request: Request) -> JSONResponse:
|
|
1813
|
+
"""GET/DELETE /api/fixture/actions — the recorded-write log."""
|
|
1814
|
+
if request.method == "DELETE":
|
|
1815
|
+
return JSONResponse({"cleared": _fixture.clear_actions()})
|
|
1816
|
+
return JSONResponse(
|
|
1817
|
+
{"actions": [a.to_dict() for a in _fixture.actions]}
|
|
1818
|
+
)
|
|
1819
|
+
|
|
1820
|
+
async def api_fixture_replay(request: Request) -> JSONResponse:
|
|
1821
|
+
"""POST /api/fixture/events/replay — run the scripted SSE sequence."""
|
|
1822
|
+
asyncio.create_task(_play_event_script())
|
|
1823
|
+
return JSONResponse({"ok": True, "count": len(_fixture.events)})
|
|
1824
|
+
|
|
1825
|
+
async def api_fixture_publish(request: Request) -> JSONResponse:
|
|
1826
|
+
"""POST /api/fixture/events — publish one ad-hoc SSE event now."""
|
|
1827
|
+
try:
|
|
1828
|
+
body = await request.json()
|
|
1829
|
+
except ValueError:
|
|
1830
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
1831
|
+
etype = body.get("type")
|
|
1832
|
+
if not etype or not isinstance(etype, str):
|
|
1833
|
+
return JSONResponse({"error": "type is required"}, status_code=400)
|
|
1834
|
+
event = event_source.publish(etype, body.get("data"))
|
|
1835
|
+
return JSONResponse({"ok": True, "id": event.id})
|
|
1836
|
+
|
|
1837
|
+
routes.extend([
|
|
1838
|
+
Route(
|
|
1839
|
+
"/api/fixture/actions",
|
|
1840
|
+
api_fixture_actions,
|
|
1841
|
+
methods=["GET", "DELETE"],
|
|
1842
|
+
include_in_schema=False,
|
|
1843
|
+
),
|
|
1844
|
+
Route(
|
|
1845
|
+
"/api/fixture/events/replay",
|
|
1846
|
+
api_fixture_replay,
|
|
1847
|
+
methods=["POST"],
|
|
1848
|
+
include_in_schema=False,
|
|
1849
|
+
),
|
|
1850
|
+
Route(
|
|
1851
|
+
"/api/fixture/events",
|
|
1852
|
+
api_fixture_publish,
|
|
1853
|
+
methods=["POST"],
|
|
1854
|
+
include_in_schema=False,
|
|
1855
|
+
),
|
|
1856
|
+
])
|
|
1857
|
+
|
|
1858
|
+
# ── Static file serving for the built React webapp ─────────────────────
|
|
1859
|
+
# Only activated when the resolved dist dir exists (i.e. after
|
|
1860
|
+
# `npm run build`, into either coord/dashboard/webapp/dist/ or the
|
|
1861
|
+
# --dist/COORD_WEB_DIST override — #1543). When absent the routes list
|
|
1862
|
+
# is unchanged and the legacy dashboard serves normally — no
|
|
1863
|
+
# test-suite impact.
|
|
1864
|
+
if webapp_dist.exists():
|
|
1865
|
+
# /assets/ — Vite hashed JS/CSS bundles (immutable; safe to cache).
|
|
1866
|
+
_assets = webapp_dist / "assets"
|
|
1867
|
+
if _assets.exists():
|
|
1868
|
+
routes.append(
|
|
1869
|
+
Mount("/assets", StaticFiles(directory=str(_assets)), name="assets")
|
|
1870
|
+
)
|
|
1871
|
+
|
|
1872
|
+
async def _spa_catch_all(request: Request) -> FileResponse | HTMLResponse:
|
|
1873
|
+
"""Serve exact static files from dist/ or SPA index.html fallback.
|
|
1874
|
+
|
|
1875
|
+
Handles three cases:
|
|
1876
|
+
- Known static roots (sw.js, manifest.webmanifest, icons/, …)
|
|
1877
|
+
→ served as the actual file so the browser gets correct MIME types.
|
|
1878
|
+
- SPA client-side routes (/issues/42, /pipeline, …)
|
|
1879
|
+
→ serve index.html; the React router takes over.
|
|
1880
|
+
"""
|
|
1881
|
+
path = request.path_params.get("path", "")
|
|
1882
|
+
candidate = webapp_dist / path
|
|
1883
|
+
if candidate.is_file():
|
|
1884
|
+
return FileResponse(str(candidate))
|
|
1885
|
+
# SPA fallback — let the React router handle the path.
|
|
1886
|
+
return HTMLResponse((webapp_dist / "index.html").read_text())
|
|
1887
|
+
|
|
1888
|
+
# Not part of the JSON API contract (client-side-router fallback) —
|
|
1889
|
+
# excluded from the OpenAPI route inventory via include_in_schema.
|
|
1890
|
+
routes.append(
|
|
1891
|
+
Route("/{path:path}", _spa_catch_all, methods=["GET"], include_in_schema=False)
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
return Starlette(routes=routes, lifespan=_lifespan)
|