From 3c03dc5a083395bcf25d4e19a47dc43222cb53b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:47:44 +0900 Subject: [PATCH] feat: show labeled analysis-run status history (v0.81.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buyer gap: after #100 the detail showed cutoff and counts but not the legal lifecycle the registry already stored. GET /api/analysis-runs/{id} now returns labeled status_history (Pending → Running → Succeeded with occurrence times). The list stays latest-status only. Hidden runs still 404 and never leak events. Failure codes stay machine tokens. Synthetic Demo Corp seed only. --- ARCHITECTURE.md | 9 ++++-- .../0.81.0-analysis-run-status-history.md | 5 ++++ CHANGELOG.md | 10 +++++++ backend/app/analysis_run_ingestion.py | 30 +++++++++++++++++++ backend/app/main.py | 5 +++- backend/tests/test_api.py | 19 ++++++++++-- docs/adr/0014-authorized-analysis-run-read.md | 8 +++-- frontend/package.json | 2 +- frontend/src/App.test.tsx | 24 +++++++++++++++ frontend/src/App.tsx | 10 +++++++ frontend/src/api.ts | 9 ++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- 13 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.d/0.81.0-analysis-run-status-history.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e00b8c8bd..33d9d2c09 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -464,11 +464,14 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. The home list is clickable: `GET /api/analysis-runs/{id}` fills a -labeled detail (cutoff, requested date, counts) without exposing a -DSN or raw record. The payload is lookup labels plus non-negative aggregate counts -- never +labeled detail (cutoff, requested date, counts, status history) +without exposing a DSN or raw record. Status history is detail-only +and uses lookup labels plus occurrence times; a failure event keeps +its machine `failure_code` rather than an invented caption. The +payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents". +Demo Corp" with "3 documents" and Pending / Running / Succeeded times. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.81.0-analysis-run-status-history.md b/CHANGELOG.d/0.81.0-analysis-run-status-history.md new file mode 100644 index 000000000..9fa4b8706 --- /dev/null +++ b/CHANGELOG.d/0.81.0-analysis-run-status-history.md @@ -0,0 +1,5 @@ +# 0.81.0 analysis-run status history + +Detail of `GET /api/analysis-runs/{id}` shows the labeled append-only +lifecycle. The list stays latest-status only. Hidden runs 404. +Synthetic Demo Corp seed only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f7b3ca4..f9a1371a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.81.0] - 2026-08-16 + +### Added + +- Analysis-run detail shows the labeled lifecycle: Pending, Running, + then Succeeded, with occurrence times from `analysis_run_status_event`. + The list stays latest-status only. Hidden runs still 404 and never + leak events. Failure codes stay machine tokens -- no invented label. + Synthetic Demo Corp seed only. + ## [0.80.0] - 2026-08-16 ### Added diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index 86b75ef89..b9a09fc09 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -96,6 +96,35 @@ async def _counts_by_run( return grouped +async def _status_history( + conn: asyncpg.Connection, + analysis_run_id: str, +) -> list[dict[str, Any]]: + """Labeled append-only lifecycle for one already-visible run.""" + rows = await conn.fetch( + """ + select status_ordinal, status_code, occurred_at, failure_code + from analysis_run_status_event + where analysis_run_id = $1::uuid + order by status_ordinal + """, + analysis_run_id, + ) + labels = await labels_for_codes(conn, [row["status_code"] for row in rows]) + history: list[dict[str, Any]] = [] + for row in rows: + item: dict[str, Any] = { + "status_ordinal": int(row["status_ordinal"]), + "status_code": row["status_code"], + "status_label": labels.get(row["status_code"], row["status_code"]), + "occurred_at": _iso(row["occurred_at"]), + } + if row["failure_code"]: + item["failure_code"] = row["failure_code"] + history.append(item) + return history + + async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], @@ -187,4 +216,5 @@ async def fetch_visible_analysis_run( detail["code_revision_sha"] = row["code_revision_sha"] if row["failure_code"]: detail["failure_code"] = row["failure_code"] + detail["status_history"] = await _status_history(conn, analysis_run_id) return detail diff --git a/backend/app/main.py b/backend/app/main.py index 81630d35b..e039a2f58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1179,7 +1179,10 @@ async def read_analysis_run( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """One authorized analysis-run projection, or 404 when hidden.""" + """One authorized analysis-run projection, or 404 when hidden. + + Detail adds the labeled status history. Hidden runs never leak events. + """ _require_post_read(account) try: UUID(analysis_run_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3a6bab603..3104dadd6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -453,14 +453,29 @@ def test_analysis_runs_are_labeled_aggregates_and_hide_other_scopes( dumped = str(visible) assert "postgresql://" not in dumped assert "select " not in dumped.lower() + assert "status_history" not in visible detail = client.get( f"/api/analysis-runs/{seeded_db['visible_run_id']}", headers={"Authorization": f"Bearer {demo_analyst_token}"}, ) assert detail.status_code == 200 - assert detail.json()["configuration_schema_version"] == "lineage-run-v1" - assert "snapshot_sha256" not in detail.json() + body = detail.json() + assert body["configuration_schema_version"] == "lineage-run-v1" + assert "snapshot_sha256" not in body + history = body["status_history"] + assert [event["status_label"] for event in history] == [ + "Pending", + "Running", + "Succeeded", + ] + assert [event["occurred_at"][:16] for event in history] == [ + "2026-01-12T12:31", + "2026-01-12T12:32", + "2026-01-12T12:33", + ] + assert all("failure_code" not in event for event in history) + assert "postgresql://" not in str(body) hidden = client.get( f"/api/analysis-runs/{seeded_db['hidden_run_id']}", diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 0621614d3..c0f32beef 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -28,6 +28,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: - The payload carries lookup labels and non-negative aggregate counts. It does not carry source SQL, DSNs, raw records, image bytes, provider payloads, credentials, or another service's table names. +- `GET /api/analysis-runs/{id}` also returns the append-only labeled + `status_history`. The list does not. A failed event may include the + stored machine `failure_code`; this slice does not invent a label. - TEPP remains a versioned `AnalysisRunRequest` consumer (`lineageweave.tepp_client`). This slice does not fork TEPP arithmetic. - contextual-orchestrator remains the only LLM path. This slice does not @@ -37,8 +40,9 @@ LineageWeave owns a fail-closed read projection of the #89 registry: `make seed` writes one synthetic Demo Corp lineage run so the existing React home page can show Analysis runs without a second application. -Write/rebuild APIs, TEPP submission, and an Analysis Run Console remain -later slices. +The detail now shows the legal lifecycle the registry already stored. +Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console +remain later slices. ## References diff --git a/frontend/package.json b/frontend/package.json index ca3a1810e..aacb6f74d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.80.0", + "version": "0.81.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ba1285c40..a06e0dc31 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -189,6 +189,26 @@ describe("App, authenticated", () => { count_value: 3, }, ], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:31:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:32:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_succeeded", + status_label: "Succeeded", + occurred_at: "2026-01-12T12:33:00Z", + }, + ], }), ); } @@ -1365,6 +1385,10 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Lineage reconstruction · Succeeded · Demo Corp" })).toBeInTheDocument(); expect(screen.getByText(/Cutoff 2026-01-12/)).toBeInTheDocument(); expect(screen.getByText(/Requested 2026-01-12/)).toBeInTheDocument(); + const history = screen.getByRole("list", { name: "Analysis run status history" }); + expect(history).toHaveTextContent("Pending 2026-01-12 12:31"); + expect(history).toHaveTextContent("Running 2026-01-12 12:32"); + expect(history).toHaveTextContent("Succeeded 2026-01-12 12:33"); expect(screen.queryByText(/postgresql:\/\//)).not.toBeInTheDocument(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8568e9acf..49b2bf16c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1432,6 +1432,16 @@ function AnalysisRunsPanel({ accessToken }: { accessToken: string }) { ))} + {selected.status_history && selected.status_history.length > 0 && ( +
    + {selected.status_history.map((event) => ( +
  1. + {event.status_label} {event.occurred_at.slice(0, 16).replace("T", " ")} + {event.failure_code ? ` · ${event.failure_code}` : ""} +
  2. + ))} +
+ )} )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc1c39e6d..5dea72c0a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -499,6 +499,14 @@ export interface AnalysisRunCount { count_value: number; } +export interface AnalysisRunStatusEvent { + status_ordinal: number; + status_code: string; + status_label: string; + occurred_at: string; + failure_code?: string; +} + export interface AnalysisRun { analysis_run_id: string; run_kind_code: string; @@ -511,6 +519,7 @@ export interface AnalysisRun { knowledge_cutoff: string; requested_at: string; source_counts: AnalysisRunCount[]; + status_history?: AnalysisRunStatusEvent[]; } export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 7b561a3ab..5603c60fe 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.80.0" +__version__ = "0.81.0" diff --git a/pyproject.toml b/pyproject.toml index 57a3973ab..3862da9a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.80.0" +version = "0.81.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" }