code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/events.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""SSE event source for the coordinator and live-log streaming for agents.
|
|
2
|
+
|
|
3
|
+
The dashboard (#9) mounts these on its HTTP server. Two things live here:
|
|
4
|
+
|
|
5
|
+
1. `EventSource` — fan-out hub. Producers call `publish(event_type, data)`;
|
|
6
|
+
subscribers each get their own `asyncio.Queue`, so a slow client never
|
|
7
|
+
blocks a fast one. Events carry a monotonic `id`, and a small ring buffer
|
|
8
|
+
lets reconnecting clients resume via `Last-Event-ID`.
|
|
9
|
+
|
|
10
|
+
2. `sse_response(source, last_event_id=...)` — wraps the source as a Starlette
|
|
11
|
+
`StreamingResponse` with the right headers (`text/event-stream`, no-cache,
|
|
12
|
+
keepalive).
|
|
13
|
+
|
|
14
|
+
3. `stream_assignment_log(agent, assignment_id)` — async generator that tails
|
|
15
|
+
the assignment log file and yields SSE-formatted chunks. The event `id` is
|
|
16
|
+
the byte offset, which doubles as a `since` cursor for resume.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import time
|
|
24
|
+
from collections import deque
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, AsyncIterator
|
|
28
|
+
|
|
29
|
+
from starlette.requests import Request
|
|
30
|
+
from starlette.responses import StreamingResponse
|
|
31
|
+
from starlette.routing import Route
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Event types (the wire vocabulary) ───────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
ASSIGNMENT_STARTED = "assignment_started"
|
|
37
|
+
ASSIGNMENT_COMPLETED = "assignment_completed"
|
|
38
|
+
ASSIGNMENT_FAILED = "assignment_failed"
|
|
39
|
+
MACHINE_CONNECTED = "machine_connected"
|
|
40
|
+
MACHINE_DISCONNECTED = "machine_disconnected"
|
|
41
|
+
BOARD_UPDATED = "board_updated"
|
|
42
|
+
|
|
43
|
+
KNOWN_EVENT_TYPES = frozenset({
|
|
44
|
+
ASSIGNMENT_STARTED,
|
|
45
|
+
ASSIGNMENT_COMPLETED,
|
|
46
|
+
ASSIGNMENT_FAILED,
|
|
47
|
+
MACHINE_CONNECTED,
|
|
48
|
+
MACHINE_DISCONNECTED,
|
|
49
|
+
BOARD_UPDATED,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Tunables ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
DEFAULT_HISTORY_SIZE = 256
|
|
56
|
+
DEFAULT_QUEUE_MAXSIZE = 128
|
|
57
|
+
DEFAULT_KEEPALIVE_SECONDS = 15.0
|
|
58
|
+
LOG_POLL_INTERVAL = 0.25
|
|
59
|
+
LOG_CHUNK_SIZE = 4096
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Event:
|
|
64
|
+
"""One thing that happened, ready to serialize as SSE."""
|
|
65
|
+
|
|
66
|
+
id: int
|
|
67
|
+
type: str
|
|
68
|
+
data: Any
|
|
69
|
+
created_at: float = field(default_factory=time.time)
|
|
70
|
+
|
|
71
|
+
def to_sse(self) -> str:
|
|
72
|
+
# SSE requires data: prefix on every line. JSON-encode the payload so
|
|
73
|
+
# multi-line strings and structured data both round-trip cleanly.
|
|
74
|
+
payload = json.dumps(self.data, default=str)
|
|
75
|
+
lines = [f"id: {self.id}", f"event: {self.type}"]
|
|
76
|
+
for chunk in payload.splitlines() or [""]:
|
|
77
|
+
lines.append(f"data: {chunk}")
|
|
78
|
+
return "\n".join(lines) + "\n\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EventSource:
|
|
82
|
+
"""In-memory pub/sub. One instance per coordinator process.
|
|
83
|
+
|
|
84
|
+
Subscribers each get an independent `asyncio.Queue`. If a queue fills, the
|
|
85
|
+
oldest event for that subscriber is dropped — bounded memory beats global
|
|
86
|
+
backpressure here. A short ring buffer of recent events lets reconnecting
|
|
87
|
+
clients resume from `Last-Event-ID`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
history_size: int = DEFAULT_HISTORY_SIZE,
|
|
94
|
+
queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._next_id = 1
|
|
97
|
+
self._history: deque[Event] = deque(maxlen=history_size)
|
|
98
|
+
self._subscribers: set[asyncio.Queue[Event]] = set()
|
|
99
|
+
self._queue_maxsize = queue_maxsize
|
|
100
|
+
self._lock = asyncio.Lock()
|
|
101
|
+
|
|
102
|
+
# ── Producer side ──────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def publish(self, event_type: str, data: Any) -> Event:
|
|
105
|
+
"""Record an event and fan it out to current subscribers.
|
|
106
|
+
|
|
107
|
+
Called from the same event loop that owns the queues. Synchronous —
|
|
108
|
+
producers don't await — so this is cheap to call from board mutation
|
|
109
|
+
sites. If a subscriber's queue is full we drop its oldest event so we
|
|
110
|
+
can still enqueue the new one.
|
|
111
|
+
"""
|
|
112
|
+
event = Event(id=self._next_id, type=event_type, data=data)
|
|
113
|
+
self._next_id += 1
|
|
114
|
+
self._history.append(event)
|
|
115
|
+
for queue in list(self._subscribers):
|
|
116
|
+
self._offer(queue, event)
|
|
117
|
+
return event
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _offer(queue: asyncio.Queue[Event], event: Event) -> None:
|
|
121
|
+
try:
|
|
122
|
+
queue.put_nowait(event)
|
|
123
|
+
except asyncio.QueueFull:
|
|
124
|
+
try:
|
|
125
|
+
queue.get_nowait()
|
|
126
|
+
except asyncio.QueueEmpty:
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
queue.put_nowait(event)
|
|
130
|
+
except asyncio.QueueFull:
|
|
131
|
+
# Concurrent subscriber pop won the race; give up silently.
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# ── Consumer side ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async def subscribe(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
last_event_id: int | None = None,
|
|
140
|
+
) -> AsyncIterator[Event]:
|
|
141
|
+
"""Yield events as they're published. Catches up via `last_event_id`.
|
|
142
|
+
|
|
143
|
+
The returned async generator must be closed (via `aclose()` or by
|
|
144
|
+
exiting `async for`) to release the subscriber slot. Reconnecting
|
|
145
|
+
clients pass their last seen id; we replay from history if we still
|
|
146
|
+
have it, otherwise they get only new events from now on.
|
|
147
|
+
"""
|
|
148
|
+
queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=self._queue_maxsize)
|
|
149
|
+
async with self._lock:
|
|
150
|
+
self._subscribers.add(queue)
|
|
151
|
+
backfill = self._backfill(last_event_id)
|
|
152
|
+
try:
|
|
153
|
+
for event in backfill:
|
|
154
|
+
yield event
|
|
155
|
+
while True:
|
|
156
|
+
event = await queue.get()
|
|
157
|
+
yield event
|
|
158
|
+
finally:
|
|
159
|
+
async with self._lock:
|
|
160
|
+
self._subscribers.discard(queue)
|
|
161
|
+
|
|
162
|
+
def _backfill(self, last_event_id: int | None) -> list[Event]:
|
|
163
|
+
if last_event_id is None:
|
|
164
|
+
return []
|
|
165
|
+
return [e for e in self._history if e.id > last_event_id]
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def subscriber_count(self) -> int:
|
|
169
|
+
return len(self._subscribers)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def next_id(self) -> int:
|
|
173
|
+
return self._next_id
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── SSE response helpers ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _parse_last_event_id(request: Request) -> int | None:
|
|
179
|
+
raw = request.headers.get("last-event-id")
|
|
180
|
+
if raw is None:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
return int(raw)
|
|
184
|
+
except ValueError:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def sse_response(
|
|
189
|
+
source: EventSource,
|
|
190
|
+
request: Request,
|
|
191
|
+
*,
|
|
192
|
+
keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
|
|
193
|
+
) -> StreamingResponse:
|
|
194
|
+
"""Wrap an EventSource as a Starlette streaming SSE response."""
|
|
195
|
+
|
|
196
|
+
last_event_id = _parse_last_event_id(request)
|
|
197
|
+
|
|
198
|
+
async def gen() -> AsyncIterator[bytes]:
|
|
199
|
+
# Initial retry hint and a comment so the client knows we're open even
|
|
200
|
+
# before any event is published.
|
|
201
|
+
yield b"retry: 2000\n\n"
|
|
202
|
+
async for chunk in _multiplex(
|
|
203
|
+
source.subscribe(last_event_id=last_event_id),
|
|
204
|
+
keepalive_seconds=keepalive_seconds,
|
|
205
|
+
request=request,
|
|
206
|
+
):
|
|
207
|
+
yield chunk
|
|
208
|
+
|
|
209
|
+
return StreamingResponse(
|
|
210
|
+
gen(),
|
|
211
|
+
media_type="text/event-stream",
|
|
212
|
+
headers={
|
|
213
|
+
"Cache-Control": "no-cache, no-transform",
|
|
214
|
+
"X-Accel-Buffering": "no",
|
|
215
|
+
"Connection": "keep-alive",
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_events_route(
|
|
221
|
+
source: EventSource,
|
|
222
|
+
*,
|
|
223
|
+
path: str = "/events",
|
|
224
|
+
keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
|
|
225
|
+
) -> Route:
|
|
226
|
+
"""Convenience: a Starlette Route mounting `source` at `path` as SSE.
|
|
227
|
+
|
|
228
|
+
The dashboard server (#9) mounts this on its app. Kept here so the wire
|
|
229
|
+
format (headers, keepalive cadence, `Last-Event-ID` handling) lives in one
|
|
230
|
+
place — callers shouldn't have to reimplement it.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
async def handler(request: Request) -> StreamingResponse:
|
|
234
|
+
return sse_response(source, request, keepalive_seconds=keepalive_seconds)
|
|
235
|
+
|
|
236
|
+
return Route(path, handler, methods=["GET"])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Board-state event helpers (called by board mutation sites) ──────────────
|
|
240
|
+
|
|
241
|
+
def publish_assignment_started(source: EventSource, assignment: Any) -> Event:
|
|
242
|
+
return source.publish(ASSIGNMENT_STARTED, _assignment_payload(assignment))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def publish_assignment_completed(source: EventSource, assignment: Any) -> Event:
|
|
246
|
+
return source.publish(ASSIGNMENT_COMPLETED, _assignment_payload(assignment))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def publish_assignment_failed(source: EventSource, assignment: Any) -> Event:
|
|
250
|
+
return source.publish(ASSIGNMENT_FAILED, _assignment_payload(assignment))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def publish_machine_connected(source: EventSource, machine_name: str) -> Event:
|
|
254
|
+
return source.publish(MACHINE_CONNECTED, {"machine": machine_name})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def publish_machine_disconnected(source: EventSource, machine_name: str) -> Event:
|
|
258
|
+
return source.publish(MACHINE_DISCONNECTED, {"machine": machine_name})
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def publish_board_updated(source: EventSource, summary: dict) -> Event:
|
|
262
|
+
return source.publish(BOARD_UPDATED, summary)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _assignment_payload(assignment: Any) -> dict:
|
|
266
|
+
"""Coerce an Assignment-like object to a JSON-safe dict for the wire."""
|
|
267
|
+
if hasattr(assignment, "to_dict"):
|
|
268
|
+
return assignment.to_dict()
|
|
269
|
+
if hasattr(assignment, "__dict__"):
|
|
270
|
+
return {k: v for k, v in vars(assignment).items() if not k.startswith("_")}
|
|
271
|
+
if isinstance(assignment, dict):
|
|
272
|
+
return assignment
|
|
273
|
+
return {"value": str(assignment)}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _multiplex(
|
|
277
|
+
events: AsyncIterator[Event],
|
|
278
|
+
*,
|
|
279
|
+
keepalive_seconds: float,
|
|
280
|
+
request: Request,
|
|
281
|
+
) -> AsyncIterator[bytes]:
|
|
282
|
+
"""Yield SSE bytes from `events`, sending a keepalive comment when idle.
|
|
283
|
+
|
|
284
|
+
Also bails out if the client disconnects, so we don't leak subscribers.
|
|
285
|
+
"""
|
|
286
|
+
aiter = events.__aiter__()
|
|
287
|
+
pending: asyncio.Task | None = None
|
|
288
|
+
try:
|
|
289
|
+
while True:
|
|
290
|
+
if await request.is_disconnected():
|
|
291
|
+
return
|
|
292
|
+
if pending is None:
|
|
293
|
+
pending = asyncio.create_task(aiter.__anext__())
|
|
294
|
+
try:
|
|
295
|
+
event = await asyncio.wait_for(
|
|
296
|
+
asyncio.shield(pending),
|
|
297
|
+
timeout=keepalive_seconds,
|
|
298
|
+
)
|
|
299
|
+
except asyncio.TimeoutError:
|
|
300
|
+
yield b": keepalive\n\n"
|
|
301
|
+
continue
|
|
302
|
+
except StopAsyncIteration:
|
|
303
|
+
pending = None
|
|
304
|
+
return
|
|
305
|
+
pending = None
|
|
306
|
+
yield event.to_sse().encode("utf-8")
|
|
307
|
+
finally:
|
|
308
|
+
if pending is not None:
|
|
309
|
+
pending.cancel()
|
|
310
|
+
try:
|
|
311
|
+
await pending
|
|
312
|
+
except (asyncio.CancelledError, StopAsyncIteration, Exception):
|
|
313
|
+
pass
|
|
314
|
+
await aiter.aclose()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Live log streaming for agent /stream/<id> ───────────────────────────────
|
|
318
|
+
|
|
319
|
+
async def stream_assignment_log(
|
|
320
|
+
log_path: Path,
|
|
321
|
+
*,
|
|
322
|
+
is_active: "callable",
|
|
323
|
+
request: Request,
|
|
324
|
+
start_offset: int = 0,
|
|
325
|
+
poll_interval: float = LOG_POLL_INTERVAL,
|
|
326
|
+
keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
|
|
327
|
+
) -> AsyncIterator[bytes]:
|
|
328
|
+
"""Tail `log_path` and yield SSE-formatted bytes.
|
|
329
|
+
|
|
330
|
+
`is_active()` returns True while the assignment is still RUNNING — we keep
|
|
331
|
+
polling for new bytes; once it returns False we drain anything that landed
|
|
332
|
+
after the last read and then send a terminal `event: end`. The event `id`
|
|
333
|
+
on each chunk is the byte offset after that chunk, so reconnecting clients
|
|
334
|
+
can pass `Last-Event-ID` to resume mid-stream.
|
|
335
|
+
"""
|
|
336
|
+
offset = max(0, int(start_offset))
|
|
337
|
+
last_yield = time.monotonic()
|
|
338
|
+
yield b"retry: 2000\n\n"
|
|
339
|
+
while True:
|
|
340
|
+
if await request.is_disconnected():
|
|
341
|
+
return
|
|
342
|
+
chunk, offset = _read_log_chunk(log_path, offset)
|
|
343
|
+
if chunk:
|
|
344
|
+
yield _format_log_event(offset, chunk)
|
|
345
|
+
last_yield = time.monotonic()
|
|
346
|
+
continue
|
|
347
|
+
if not is_active():
|
|
348
|
+
# One final drain after the worker exits — bytes can arrive
|
|
349
|
+
# between our last read and the status flip.
|
|
350
|
+
chunk, offset = _read_log_chunk(log_path, offset)
|
|
351
|
+
if chunk:
|
|
352
|
+
yield _format_log_event(offset, chunk)
|
|
353
|
+
yield f"id: {offset}\nevent: end\ndata: {{}}\n\n".encode("utf-8")
|
|
354
|
+
return
|
|
355
|
+
if time.monotonic() - last_yield >= keepalive_seconds:
|
|
356
|
+
yield b": keepalive\n\n"
|
|
357
|
+
last_yield = time.monotonic()
|
|
358
|
+
await asyncio.sleep(poll_interval)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read_log_chunk(log_path: Path, offset: int) -> tuple[str, int]:
|
|
362
|
+
if not log_path.exists():
|
|
363
|
+
return "", offset
|
|
364
|
+
try:
|
|
365
|
+
with open(log_path, "rb") as f:
|
|
366
|
+
f.seek(offset)
|
|
367
|
+
data = f.read(LOG_CHUNK_SIZE)
|
|
368
|
+
except OSError:
|
|
369
|
+
return "", offset
|
|
370
|
+
if not data:
|
|
371
|
+
return "", offset
|
|
372
|
+
return data.decode("utf-8", errors="replace"), offset + len(data)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _format_log_event(offset: int, text: str) -> bytes:
|
|
376
|
+
lines = [f"id: {offset}", "event: log"]
|
|
377
|
+
for line in text.splitlines() or [""]:
|
|
378
|
+
lines.append(f"data: {line}")
|
|
379
|
+
if text.endswith("\n"):
|
|
380
|
+
lines.append("data: ")
|
|
381
|
+
return ("\n".join(lines) + "\n\n").encode("utf-8")
|