| phase | quick-260707-cvz | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan | 1 | ||||||||||||||||||||||||||||||||||
| type | execute | ||||||||||||||||||||||||||||||||||
| wave | 1 | ||||||||||||||||||||||||||||||||||
| depends_on | |||||||||||||||||||||||||||||||||||
| files_modified |
|
||||||||||||||||||||||||||||||||||
| autonomous | true | ||||||||||||||||||||||||||||||||||
| requirements |
|
||||||||||||||||||||||||||||||||||
| must_haves |
|
Purpose: answer the user's question "where do I see progress after clicking Deepen?" in-place. Output: one new poll endpoint, one new fragment, a wired success branch, and route/render tests.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md </execution_context>
@.planning/STATE.md @./CLAUDE.md<key_facts> Completion contract (verified in code, load-bearing):
agent_analysis.post_analysis_progress(routers/agent_analysis.py:259) is a COUNTER-ONLY upsert. On the deepen re-run's START call it writes(fine_windows_analyzed=0, fine_windows_total=N)and overwrites ONLY those two columns viaon_conflict_do_update— it does NOT touchanalysis_completed_at. So during a re-deepen of an already-ANALYZED file,analysis_completed_atkeeps its OLD (pre-click) value until completion. DO NOT gate "running" on completed_at being NULL.agent_analysis.put_analysis(routers/agent_analysis.py:241) is the ONLY writer that stampsanalysis_completed_at = func.now()(in the same tx it flips FileState.ANALYZED). This is the single monotonic completion signal.AnalysisResult(models/analysis.py):fine_windows_analyzed/fine_windows_total(both nullable),analysis_completed_at(nullable, tz-aware),sampled(nullable bool).- Existing
N/Midiom to mirror: analyze_workspace.html:79-90 — "running · %s/%s windows".
Established HTMX self-poll idiom to REUSE (three-state terminal-halt, Pitfall 6):
- scan_progress_card.html + pipeline_scans.scan_progress (routers/pipeline_scans.py:200): the
in-progress branch carries
hx-get+hx-trigger="every 2s"+hx-swap="outerHTML"; the terminal branches OMIT all three, so the outerHTML swap removes the trigger and HTMX halts automatically.
Router facts:
router = APIRouter(tags=["pipeline"])— NO prefix; routes are full paths (e.g. the deepen POST is/pipeline/files/{file_id}/deepen, pipeline.py:877).- Deepen button + anchor: analysis_timeline.html:9-16 — button POSTs to the deepen endpoint with
hx-target="#deepen-result-{{ file_id }}",hx-swap="innerHTML"; anchor is<span id="deepen-result-{{ file_id }}" aria-live="polite">. </key_facts>
<completion_predicate> COMPLETION PREDICATE (exact — use verbatim in the endpoint):
requested_at = datetime.fromtimestamp(since, tz=UTC) # `since` = deepen-click epoch seconds
complete = (analysis is not None
and analysis.analysis_completed_at is not None
and analysis.analysis_completed_at > requested_at)
Rationale: since is captured at click time and threaded through the poll URL. A stale pre-click
sampled result has completed_at <= requested_at ⇒ NOT complete (kills the misleading-complete edge).
A fresh put_analysis stamps func.now() > requested_at ⇒ complete. Robust against "not started yet".
STATE MACHINE for the fragment (evaluated in order):
- file missing (FileRecord is None) -> terminal "gone", no poll.
- complete (predicate above true) -> terminal "Deepen complete", no poll.
- fine_total truthy AND fine_analyzed < fine_total -> RUNNING "Re-analyzing · {a}/{t} windows", poll.
- otherwise (stale/equal counts, job not started yet) -> "Queued — starting deepen…", poll. Counts are numeric-only (autoescaped ints, XSS-safe). Guard None counts to 0 for display. </completion_predicate>
Edit deepen_response.html: keep the not_found and no_active_agent branches EXACTLY as-is
(static one-liners). Replace ONLY the {% else %} (success) branch body with a bootstrap poller —
a self-replacing <span> that fires the first fetch on load and then hands off to deepen_progress:
hx-get="/pipeline/files/{{ file_id }}/deepen-progress?since={{ since }}",
hx-trigger="load, every 2s", hx-swap="outerHTML", aria-live="polite", initial text
"Queued — starting deepen…". On first swap the bootstrap span is replaced by deepen_progress.html,
which then owns the single poll loop (no double-poll). This requires file_id and since in the
deepen POST context (added in Task 2).
uv run python -c "from jinja2 import Environment, FileSystemLoader; e=Environment(loader=FileSystemLoader('src/phaze/templates')); e.get_template('pipeline/partials/deepen_progress.html'); e.get_template('pipeline/partials/deepen_response.html'); print('templates parse OK')"
Both templates parse; deepen_progress renders 4 states with hx-trigger present only on running/queued; deepen_response success branch is the bootstrap poller, not_found/no_active_agent unchanged.
In deepen_analysis (pipeline.py:877): compute since = datetime.now(UTC).timestamp() (a float)
BEFORE the enqueue block, and add "file_id": file_id, "since": since to the TemplateResponse
context. Do NOT change any guard, the enqueue/dedup/routing logic, or the not_found/no_active_agent
branches — since/file_id are only consumed by the success branch's bootstrap poller.
Add a new endpoint @router.get("/pipeline/files/{file_id}/deepen-progress", response_class=HTMLResponse)
async def deepen_progress(request, file_id: uuid.UUID, since: float, session=Depends(get_session)).
since is a required numeric query param (float) — FastAPI coerces/validates it (a non-numeric value
is a 422, XSS-safe). Body:
- Load FileRecord by id; if None -> render deepen_progress.html with
{"gone": True, "complete": False, "running": False, "fine_done": 0, "fine_total": 0, "file_id": file_id, "since": since}. - Load
AnalysisResultfor file_id (scalar_one_or_none). requested_at = datetime.fromtimestamp(since, tz=UTC).- Apply the COMPLETION PREDICATE verbatim (see <completion_predicate>): compute
complete. fine_done = analysis.fine_windows_analyzed or 0;fine_total = analysis.fine_windows_total or 0(None-guarded).running = (not complete) and fine_total > 0 and fine_done < fine_total.- Render deepen_progress.html with
{gone: False, complete, running, fine_done, fine_total, file_id, since}. Place the endpoint adjacent todeepen_analysis. Keep mypy-clean (annotate return-> HTMLResponse).
Cover the GET /pipeline/files/{file_id}/deepen-progress?since= endpoint:
- queued/starting: seed an AnalysisResult with a pre-click
analysis_completed_at(<= since) and equal counts (e.g. 20/20); assert response contains "Queued" and DOES carryhx-trigger. - running: seed fine_windows_analyzed < fine_windows_total (e.g. 34/62), completed_at pre-click or
NULL; assert body contains "34/62 windows" and carries
hx-trigger(poll active). - complete: seed
analysis_completed_atstrictly AFTER thesincevalue passed in the query; assert body contains "Deepen complete" and does NOT containhx-trigger(poll halted). - gone: request with a random unknown file_id (well-formed uuid); assert "no longer available" and
no
hx-trigger. Cover the success path: POST the deepen endpoint for a file WITH an active agent (reuse the existing success-path fixture) and assert the response body contains the bootstrap poller —deepen-progressin anhx-getANDhx-trigger="load, every 2s"— proving the success branch now polls (not the old static "Re-analysis queued" line). Keep the not_found/no_active_agent assertions from existing tests intact (they must still return the static one-liners). Choosesincevalues as explicit epoch floats and set seededanalysis_completed_atrelative to them (tz-aware UTC datetimes) so the boundary (> vs <=) is deterministic.
<threat_model>
| Boundary | Description |
|---|---|
| browser → GET deepen-progress | file_id (uuid path) and since (float query) cross from client |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-cvz-01 | Tampering | since query param |
mitigate | Typed float param — FastAPI 422s non-numeric; used only in a datetime compare, never rendered raw |
| T-cvz-02 | Information disclosure | window counts in fragment | mitigate | Only numeric ints (None-guarded to 0) rendered; no essentia strings, no raw HTML |
| T-cvz-03 | DoS | 2s self-poll loop | accept | Single-user admin tool; terminal-state outerHTML swap halts the loop (Pitfall 6); gone-state also halts on deleted file |
| T-cvz-04 | Elevation | forged file_id | accept | Read-only progress on an admin-only surface; unknown id returns benign "gone" fragment, never a 500 |
| </threat_model> |
<success_criteria>
- Clicking "Deepen analysis" returns a self-polling fragment showing live
N/M windows. - Poll reaches a terminal "Deepen complete" state and stops (no
hx-triggerin terminal markup). - Stale pre-click sampled result never shows "complete" (timestamp-gated predicate).
- not_found / no_active_agent branches and the enqueue/dedup/routing logic are unchanged.
- ruff + mypy + pre-commit clean; pipeline router coverage >=90%; no
--no-verify. </success_criteria>