Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions sidecar/attune_gui/static_cw/living-docs-poller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Pure logic for the Living Docs row poller.
//
// DOM-free and timer-free on purpose: the recursive-setTimeout loop,
// visibilitychange handling, fetch, and DOM rendering stay in the inline
// shim in living_docs.html. This module holds only the decision the
// poller branches on — extracted so it can be unit-tested (Vitest, Node).
//
// See specs/living-docs-poller-testing.

/** Poll cadence (ms) the shim reschedules on while rows are regenerating. */
export const POLL_INTERVAL_MS = 1500;

/**
* Keep polling iff at least one row is still regenerating.
*
* Input is the `rows` array from GET /api/living-docs/rows (objects with
* a `computed_state`). Defensive against a non-array payload or rows
* missing `computed_state` so a malformed response can't throw inside the
* poll loop.
*/
export function shouldKeepPolling(rows) {
return (
Array.isArray(rows) &&
rows.some((r) => r && r.computed_state === "regenerating")
);
}
36 changes: 36 additions & 0 deletions sidecar/attune_gui/static_cw/living-docs-poller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";

import { POLL_INTERVAL_MS, shouldKeepPolling } from "./living-docs-poller.js";

const row = (computed_state) => ({ id: "f/concept", computed_state });

describe("shouldKeepPolling", () => {
it("is true when any row is regenerating", () => {
expect(shouldKeepPolling([row("current"), row("regenerating"), row("stale")])).toBe(true);
});

it("is false when no row is regenerating", () => {
const rows = ["current", "stale", "missing", "pending-review", "errored"].map(row);
expect(shouldKeepPolling(rows)).toBe(false);
});

it("is false for an empty list", () => {
expect(shouldKeepPolling([])).toBe(false);
});

it("does not throw and is false for non-array input", () => {
expect(shouldKeepPolling(undefined)).toBe(false);
expect(shouldKeepPolling(null)).toBe(false);
expect(shouldKeepPolling({})).toBe(false);
});

it("does not throw on rows missing computed_state", () => {
expect(shouldKeepPolling([{}, null, { computed_state: undefined }])).toBe(false);
});
});

describe("POLL_INTERVAL_MS", () => {
it("pins the poll cadence the shim relies on", () => {
expect(POLL_INTERVAL_MS).toBe(1500);
});
});
11 changes: 8 additions & 3 deletions sidecar/attune_gui/templates/living_docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
{% endblock %}

{% block scripts %}
<script>
<script type="module">
// Pure poll decision (shouldKeepPolling / cadence) lives in the tested
// module static_cw/living-docs-poller.js; the timer/DOM/visibility glue
// stays here. See specs/living-docs-poller-testing.
import { shouldKeepPolling, POLL_INTERVAL_MS } from '/cw-static/living-docs-poller.js';

// ── Polling ───────────────────────────────────────────────────────────────
// Recursive setTimeout (not setInterval) so a hidden tab fully pauses
// instead of waking every 1.5s to no-op.
Expand Down Expand Up @@ -197,12 +202,12 @@ <h2 class="section-title">Documents <span class="dim small">({{ rows|length }})<
try {
const data = await AttuneUI.api('/api/living-docs/rows');
_applyRows(data.rows);
if (!data.rows.some(r => r.computed_state === 'regenerating')) {
if (!shouldKeepPolling(data.rows)) {
_stopPoll();
return;
}
} catch (_) { /* transient — keep polling */ }
_scheduleNextPoll(1500);
_scheduleNextPoll(POLL_INTERVAL_MS);
}

