Skip to content

Commit d946559

Browse files
feat(dashboard): testable Living Docs poll state machine (#71)
* docs(specs): living-docs-poller-testing — Phase 1 requirements (draft) Follow-on to dashboard-js-testing. Scope: extract + Vitest-test the Living Docs poll state machine (shouldKeepPolling / scheduling) using the approved no-build ES-module pattern. Behavior-preserving; no API change. The Jinja↔JS badge/action mapping duplication is noted but explicitly deferred to its own follow-up spec (needs an API-payload decision). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(specs): living-docs-poller-testing — design + tasks (approved) Phases 1-3 approved 2026-06-14. Extract shouldKeepPolling + POLL_INTERVAL_MS to a tested static_cw module; convert the poller block to type=module glue. Stacks on #68 for the static_cw infra + Vitest glob. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): extract Living Docs poll decision to a tested module Apply the dashboard-js-testing pattern to the Living Docs poller (specs/living-docs-poller-testing): - static_cw/living-docs-poller.js — pure shouldKeepPolling(rows) + POLL_INTERVAL_MS, DOM/timer-free; defensive over non-array / missing computed_state. - static_cw/living-docs-poller.test.js — 6 cases (stop/continue/empty/ defensive + cadence constant). - living_docs.html — poller <script> becomes <script type="module"> importing the module; inline .some(...) → shouldKeepPolling, 1500 → POLL_INTERVAL_MS. All timer/DOM/visibility glue unchanged. Vitest: 113 passed (poller + batch-panel + editor). Verified live: module 200, page intact, batch SSE still 200, no console errors — behavior identical. Stacks on #68. Badge/action map de-dup remains a separate deferred spec. 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 b22de54 commit d946559

6 files changed

Lines changed: 369 additions & 3 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Pure logic for the Living Docs row poller.
2+
//
3+
// DOM-free and timer-free on purpose: the recursive-setTimeout loop,
4+
// visibilitychange handling, fetch, and DOM rendering stay in the inline
5+
// shim in living_docs.html. This module holds only the decision the
6+
// poller branches on — extracted so it can be unit-tested (Vitest, Node).
7+
//
8+
// See specs/living-docs-poller-testing.
9+
10+
/** Poll cadence (ms) the shim reschedules on while rows are regenerating. */
11+
export const POLL_INTERVAL_MS = 1500;
12+
13+
/**
14+
* Keep polling iff at least one row is still regenerating.
15+
*
16+
* Input is the `rows` array from GET /api/living-docs/rows (objects with
17+
* a `computed_state`). Defensive against a non-array payload or rows
18+
* missing `computed_state` so a malformed response can't throw inside the
19+
* poll loop.
20+
*/
21+
export function shouldKeepPolling(rows) {
22+
return (
23+
Array.isArray(rows) &&
24+
rows.some((r) => r && r.computed_state === "regenerating")
25+
);
26+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { POLL_INTERVAL_MS, shouldKeepPolling } from "./living-docs-poller.js";
4+
5+
const row = (computed_state) => ({ id: "f/concept", computed_state });
6+
7+
describe("shouldKeepPolling", () => {
8+
it("is true when any row is regenerating", () => {
9+
expect(shouldKeepPolling([row("current"), row("regenerating"), row("stale")])).toBe(true);
10+
});
11+
12+
it("is false when no row is regenerating", () => {
13+
const rows = ["current", "stale", "missing", "pending-review", "errored"].map(row);
14+
expect(shouldKeepPolling(rows)).toBe(false);
15+
});
16+
17+
it("is false for an empty list", () => {
18+
expect(shouldKeepPolling([])).toBe(false);
19+
});
20+
21+
it("does not throw and is false for non-array input", () => {
22+
expect(shouldKeepPolling(undefined)).toBe(false);
23+
expect(shouldKeepPolling(null)).toBe(false);
24+
expect(shouldKeepPolling({})).toBe(false);
25+
});
26+
27+
it("does not throw on rows missing computed_state", () => {
28+
expect(shouldKeepPolling([{}, null, { computed_state: undefined }])).toBe(false);
29+
});
30+
});
31+
32+
describe("POLL_INTERVAL_MS", () => {
33+
it("pins the poll cadence the shim relies on", () => {
34+
expect(POLL_INTERVAL_MS).toBe(1500);
35+
});
36+
});

sidecar/attune_gui/templates/living_docs.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,12 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
168168
{% endblock %}
169169

170170
{% block scripts %}
171-
<script>
171+
<script type="module">
172+
// Pure poll decision (shouldKeepPolling / cadence) lives in the tested
173+
// module static_cw/living-docs-poller.js; the timer/DOM/visibility glue
174+
// stays here. See specs/living-docs-poller-testing.
175+
import { shouldKeepPolling, POLL_INTERVAL_MS } from '/cw-static/living-docs-poller.js';
176+
172177
// ── Polling ───────────────────────────────────────────────────────────────
173178
// Recursive setTimeout (not setInterval) so a hidden tab fully pauses
174179
// instead of waking every 1.5s to no-op.
@@ -197,12 +202,12 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
197202
try {
198203
const data = await AttuneUI.api('/api/living-docs/rows');
199204
_applyRows(data.rows);
200-
if (!data.rows.some(r => r.computed_state === 'regenerating')) {
205+
if (!shouldKeepPolling(data.rows)) {
201206
_stopPoll();
202207
return;
203208
}
204209
} catch (_) { /* transient — keep polling */ }
205-
_scheduleNextPoll(1500);
210+
_scheduleNextPoll(POLL_INTERVAL_MS);
206211
}
207212

208213
document.addEventListener('visibilitychange', () => {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Design: Testable Living Docs poll state machine
2+
3+
> Phase 2 design for `specs/living-docs-poller-testing/`. Read
4+
> `requirements.md` first. Single layer: **attune-gui**. Reuses the
5+
> no-build ES-module + Vitest pattern from
6+
> [`dashboard-js-testing`](../dashboard-js-testing/) (and depends on its
7+
> `static_cw/` infra + Vitest include glob — that PR merges first).
8+
9+
**Status**: approved (2026-06-14) — tasks: [`tasks.md`](tasks.md)
10+
11+
---
12+
13+
## Architecture
14+
15+
Same pattern as the batch panel: the **one pure decision** in the poller
16+
moves to a `static_cw/` ES module; the inline `<script>` becomes a
17+
`type="module"` shim that keeps all the imperative glue (timers, DOM,
18+
`visibilitychange`) and calls the module.
19+
20+
The poller's only branch-worthy, bug-prone logic is the **stop
21+
condition** at
22+
[`living_docs.html:200`](attune-gui/sidecar/attune_gui/templates/living_docs.html):
23+
24+
```js
25+
if (!data.rows.some(r => r.computed_state === 'regenerating')) { _stopPoll(); return; }
26+
27+
_scheduleNextPoll(1500);
28+
```
29+
30+
Extracted module:
31+
32+
```js
33+
// static_cw/living-docs-poller.js (NEW)
34+
export const POLL_INTERVAL_MS = 1500;
35+
36+
/** Keep polling iff at least one row is still regenerating. */
37+
export function shouldKeepPolling(rows) {
38+
return Array.isArray(rows) && rows.some(
39+
(r) => r && r.computed_state === "regenerating",
40+
);
41+
}
42+
```
43+
44+
Everything else — `_startPoll` / `_stopPoll` / `_scheduleNextPoll`
45+
(timers), the `visibilitychange` pause/resume, `_poll`'s fetch +
46+
try/catch, `_applyRows` / `_renderBadge` / `_renderActions`, the action
47+
button wiring, and the ws-form / scan-btn handlers — **stays inline**.
48+
The block is converted to `<script type="module">` so it can `import`;
49+
its functions were already block-scoped, and nothing external references
50+
them (handlers attach via `addEventListener`, not inline `onclick`), so
51+
module scope + `defer` semantics are safe.
52+
53+
The `_poll` change is one line:
54+
55+
```js
56+
import { shouldKeepPolling, POLL_INTERVAL_MS } from '/cw-static/living-docs-poller.js';
57+
58+
if (!shouldKeepPolling(data.rows)) { _stopPoll(); return; }
59+
60+
_scheduleNextPoll(POLL_INTERVAL_MS);
61+
```
62+
63+
> **Note on the initial-start check.** `_startPoll()` is also kicked off
64+
> on load by a DOM query (`tr[data-state="regenerating"]`). That reads
65+
> the rendered table, not row data, so it stays inline — `shouldKeepPolling`
66+
> operates on the `/api/living-docs/rows` payload, which is the poll path.
67+
68+
## API changes
69+
70+
None. Pure client refactor; the poller consumes the same
71+
`/api/living-docs/rows` shape.
72+
73+
## Data model changes
74+
75+
None.
76+
77+
## UI/UX
78+
79+
No change — polling cadence, start/stop behavior, hidden-tab pause, and
80+
all row rendering are identical. The only delta is `<script type="module">`
81+
(deferred) replacing the inline `<script>`; the block only attaches
82+
handlers and an optional initial poll, so deferral is safe. Verified
83+
live.
84+
85+
## Cross-layer impact
86+
87+
- **attune-gui** only: new `static_cw/living-docs-poller.js` + test, a
88+
one-block edit to `living_docs.html`.
89+
- Depends on `dashboard-js-testing` (#68) for the `static_cw/` convention
90+
+ the Vitest include glob — this work **stacks on that branch** and
91+
rebases onto `main` after it merges.
92+
93+
## Tradeoffs & alternatives
94+
95+
| Option | Pros | Cons | Chosen? |
96+
|--------|------|------|---------|
97+
| **Extract `shouldKeepPolling` + interval; keep glue inline** | Tests the one bug-prone decision; tiny, behavior-preserving; reuses the pattern | Most of the block stays untested (but it's imperative glue with no logic to test) ||
98+
| Extract the whole poller (timers + visibility) into the module with injected `setTimeout`/`document` | More "coverage" | Mocking timers/visibility is high-ceremony for low value; the glue has no branching worth it ||
99+
| Leave it inline | No change | The stop condition stays untested — the whole point ||
100+
| De-dup badge/action maps too | Fixes the bigger drift risk | Out of scope (own spec) — needs an API-payload decision | ❌ (deferred) |
101+
102+
## Testing strategy
103+
104+
`static_cw/living-docs-poller.test.js` (Vitest, Node env, no jsdom):
105+
106+
1. `shouldKeepPolling` → true when any row is `regenerating`.
107+
2. → false when no row is `regenerating` (mix of
108+
`current`/`stale`/`missing`/`pending-review`/`errored`).
109+
3. → false for `[]`.
110+
4. → false / no-throw for non-array input and rows with missing/`null`
111+
`computed_state` (defensive guards).
112+
5. `POLL_INTERVAL_MS` is exported and equals 1500 (pins the cadence the
113+
shim relies on).
114+
115+
Plus a live re-verify: load `/dashboard/living-docs`, confirm the page
116+
renders and (where a regenerating row exists) polling start/stop is
117+
unchanged; no console errors; module served 200.
118+
119+
## Rollback
120+
121+
One-commit revert: restore the inline `.some(...)` check + `1500`, drop
122+
`living-docs-poller.{js,test.js}`, revert the `<script>` tag. No
123+
API/data migration; other blocks untouched.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Spec: Testable Living Docs poll state machine
2+
3+
> Single-layer feature (attune-gui). Follow-on to
4+
> [`dashboard-js-testing`](../dashboard-js-testing/) — reuses its
5+
> approved no-build ES-module + Vitest pattern (build strategy and test
6+
> runner are already settled there; this spec does not re-litigate them).
7+
8+
---
9+
10+
## Phase 1: Requirements
11+
12+
**Status**: approved (2026-06-14)
13+
14+
### Problem statement
15+
16+
The Living Docs page polls `/api/living-docs/rows` while any document is
17+
regenerating
18+
([`living_docs.html`](attune-gui/sidecar/attune_gui/templates/living_docs.html),
19+
`{% block scripts %}`). The poller — `_startPoll` / `_stopPoll` /
20+
`_scheduleNextPoll` / `_poll` — is a recursive-`setTimeout` state machine
21+
that must:
22+
23+
- **stop** once no row is `regenerating`
24+
([`:200`](attune-gui/sidecar/attune_gui/templates/living_docs.html)),
25+
- **pause** on a hidden tab and resume on `visibilitychange`,
26+
- keep going on a transient fetch error (don't give up mid-regeneration).
27+
28+
This is exactly the kind of decision logic that breaks silently — polls
29+
forever, stops too early, or hammers a backgrounded tab — and it is
30+
**untested**. It's the next-richest untested inline block after the batch
31+
panel, and migrating it applies the pattern established in
32+
`dashboard-js-testing`.
33+
34+
### Scope
35+
36+
**In scope:**
37+
38+
- **Extract the poll decision logic** into a `static_cw/*.js` ES module —
39+
the pure parts: `shouldKeepPolling(rows)` (stop when none regenerating)
40+
and any next-delay helper. The module is DOM-free / timer-free so
41+
Vitest imports it in Node.
42+
- **Vitest coverage** for the extracted logic, run by the existing
43+
`frontend (Vitest)` CI job (the include glob from
44+
`dashboard-js-testing`).
45+
- **Shrink the inline script** to a `<script type="module">` glue shim
46+
that keeps the DOM/`setTimeout`/`visibilitychange` wiring and calls the
47+
module. Behavior-preserving.
48+
49+
**Out of scope:**
50+
51+
- **The Jinja↔JS `computed_state` → badge/action mapping duplication.**
52+
The same mapping is hand-maintained in Jinja
53+
([`:118-157`](attune-gui/sidecar/attune_gui/templates/living_docs.html))
54+
and in JS `_renderBadge`/`_renderActions`
55+
([`:228-256`](attune-gui/sidecar/attune_gui/templates/living_docs.html)).
56+
It's a real drift risk, but de-duplicating it (e.g. a server-provided
57+
presentational field) is a larger change with an API-payload impact —
58+
**deferred to its own follow-up spec**. This spec leaves both copies
59+
as-is; the glue shim still calls the existing `_renderBadge` /
60+
`_renderActions` unchanged.
61+
- Other inline blocks (`commands.html`, etc.) — separate follow-ups.
62+
- Re-deriving the build/test-runner choice — settled in
63+
`dashboard-js-testing`.
64+
- Any change to poll cadence, action behavior, or API semantics.
65+
66+
### User stories
67+
68+
1. **As a maintainer**, I want the poll stop/continue logic unit-tested
69+
so a change can't silently make the dashboard poll forever or stop
70+
mid-regeneration.
71+
2. **As a reviewer**, I want the extraction to be behavior-preserving and
72+
verified live before merge.
73+
74+
### Affected layers
75+
76+
- [x] attune-gui (frontend) — primary and only
77+
- [ ] attune-rag / help / author — no changes
78+
79+
### Coverage areas
80+
81+
| Area | Status | Notes |
82+
|------|--------|-------|
83+
| **Problem & scope** | addressed | Untested poll state machine; extract + test the pure decision. |
84+
| **Data & API contracts** | N/A | No API change — pure client refactor. |
85+
| **UI/UX & states** | addressed | No visual/behavior change; same polling cadence and badges. |
86+
| **Edge cases** | addressed | Empty rows, all-terminal rows (must stop), some-regenerating (must continue), hidden-tab pause, transient fetch error (keep polling). |
87+
| **Cross-layer impact** | N/A | Single layer. |
88+
| **Error handling** | addressed | Transient fetch error preserves current "keep polling" behavior; glue shim retains the try/catch. |
89+
| **Tradeoffs & alternatives** | addressed | Pure-logic extraction vs. leaving inline (defeats the purpose) vs. de-dup refactor (deferred — see Out of scope). |
90+
| **Rollback strategy** | addressed | Revert restores the inline poller; other blocks untouched. |
91+
92+
### Edge cases & open questions
93+
94+
| Question / Edge case | Resolution |
95+
|----------------------|------------|
96+
| Stop when no row regenerating | `shouldKeepPolling(rows)`: some `regenerating` → true; none → false; empty → false. Tested. |
97+
| Hidden-tab pause / resume | Stays in the DOM shim (`document.hidden`, `visibilitychange`); not in the pure module. |
98+
| Transient fetch error mid-poll | Shim keeps the existing try/catch + reschedule; `shouldKeepPolling` only decides from row data, not fetch outcome. |
99+
| Does `_applyRows` / `_renderBadge` / `_renderActions` move? | **No** — DOM rendering stays inline this spec (the badge/action de-dup is a separate deferred spec). Only the poll decision is extracted. |
100+
| What is `shouldKeepPolling`'s input shape? | The `rows` array from `/api/living-docs/rows` (objects with `computed_state`). Pure over that — no DOM. |
101+
102+
### Success criteria
103+
104+
- `shouldKeepPolling` (and any schedule helper) live in a tested
105+
`static_cw/*.js` module; Vitest covers stop / continue / empty.
106+
- Living Docs polls **identically** to today — starts when a row is
107+
regenerating, stops when none are, pauses on hidden tab (verified
108+
live).
109+
- The `frontend (Vitest)` CI job runs the new tests; no new infra.
110+
111+
### Gaps
112+
113+
- The Jinja↔JS badge/action duplication is a known drift risk left
114+
**explicitly deferred** to a follow-up spec (it needs an API-payload
115+
decision). Documented, not silently skipped.
116+
117+
---
118+
119+
## Phase 2: Design
120+
121+
**Status**: approved (2026-06-14) — see [`design.md`](design.md)
122+
123+
## Phase 3: Tasks
124+
125+
**Status**: approved (2026-06-14) — see [`tasks.md`](tasks.md)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Tasks: Testable Living Docs poll state machine
2+
3+
> Phase 3 for `specs/living-docs-poller-testing/`. Read `design.md`
4+
> first. Single layer: **attune-gui**. Stacks on `dashboard-js-testing`
5+
> (#68) for the `static_cw/` infra + Vitest include glob.
6+
7+
**Status**: approved (2026-06-14)
8+
9+
## Implementation order
10+
11+
| # | Task | Status | Notes |
12+
|---|------|--------|-------|
13+
| 0 | Branch off `feat/dashboard-js-testing` (#68 infra) | done | `feat/living-docs-poller-testing` |
14+
| 1 | Create `static_cw/living-docs-poller.js``shouldKeepPolling(rows)` + `POLL_INTERVAL_MS` | done | pure, DOM-free; defensive over non-array / missing `computed_state` |
15+
| 2 | Create `static_cw/living-docs-poller.test.js` — 5 cases | done | 6 cases (defensive split); colocated |
16+
| 3 | Convert the poller `<script>` block to `<script type="module">`; import the module | done | inline `.some(...)``shouldKeepPolling`; `1500``POLL_INTERVAL_MS`; glue unchanged |
17+
| 4 | Run Vitest — new tests + batch-panel + editor suites all green | done | 113 passed (11 files) |
18+
| 5 | Live re-verify Living Docs | done | poller module 200, page intact, 2 module scripts, batch SSE still 200, no console errors |
19+
20+
> Shipped — all tasks complete; behavior-preserving, verified live.
21+
22+
## Testing strategy
23+
24+
Per `design.md`. Pure-logic Vitest (no jsdom):
25+
26+
1. `shouldKeepPolling` true when any row `regenerating`.
27+
2. false when none (mix of other states).
28+
3. false for `[]`.
29+
4. false / no-throw for non-array input and rows with `null`/missing
30+
`computed_state`.
31+
5. `POLL_INTERVAL_MS === 1500`.
32+
33+
Regression: task 4 must show `batch-panel.test.js` and the editor
34+
`src/**/*.test.ts` suites still collected and green.
35+
36+
## Rollback plan
37+
38+
One-commit revert: restore the inline `.some(...)` + `1500`, delete
39+
`living-docs-poller.{js,test.js}`, revert the `<script>` tag. No
40+
API/data migration. Other inline blocks untouched.
41+
42+
## Notes / guardrails
43+
44+
- **Behavior-preserving only.** Task 5 live-verify is the gate.
45+
- **Keep the module DOM/timer-free**`setTimeout`, `document.hidden`,
46+
`visibilitychange`, and the initial DOM-query start stay in the inline
47+
shim.
48+
- **Don't touch** `_renderBadge` / `_renderActions` — the badge/action
49+
de-dup is a separate deferred spec.
50+
- **Stacks on #68** — open the PR against `main` noting it builds on #68;
51+
rebase onto `main` once #68 merges.

0 commit comments

Comments
 (0)