chore(agent): track .agent/ planning tree in git
Remove the blanket .agent/ gitignore so the phase roadmap, user stories, reports, and PLAN.md are versioned with the code. Only runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log) remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# Phase 41 — Sync fails fast + modal when a model is down
|
||||
|
||||
**Source:** `TODO.md` L4 — "If the embedding or lite model is not accessible the sync button should fail fast and there should be a modal error popup explaining that the model isn't available."
|
||||
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||
**Context:** `app/api/sync.py::_run_sync` (phase 32/35/38) runs source resolution → git clones → `import_sources` (embeds) → overview (lite) — with a dead LLM endpoint the run discovers it only mid-import, after slow clones. The sync state machine is module-owned by `frontend/assets/header.js` (`applySyncFailure` → button title/aria + `.is-error` + `bor:sync-status` event; the Sources page renders `#sync-error-banner`). No dialog component exists yet.
|
||||
|
||||
## Objective
|
||||
When `embed` or `lite` is unreachable, the sync fails **before any expensive work** with a message naming the model, and the failure is readable in a **modal dialog** on every page that carries `#sync-btn`.
|
||||
|
||||
## Dependencies
|
||||
- `40_tuning_toggle_flash` (todo) — current shared-header state (sequential; no code overlap, but both touch `header.js` — keep this phase's changes confined to the sync section).
|
||||
- `32_admin_sync_button` / `35_git_sources_admin` / `38_local_directory_sources` (complete) — the pipeline, the status contract, and the module-owned button lifecycle this phase extends.
|
||||
|
||||
## Tasks
|
||||
1. `01_model_probe_fail_fast.md` — `check_models()` probe in `app/rag/llm.py`, called first in `_run_sync`; unit + integration tests.
|
||||
2. `02_sync_error_modal.md` — `header.js` modal (built in JS, all pages) + CSS; source pins.
|
||||
3. `03_model_down_e2e_and_commit.md` — dedicated E2E suite (dead-LLM module app) + phase-32 regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: probe success/failure paths with a fake LLM client (embed-down, lite-down, both up); the sync task's fail-fast ordering (probe before source resolution — assert no clone call happens).
|
||||
- Integration: `POST /api/sync` with a stubbed failing client → `GET /api/sync/status` reaches `failed` with the model-naming error; healthy path regression.
|
||||
- Coverage: **>90%** on `app/` including the new probe code.
|
||||
- E2E (mandatory, A16): `tests/e2e/test_sync_model_down.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] With a dead LLM endpoint: sync fails within seconds, **before** any clone, error names the unavailable model; the modal shows it; button settles retry-ready.
|
||||
- [ ] Modal contract: `role="alertdialog"`, `aria-modal`, text via `textContent`, close via button / `Esc` / backdrop, focus in-and-out.
|
||||
- [ ] Healthy sync pipeline (clone → import → overview) unchanged — phase-32 suite green in isolation.
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_sync_model_down.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **A12 untouched** — still in-process, no queue, no new service; the probe is two cheap model calls.
|
||||
- **A10 untouched** — no new endpoint; `/api/sync` + `/api/sync/status` keep their shapes (a model failure is just another `failed` state).
|
||||
- **Phase-32 contract kept** — 2 s poll, 202/409, no client timeout, `bor:sync-status` event, button title/aria affordance, Sources banner (the modal is additive).
|
||||
- **Owner-locked (2026-08-27, roadmap A4):** probe runs **before** git clones; the modal is the primary failure surface on every page.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Task 01 — Model probe: fail fast before any clone
|
||||
|
||||
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "If the embedding or lite model is not accessible the sync button should fail fast and there should be a modal error popup explaining that the model isn't available."
|
||||
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||
|
||||
## Objective
|
||||
The sync run verifies both models it needs (`embed` + the summary `lite`) **first** — before source resolution, before any `clone_or_pull` — and fails the run with a clear, model-naming error when either is unreachable.
|
||||
|
||||
## Work
|
||||
1. `app/rag/llm.py` — add `class ModelUnavailableError(LLMError)` and:
|
||||
```python
|
||||
async def check_models(llm: LLMClient) -> None:
|
||||
"""Verify the models a sync needs (embed + summary) before any
|
||||
expensive work; raise ModelUnavailableError naming the model."""
|
||||
```
|
||||
- `await llm.embed_one("sync model check")` — wrap `EmbeddingError` (and any other exception) in `ModelUnavailableError`: message names the **embedding model** (use `llm.settings.llm_embed_model`, e.g. "The embedding model ('embed') is not available — check the model endpoint and retry.")
|
||||
- `await llm.chat([{"role": "user", "content": "ping"}])` (defaults to `llm_summary_model`) — wrap `LLMError`/other in `ModelUnavailableError` naming the **summary model** (`llm.settings.llm_summary_model`, e.g. "The summary model ('lite') is not available — check the model endpoint and retry.").
|
||||
- Docstring notes the probe is deliberately tiny (one short embedding + one 1-token-scale completion) and that the sync sanitizer downstream still masks any embedded credentials.
|
||||
2. `app/api/sync.py` — in `_run_sync()`, construct `llm = LLMClient()` **before** the DB/source block and call `await check_models(llm)` as the **first** pipeline step (before `effective_sources`, before the clone loop). Update the module docstring's pipeline list (the probe is step 1: "verify `embed` + summary model availability — fail fast before any clone") and renumber. `ModelUnavailableError` falls into the existing `except Exception` → `failed` state with the sanitized error (no special-casing needed — verify the message survives `_sanitize_error` unchanged).
|
||||
3. `tests/unit/test_sync_model_probe.py` (new) — with a fake LLM client (duck-typed `embed_one`/`chat`, see `tests/fakes.py` `FakeEmbedder` for the shape):
|
||||
- both up → `check_models` returns, both methods called;
|
||||
- embed raises → `ModelUnavailableError` mentioning the embed model name, `chat` never called;
|
||||
- chat raises → `ModelUnavailableError` mentioning the summary model name;
|
||||
- message content assertions (model name present, "not available" wording).
|
||||
4. `tests/integration/test_sync_api.py` — extend:
|
||||
- **fail-fast:** monkeypatch `app.api.sync.LLMClient` (or `check_models`) so the probe raises `ModelUnavailableError`; also monkeypatch `clone_or_pull` to *assert it is never called*; `POST /api/sync` → poll `GET /api/sync/status` until terminal → `state == "failed"`, `error` names the model;
|
||||
- **ordering:** a spy on `effective_sources` shows the probe ran before it;
|
||||
- **healthy regression:** the existing success/failure tests stay green (they stub the LLM — the stub must now satisfy the probe: `FakeEmbedder` already implements `chat`; if the existing stub lacks `embed_one`, add it — `FakeEmbedder.embed` exists, so subclass or delegate).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration as above; the probe must be covered (success + both failure modes) to keep `app/` **>90%**.
|
||||
- `uv run pytest tests/unit/test_sync_model_probe.py tests/integration/test_sync_api.py -v` green; full suite green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `check_models` exists, is called first in `_run_sync`, and names the failing model in `ModelUnavailableError`.
|
||||
- [ ] A dead-model sync fails **before** any clone (spy-asserted) with a sanitized, model-naming error in the `failed` state.
|
||||
- [ ] Healthy pipeline behavior unchanged (existing sync integration tests green).
|
||||
@@ -0,0 +1,29 @@
|
||||
# Task 02 — Sync error modal (module-owned, every page)
|
||||
|
||||
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "If the embedding or lite model is not accessible the sync button should fail fast and there should be a modal error popup explaining that the model isn't available."
|
||||
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||
|
||||
## Objective
|
||||
A readable, accessible modal dialog for sync failures, built by the shared header module (which owns the sync state machine), so every page carrying `#sync-btn` gets it with zero page-markup changes.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/header.js` — in the sync section, add:
|
||||
- `showSyncModal(error)`: lazily create the dialog **once** and append to `document.body` (module-level `let syncModal = null`):
|
||||
- backdrop `<div class="sync-modal-backdrop">`;
|
||||
- panel `<div class="sync-modal" role="alertdialog" aria-modal="true" aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">` with an `<h2 id="sync-modal-title">Sync failed</h2>`, a `<p id="sync-modal-error">` whose text is set via **`textContent`** (the sanitized error — XSS-safe, never `innerHTML`), and a `<button type="button" class="sync-modal-close" aria-label="Close error dialog">` (×);
|
||||
- opening: add a `.is-open` class (or remove `hidden`), move focus to the close button, remember `document.activeElement` (expected `#sync-btn`);
|
||||
- closing: reverse (focus returns to the remembered element — `#sync-btn` when present), `Esc` keydown on `document` while open, backdrop click (click on the backdrop element itself, not the panel), and the close button all call the same close function; a second failure while open **updates the error text in place** (no stacking).
|
||||
- call `showSyncModal(status.error)` from `applySyncFailure(status)` **after** the existing button-title/aria/`.is-error` + `emitSyncStatus` lines (those stay byte-identical — the Sources page's `#sync-error-banner` keeps rendering off the event).
|
||||
- null-safe: everything guards on `syncBtn`/`document.body`; pages without `#sync-btn` never create the modal (the function is only reachable from the sync state machine).
|
||||
- Update the module docstring's sync bullet: the failed state now also opens the module-owned error modal (2026-08-27, `TODO.md` L4).
|
||||
2. `frontend/assets/styles.css` — `.sync-modal-backdrop` (fixed, full-viewport, `rgba` dim over the page, `z-index` above the header) + `.sync-modal` (centered panel, max-width ≈28rem, the dark-theme **error palette** from PLAN §7.2: panel on the error-surface `#2d1318` family, text `#fca5a5`-class ink, 1px error border; title in ink, error text ink-soft-on-error-surface ≥4.5:1); open/close via `.is-open` (visibility/opacity, no motion under `prefers-reduced-motion`); the close button keeps the global `:focus-visible` 3px outline; 44px touch floor.
|
||||
3. `tests/unit/test_sync_button.py` — add source pins (house style): `header.js` contains `role="alertdialog"`, the `textContent` assignment of the modal error, the `Esc` close binding, the backdrop-click close, focus return to `#sync-btn`, and the `showSyncModal` call inside `applySyncFailure` (after `emitSyncStatus`); `styles.css` carries the `.sync-modal` rules + the reduced-motion stilling. Update any pin that asserts the exact `applySyncFailure` body.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; full suite green (no `app/` change — coverage TOTAL unchanged).
|
||||
- Coverage: **>90%** on `app/` (unchanged).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `applySyncFailure` opens the modal with the sanitized error; button title/aria + `bor:sync-status` event behavior byte-identical.
|
||||
- [ ] Modal: `role="alertdialog"`, `aria-modal`, labeled, `textContent`-rendered error, close via button/`Esc`/backdrop, focus in-and-out to `#sync-btn`.
|
||||
- [ ] No page HTML changed (the modal is JS-built); no CDN (A11).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 03 — Model-down E2E + regressions + commit
|
||||
|
||||
**Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md:4` — "If the embedding or lite model is not accessible the sync button should fail fast and there should be a modal error popup explaining that the model isn't available."
|
||||
**Story:** `.agent/user_stories/sync-model-fail-fast.md`
|
||||
|
||||
## Objective
|
||||
Prove the whole story in the browser against a **dead model endpoint** — fast failure, readable modal, dismissal, unchanged secondary surfaces, and an untouched healthy pipeline — then commit the phase.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_sync_model_down.py` (new) — mock-only, DB up, git on PATH. Follow the `test_sync_button.py` module-app pattern, but boot **two** module-scoped apps on distinct ports (import `APP_PORT`, `ADMIN_PASSWORD`, `SESSION_SECRET`, `_wait_http` from `e2e.conftest`; use e.g. `APP_PORT + 41` for the dead-model app so the isolated run never clashes with a session app):
|
||||
- **dead-model app** env: `BOR_LLM_BASE_URL=http://127.0.0.1:9/v1` (closed port — instant connection refused), `BOR_GIT_SOURCES=file://<the test_sync_button fixture repo pattern>` (a local `file://` fixture repo, built the same way `test_sync_button.py` does — a (regressed, non-fail-fast) run would therefore spend real time cloning before failing), its own `BOR_SOURCES_DIR` under `tmp_path`;
|
||||
- `test_model_down_fails_fast_with_modal` — admin login, click `#sync-btn`; expect (budget ≤ ~10 s, contrast with the 60 s healthy budget) the button settling retry-ready **and** the modal visible: `role="alertdialog"`, title "Sync failed", error text naming the model ("embedding model" / the model id), `aria-modal="true"`;
|
||||
- `test_modal_dismissal` — one fresh failure, then close via the × button (focus returns to `#sync-btn`); a fresh failure, close via `Esc`; a fresh failure, close via backdrop click;
|
||||
- `test_sync_error_surfaces_unaffected` — after a failure the button keeps `title` + `.is-error`; on `/sources.html` (same dead-model app) the `#sync-error-banner` renders the error off `bor:sync-status`;
|
||||
- `test_healthy_sync_still_succeeds` — a **healthy** module app (same port scheme, `BOR_LLM_BASE_URL` = the session mock like `test_sync_button.py`) runs the full pipeline to "Synced HH:MM" (counts + idempotency as in phase 32) — proves the probe didn't break the happy path.
|
||||
- Module fixture teardown: terminate both apps (the conftest pattern).
|
||||
2. Regression pass (isolation runs): `tests/e2e/test_sync_button.py` (phase 32 — must stay green unmodified), `test_git_sources_admin.py`, `test_local_directory_sources.py`.
|
||||
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
4. Commit (Conventional Commits, `--no-gpg-sign`), e.g. `feat(sync): fail fast with a modal when a model is unavailable`, staging this phase's files; move `.agent/phases/todo/41_sync_fail_fast_models/` → `.agent/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: `uv run pytest tests/e2e/test_sync_model_down.py -v --no-cov` green in isolation.
|
||||
- Coverage: **>90%** on `app/` (the probe code is fully covered by task 01's tests).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The model-down suite passes in isolation: fast fail before clones, modal with model-naming error, all three dismissal paths, secondary surfaces intact, healthy run unaffected.
|
||||
- [ ] Phase-32/35/38 regression suites green in isolation.
|
||||
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
Reference in New Issue
Block a user