document.addEventListener('visibilitychange', () => {
Expand Down
123 changes: 123 additions & 0 deletions specs/living-docs-poller-testing/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Design: Testable Living Docs poll state machine

> Phase 2 design for `specs/living-docs-poller-testing/`. Read
> `requirements.md` first. Single layer: **attune-gui**. Reuses the
> no-build ES-module + Vitest pattern from
> [`dashboard-js-testing`](../dashboard-js-testing/) (and depends on its
> `static_cw/` infra + Vitest include glob — that PR merges first).

**Status**: approved (2026-06-14) — tasks: [`tasks.md`](tasks.md)

---

## Architecture

Same pattern as the batch panel: the **one pure decision** in the poller
moves to a `static_cw/` ES module; the inline `<script>` becomes a
`type="module"` shim that keeps all the imperative glue (timers, DOM,
`visibilitychange`) and calls the module.

The poller's only branch-worthy, bug-prone logic is the **stop
condition** at
[`living_docs.html:200`](attune-gui/sidecar/attune_gui/templates/living_docs.html):

```js
if (!data.rows.some(r => r.computed_state === 'regenerating')) { _stopPoll(); return; }
_scheduleNextPoll(1500);
```

Extracted module:

```js
// static_cw/living-docs-poller.js (NEW)
export const POLL_INTERVAL_MS = 1500;

/** Keep polling iff at least one row is still regenerating. */
export function shouldKeepPolling(rows) {
return Array.isArray(rows) && rows.some(
(r) => r && r.computed_state === "regenerating",
);
}
```

Everything else — `_startPoll` / `_stopPoll` / `_scheduleNextPoll`
(timers), the `visibilitychange` pause/resume, `_poll`'s fetch +
try/catch, `_applyRows` / `_renderBadge` / `_renderActions`, the action
button wiring, and the ws-form / scan-btn handlers — **stays inline**.
The block is converted to `<script type="module">` so it can `import`;
its functions were already block-scoped, and nothing external references
them (handlers attach via `addEventListener`, not inline `onclick`), so
module scope + `defer` semantics are safe.

The `_poll` change is one line:

```js
import { shouldKeepPolling, POLL_INTERVAL_MS } from '/cw-static/living-docs-poller.js';
if (!shouldKeepPolling(data.rows)) { _stopPoll(); return; }
_scheduleNextPoll(POLL_INTERVAL_MS);
```

> **Note on the initial-start check.** `_startPoll()` is also kicked off
> on load by a DOM query (`tr[data-state="regenerating"]`). That reads
> the rendered table, not row data, so it stays inline — `shouldKeepPolling`
> operates on the `/api/living-docs/rows` payload, which is the poll path.

## API changes

None. Pure client refactor; the poller consumes the same
`/api/living-docs/rows` shape.

## Data model changes

None.

## UI/UX

No change — polling cadence, start/stop behavior, hidden-tab pause, and
all row rendering are identical. The only delta is `<script type="module">`
(deferred) replacing the inline `<script>`; the block only attaches
handlers and an optional initial poll, so deferral is safe. Verified
live.

## Cross-layer impact

- **attune-gui** only: new `static_cw/living-docs-poller.js` + test, a
one-block edit to `living_docs.html`.
- Depends on `dashboard-js-testing` (#68) for the `static_cw/` convention
+ the Vitest include glob — this work **stacks on that branch** and
rebases onto `main` after it merges.

## Tradeoffs & alternatives

| Option | Pros | Cons | Chosen? |
|--------|------|------|---------|
| **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) | ✅ |
| 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 | ❌ |
| Leave it inline | No change | The stop condition stays untested — the whole point | ❌ |
| De-dup badge/action maps too | Fixes the bigger drift risk | Out of scope (own spec) — needs an API-payload decision | ❌ (deferred) |

## Testing strategy

`static_cw/living-docs-poller.test.js` (Vitest, Node env, no jsdom):

1. `shouldKeepPolling` → true when any row is `regenerating`.
2. → false when no row is `regenerating` (mix of
`current`/`stale`/`missing`/`pending-review`/`errored`).
3. → false for `[]`.
4. → false / no-throw for non-array input and rows with missing/`null`
`computed_state` (defensive guards).
5. `POLL_INTERVAL_MS` is exported and equals 1500 (pins the cadence the
shim relies on).

Plus a live re-verify: load `/dashboard/living-docs`, confirm the page
renders and (where a regenerating row exists) polling start/stop is
unchanged; no console errors; module served 200.

## Rollback

One-commit revert: restore the inline `.some(...)` check + `1500`, drop
`living-docs-poller.{js,test.js}`, revert the `<script>` tag. No
API/data migration; other blocks untouched.
125 changes: 125 additions & 0 deletions specs/living-docs-poller-testing/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Spec: Testable Living Docs poll state machine

> Single-layer feature (attune-gui). Follow-on to
> [`dashboard-js-testing`](../dashboard-js-testing/) — reuses its
> approved no-build ES-module + Vitest pattern (build strategy and test
> runner are already settled there; this spec does not re-litigate them).

---

## Phase 1: Requirements

**Status**: approved (2026-06-14)

### Problem statement

The Living Docs page polls `/api/living-docs/rows` while any document is
regenerating
([`living_docs.html`](attune-gui/sidecar/attune_gui/templates/living_docs.html),
`{% block scripts %}`). The poller — `_startPoll` / `_stopPoll` /
`_scheduleNextPoll` / `_poll` — is a recursive-`setTimeout` state machine
that must:

- **stop** once no row is `regenerating`
([`:200`](attune-gui/sidecar/attune_gui/templates/living_docs.html)),
- **pause** on a hidden tab and resume on `visibilitychange`,
- keep going on a transient fetch error (don't give up mid-regeneration).

This is exactly the kind of decision logic that breaks silently — polls
forever, stops too early, or hammers a backgrounded tab — and it is
**untested**. It's the next-richest untested inline block after the batch
panel, and migrating it applies the pattern established in
`dashboard-js-testing`.

### Scope

**In scope:**

- **Extract the poll decision logic** into a `static_cw/*.js` ES module —
the pure parts: `shouldKeepPolling(rows)` (stop when none regenerating)
and any next-delay helper. The module is DOM-free / timer-free so
Vitest imports it in Node.
- **Vitest coverage** for the extracted logic, run by the existing
`frontend (Vitest)` CI job (the include glob from
`dashboard-js-testing`).
- **Shrink the inline script** to a `<script type="module">` glue shim
that keeps the DOM/`setTimeout`/`visibilitychange` wiring and calls the
module. Behavior-preserving.

**Out of scope:**

- **The Jinja↔JS `computed_state` → badge/action mapping duplication.**
The same mapping is hand-maintained in Jinja
([`:118-157`](attune-gui/sidecar/attune_gui/templates/living_docs.html))
and in JS `_renderBadge`/`_renderActions`
([`:228-256`](attune-gui/sidecar/attune_gui/templates/living_docs.html)).
It's a real drift risk, but de-duplicating it (e.g. a server-provided
presentational field) is a larger change with an API-payload impact —
**deferred to its own follow-up spec**. This spec leaves both copies
as-is; the glue shim still calls the existing `_renderBadge` /
`_renderActions` unchanged.
- Other inline blocks (`commands.html`, etc.) — separate follow-ups.
- Re-deriving the build/test-runner choice — settled in
`dashboard-js-testing`.
- Any change to poll cadence, action behavior, or API semantics.

### User stories

1. **As a maintainer**, I want the poll stop/continue logic unit-tested
so a change can't silently make the dashboard poll forever or stop
mid-regeneration.
2. **As a reviewer**, I want the extraction to be behavior-preserving and
verified live before merge.

### Affected layers

- [x] attune-gui (frontend) — primary and only
- [ ] attune-rag / help / author — no changes

### Coverage areas

| Area | Status | Notes |
|------|--------|-------|
| **Problem & scope** | addressed | Untested poll state machine; extract + test the pure decision. |
| **Data & API contracts** | N/A | No API change — pure client refactor. |
| **UI/UX & states** | addressed | No visual/behavior change; same polling cadence and badges. |
| **Edge cases** | addressed | Empty rows, all-terminal rows (must stop), some-regenerating (must continue), hidden-tab pause, transient fetch error (keep polling). |
| **Cross-layer impact** | N/A | Single layer. |
| **Error handling** | addressed | Transient fetch error preserves current "keep polling" behavior; glue shim retains the try/catch. |
| **Tradeoffs & alternatives** | addressed | Pure-logic extraction vs. leaving inline (defeats the purpose) vs. de-dup refactor (deferred — see Out of scope). |
| **Rollback strategy** | addressed | Revert restores the inline poller; other blocks untouched. |

### Edge cases & open questions

| Question / Edge case | Resolution |
|----------------------|------------|
| Stop when no row regenerating | `shouldKeepPolling(rows)`: some `regenerating` → true; none → false; empty → false. Tested. |
| Hidden-tab pause / resume | Stays in the DOM shim (`document.hidden`, `visibilitychange`); not in the pure module. |
| Transient fetch error mid-poll | Shim keeps the existing try/catch + reschedule; `shouldKeepPolling` only decides from row data, not fetch outcome. |
| 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. |
| What is `shouldKeepPolling`'s input shape? | The `rows` array from `/api/living-docs/rows` (objects with `computed_state`). Pure over that — no DOM. |

### Success criteria

- `shouldKeepPolling` (and any schedule helper) live in a tested
`static_cw/*.js` module; Vitest covers stop / continue / empty.
- Living Docs polls **identically** to today — starts when a row is
regenerating, stops when none are, pauses on hidden tab (verified
live).
- The `frontend (Vitest)` CI job runs the new tests; no new infra.

### Gaps

- The Jinja↔JS badge/action duplication is a known drift risk left
**explicitly deferred** to a follow-up spec (it needs an API-payload
decision). Documented, not silently skipped.

---

## Phase 2: Design

**Status**: approved (2026-06-14) — see [`design.md`](design.md)

## Phase 3: Tasks

**Status**: approved (2026-06-14) — see [`tasks.md`](tasks.md)
51 changes: 51 additions & 0 deletions specs/living-docs-poller-testing/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Tasks: Testable Living Docs poll state machine

> Phase 3 for `specs/living-docs-poller-testing/`. Read `design.md`
> first. Single layer: **attune-gui**. Stacks on `dashboard-js-testing`
> (#68) for the `static_cw/` infra + Vitest include glob.

**Status**: approved (2026-06-14)

## Implementation order

| # | Task | Status | Notes |
|---|------|--------|-------|
| 0 | Branch off `feat/dashboard-js-testing` (#68 infra) | done | `feat/living-docs-poller-testing` |
| 1 | Create `static_cw/living-docs-poller.js` — `shouldKeepPolling(rows)` + `POLL_INTERVAL_MS` | done | pure, DOM-free; defensive over non-array / missing `computed_state` |
| 2 | Create `static_cw/living-docs-poller.test.js` — 5 cases | done | 6 cases (defensive split); colocated |
| 3 | Convert the poller `<script>` block to `<script type="module">`; import the module | done | inline `.some(...)` → `shouldKeepPolling`; `1500` → `POLL_INTERVAL_MS`; glue unchanged |
| 4 | Run Vitest — new tests + batch-panel + editor suites all green | done | 113 passed (11 files) |
| 5 | Live re-verify Living Docs | done | poller module 200, page intact, 2 module scripts, batch SSE still 200, no console errors |

> Shipped — all tasks complete; behavior-preserving, verified live.

## Testing strategy

Per `design.md`. Pure-logic Vitest (no jsdom):

1. `shouldKeepPolling` true when any row `regenerating`.
2. false when none (mix of other states).
3. false for `[]`.
4. false / no-throw for non-array input and rows with `null`/missing
`computed_state`.
5. `POLL_INTERVAL_MS === 1500`.

Regression: task 4 must show `batch-panel.test.js` and the editor
`src/**/*.test.ts` suites still collected and green.

## Rollback plan

One-commit revert: restore the inline `.some(...)` + `1500`, delete
`living-docs-poller.{js,test.js}`, revert the `<script>` tag. No
API/data migration. Other inline blocks untouched.

## Notes / guardrails

- **Behavior-preserving only.** Task 5 live-verify is the gate.
- **Keep the module DOM/timer-free** — `setTimeout`, `document.hidden`,
`visibilitychange`, and the initial DOM-query start stay in the inline
shim.
- **Don't touch** `_renderBadge` / `_renderActions` — the badge/action
de-dup is a separate deferred spec.
- **Stacks on #68** — open the PR against `main` noting it builds on #68;
rebase onto `main` once #68 merges.
Loading