Skip to content

Commit fdea8ce

Browse files
feat(batch): live batch-status SSE in Living Docs (#67)
* feat(batch): live batch-status SSE endpoint (backend) Add GET /api/batch/status/stream — Server-Sent Events stream of the pending attune-author maintenance batch, observe-only. Polls status_maintenance_batch via asyncio.to_thread (sync + network I/O), emits one data: frame per poll, and self-terminates on no-batch (BatchStateError → "none"), unexpected error, or terminal status. Cadence via ATTUNE_GUI_BATCH_POLL_SECS (default 30). Implements specs/gui-batch-status-sse tasks 1-5,7. Frontend panel (task 6) and docs (task 8) follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(batch): Living Docs batch-progress panel + docs Add an observe-only "Batch progress" panel to the Living Docs page, driven by EventSource over GET /api/batch/status/stream. Renders queued → in-progress (n/N) → complete with a progress bar; hides when no batch is pending. Closes the stream on none/error/terminal frames to suppress browser auto-reconnect loops. README: document the panel and ATTUNE_GUI_BATCH_POLL_SECS. Completes specs/gui-batch-status-sse tasks 6 + 8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b8d9303 commit fdea8ce

5 files changed

Lines changed: 383 additions & 1 deletion

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Sidebar nav with seven pages, each consuming the existing JSON API:
1515
| **Templates** | Markdown templates with mtime staleness, tags, and a manual-pin toggle |
1616
| **Specs** | Feature specs in `specs/` with phase + status badges. **+ New spec** bootstraps from `TEMPLATE.md`; **+ Design / + Tasks** inline; status dropdown in Preview |
1717
| **Summaries** | Inline-editable `summaries.json` with overwrite warning |
18-
| **Living Docs** | Workspace editor, scan trigger, document health, review queue, RAG quality bars |
18+
| **Living Docs** | Workspace editor, scan trigger, document health, review queue, RAG quality bars, live batch-status panel |
1919
| **Commands** | Run any registered command from a card grid (RAG queries, regen, maintain, …) |
2020
| **Jobs** | Job history with per-feature progress, last-output column, Cancel button, auto-refresh |
2121

@@ -155,6 +155,16 @@ attune-gui config unset specs_root # remove a key
155155
others trust you. Workspace can also be set via **Living Docs →
156156
Workspace** in the UI — both surfaces write to the same file.
157157

158+
### Batch-status panel
159+
160+
When you run an attune-author maintenance batch from the CLI
161+
(`attune-author regenerate --batch`, checked with `--status`), the
162+
**Living Docs** page shows a live **Batch progress** panel — queued →
163+
in-progress (n/N) → complete — streamed over Server-Sent Events from
164+
`GET /api/batch/status/stream`. The panel is observe-only (it does not
165+
start a batch) and hides itself when no batch is pending. Set
166+
`ATTUNE_GUI_BATCH_POLL_SECS` (default `30`) to change the poll cadence.
167+
158168
## Development
159169

160170
```bash

sidecar/attune_gui/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from attune_gui import __version__
1818
from attune_gui.errors import install_handlers as install_error_handlers
1919
from attune_gui.routes import ( # noqa: F401
20+
batch,
2021
choices,
2122
cowork_files,
2223
cowork_health,
@@ -79,6 +80,7 @@ def create_app() -> FastAPI:
7980
app.include_router(fs.router)
8081
app.include_router(rag.router)
8182
app.include_router(jobs.router)
83+
app.include_router(batch.router)
8284
app.include_router(choices.router)
8385
app.include_router(help.router)
8486
app.include_router(search.router)

sidecar/attune_gui/routes/batch.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Batch-status SSE route.
2+
3+
GET /api/batch/status/stream — Server-Sent Events stream of the pending
4+
attune-author maintenance batch (queued → in-progress → terminal).
5+
6+
Observe-only: the dashboard watches a batch kicked off from the CLI; it
7+
does not trigger one (that is a separate spec, ``gui-batch-trigger``).
8+
Backed by ``attune_author.maintenance_batch.status_maintenance_batch``,
9+
which is synchronous and does network I/O, so every poll runs in a
10+
worker thread to keep the single-process event loop responsive.
11+
12+
See ``specs/gui-batch-status-sse/`` for the design.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import json
19+
import logging
20+
import os
21+
from pathlib import Path
22+
from typing import Any
23+
24+
from fastapi import APIRouter, Request
25+
from fastapi.responses import StreamingResponse
26+
27+
from attune_gui.workspace import get_workspace
28+
29+
logger = logging.getLogger(__name__)
30+
31+
router = APIRouter(prefix="/api/batch", tags=["batch"])
32+
33+
#: ``processing_status`` values (and the presence of ``ended_at``) that mean
34+
#: the batch has stopped; the stream emits a final frame and closes.
35+
_TERMINAL = {"ended", "canceled", "expired"}
36+
37+
_DEFAULT_POLL_SECS = 30
38+
39+
40+
def _poll_secs() -> int:
41+
"""Resolve the poll cadence from ``ATTUNE_GUI_BATCH_POLL_SECS`` (default 30)."""
42+
raw = os.getenv("ATTUNE_GUI_BATCH_POLL_SECS", str(_DEFAULT_POLL_SECS))
43+
try:
44+
return max(1, int(raw))
45+
except ValueError:
46+
return _DEFAULT_POLL_SECS
47+
48+
49+
def _get_help_dir() -> Path:
50+
"""The ``.help`` directory under the configured workspace.
51+
52+
Mirrors the living-docs routes' resolver: configured workspace, or the
53+
process cwd as a fallback.
54+
"""
55+
root = get_workspace() or Path.cwd()
56+
return root / ".help"
57+
58+
59+
def _sse(payload: dict[str, Any]) -> str:
60+
"""Format one dict as an SSE ``data:`` frame."""
61+
return f"data: {json.dumps(payload)}\n\n"
62+
63+
64+
def _status_once(help_dir: Path) -> dict[str, Any]:
65+
"""One status query. Synchronous; run via ``asyncio.to_thread``.
66+
67+
Returns a frame dict with a ``state`` discriminator:
68+
- ``pending`` + the full ``status_maintenance_batch`` payload, or
69+
- ``none`` when there is no (or an expired) pending batch.
70+
Raises on unexpected errors — the caller renders an ``error`` frame.
71+
"""
72+
from attune_author.maintenance_batch import ( # noqa: PLC0415
73+
BatchStateError,
74+
status_maintenance_batch,
75+
)
76+
77+
try:
78+
status = status_maintenance_batch(help_dir)
79+
except BatchStateError:
80+
# No pending batch, or the local state expired. CLI shows
81+
# "no pending batch"; the dashboard mirrors that as ``none``.
82+
return {"state": "none"}
83+
return {"state": "pending", **status}
84+
85+
86+
async def _events(request: Request, help_dir: Path):
87+
"""SSE generator: poll until disconnect, terminal status, or none/error."""
88+
while True:
89+
if await request.is_disconnected():
90+
return
91+
try:
92+
frame = await asyncio.to_thread(_status_once, help_dir)
93+
except Exception as e: # noqa: BLE001 - surface, then close
94+
logger.warning("batch status poll failed: %s", e)
95+
yield _sse({"state": "error", "detail": str(e)})
96+
return
97+
98+
yield _sse(frame)
99+
100+
if frame["state"] != "pending":
101+
return # ``none`` — one frame, then close.
102+
status = frame
103+
if status.get("processing_status") in _TERMINAL or status.get("ended_at"):
104+
return # terminal — final frame already sent, then close.
105+
106+
await asyncio.sleep(_poll_secs())
107+
108+
109+
@router.get("/status/stream")
110+
async def stream_status(request: Request) -> StreamingResponse:
111+
"""Stream the pending batch's status as Server-Sent Events.
112+
113+
Emits one ``data:`` frame per poll. Closes after a ``none``/``error``
114+
frame or once the batch reaches a terminal state. Read-only GET — the
115+
app-wide origin guard applies; no client token is required (matching
116+
the other read endpoints).
117+
"""
118+
help_dir = _get_help_dir()
119+
return StreamingResponse(
120+
_events(request, help_dir),
121+
media_type="text/event-stream",
122+
headers={
123+
"Cache-Control": "no-cache",
124+
"X-Accel-Buffering": "no", # disable proxy buffering for SSE
125+
},
126+
)

sidecar/attune_gui/templates/living_docs.html

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ <h2 class="section-title">Workspace</h2>
2323
</div>
2424
</section>
2525

26+
<section class="section" id="batch-section" hidden>
27+
<h2 class="section-title">Batch progress</h2>
28+
<div class="card">
29+
<div class="qbar">
30+
<div class="qbar-head">
31+
<span id="batch-label">Waiting…</span>
32+
<span id="batch-counts" class="dim small"></span>
33+
</div>
34+
<div class="qbar-track">
35+
<div id="batch-fill" class="qbar-fill accent" style="width: 0%"></div>
36+
</div>
37+
</div>
38+
<div id="batch-detail" class="dim small" style="margin-top: .4rem"></div>
39+
</div>
40+
</section>
41+
2642
<section class="section">
2743
<h2 class="section-title">Health</h2>
2844
<div class="grid grid-4">
@@ -341,5 +357,55 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
341357
AttuneUI.toast(`Scan failed: ${e.message}`, 'danger');
342358
}
343359
});
360+
361+
// ── Batch progress (SSE, observe-only) ────────────────────────────────────
362+
// Streams GET /api/batch/status/stream. The server sends one frame per poll
363+
// and closes after a none/error/terminal frame; we es.close() on those so the
364+
// browser does not auto-reconnect into a none → close → reconnect loop. New
365+
// batches started later are picked up on the next page load.
366+
(function batchProgress() {
367+
const section = document.getElementById('batch-section');
368+
if (!section || typeof EventSource === 'undefined') return;
369+
const label = document.getElementById('batch-label');
370+
const counts = document.getElementById('batch-counts');
371+
const fill = document.getElementById('batch-fill');
372+
const detail = document.getElementById('batch-detail');
373+
374+
const TERMINAL = ['ended', 'canceled', 'expired'];
375+
const isTerminal = (f) => TERMINAL.includes(f.processing_status) || !!f.ended_at;
376+
377+
function render(frame) {
378+
if (frame.state === 'none') { section.hidden = true; return; }
379+
section.hidden = false;
380+
if (frame.state === 'error') {
381+
label.textContent = 'Status unavailable';
382+
counts.textContent = '';
383+
detail.textContent = frame.detail || 'retry';
384+
return;
385+
}
386+
const c = frame.request_counts || {};
387+
const total = frame.request_count || 0;
388+
const done = (c.succeeded || 0) + (c.errored || 0) + (c.canceled || 0) + (c.expired || 0);
389+
const pct = total ? Math.round((done / total) * 100) : 0;
390+
fill.style.width = pct + '%';
391+
counts.textContent = total ? `${done}/${total}` : '';
392+
if (isTerminal(frame)) {
393+
label.textContent = `Completed — ${c.succeeded || 0} succeeded, ${c.errored || 0} errored`;
394+
} else {
395+
label.textContent = frame.processing_status || 'processing';
396+
}
397+
detail.textContent = frame.batch_id ? `batch ${frame.batch_id}` : '';
398+
}
399+
400+
const es = new EventSource('/api/batch/status/stream');
401+
es.onmessage = (ev) => {
402+
let frame;
403+
try { frame = JSON.parse(ev.data); } catch { return; }
404+
render(frame);
405+
// Server's last frame for these states — close to suppress reconnect.
406+
if (frame.state !== 'pending' || isTerminal(frame)) es.close();
407+
};
408+
// Transient drops: let the browser auto-reconnect (no handler needed).
409+
})();
344410
</script>
345411
{% endblock %}

0 commit comments

Comments
 (0)