From 5941ae41f83a45570086f49df11895a3135c2924 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 14:42:09 -0700 Subject: [PATCH 01/27] feat(report): define observability deck payload and registry --- src/autoskillit/report/deck/__init__.py | 3 + src/autoskillit/report/deck/_payload.py | 211 ++++++++++++++++ src/autoskillit/report/deck/_registry.py | 263 ++++++++++++++++++++ tests/_test_filter.py | 2 + tests/report/test_deck_payload.py | 291 +++++++++++++++++++++++ tests/report/test_deck_registry.py | 127 ++++++++++ 6 files changed, 897 insertions(+) create mode 100644 src/autoskillit/report/deck/__init__.py create mode 100644 src/autoskillit/report/deck/_payload.py create mode 100644 src/autoskillit/report/deck/_registry.py create mode 100644 tests/report/test_deck_payload.py create mode 100644 tests/report/test_deck_registry.py diff --git a/src/autoskillit/report/deck/__init__.py b/src/autoskillit/report/deck/__init__.py new file mode 100644 index 0000000000..12820d2cf8 --- /dev/null +++ b/src/autoskillit/report/deck/__init__.py @@ -0,0 +1,3 @@ +from ._payload import build_deck_payload + +__all__ = ["build_deck_payload"] diff --git a/src/autoskillit/report/deck/_payload.py b/src/autoskillit/report/deck/_payload.py new file mode 100644 index 0000000000..d67091d3a9 --- /dev/null +++ b/src/autoskillit/report/deck/_payload.py @@ -0,0 +1,211 @@ +"""Build the static deck model from resolved report-index session rows.""" + +from collections import Counter +from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime, timedelta +from typing import Any + +from autoskillit.core import TokenMeasureState + +from ._registry import ( + AVAILABILITY_VOCABULARY, + DECK_VIEWS, + FACETS, + LANDING_VIEW, + SESSION_COLUMNS, + SESSION_TABLE, + WINDOW_FACET_ID, + WINDOW_HISTORY_ISSUE, + WINDOWS, + ChipState, + DeckViewDef, + FacetDef, + ReasonDef, +) + +DAY_MS = 86_400_000 +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + + +def encode_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> dict[str, Any]: + return {"columns": list(columns), "rows": [[row.get(c) for c in columns] for row in rows]} + + +def _history(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + timed = [r["time_ms"] for r in rows if r.get("time_ms") is not None] + return { + "first_ms": min(timed) if timed else None, + "last_ms": max(timed) if timed else None, + "untimed": len(rows) - len(timed), + } + + +def _chip( + key: str, label: str, state: ChipState, count: int, reason: ReasonDef | None, **extra: Any +) -> dict[str, Any]: + return { + "key": key, + "label": label, + "state": state.value, + "count": count, + "reason": reason.text if reason else None, + "issue": reason.issue if reason else None, + **extra, + } + + +def _value_chips( + view: DeckViewDef, facet: FacetDef, rows: Sequence[Mapping[str, Any]] +) -> list[dict[str, Any]]: + counts = Counter(row.get(facet.column) for row in rows) + recorded = any(v is not None for v in counts) + strikes = {(s.facet_id, s.key): s.reason for s in view.strikes} + chips = [] + for value in facet.declared: + reason = ( + dict(value.unresolvable_in).get(view.table) if view.table is not None else None + ) or strikes.get((facet.facet_id, value.key)) + count = counts.get(value.match, 0) if value.match is not None else 0 + if reason: + state, count = ChipState.STRUCK, 0 + elif count: + state = ChipState.LIVE + else: + state = ChipState.ABSENT + reason = ( + facet.unrecorded_gap + if not recorded and facet.unrecorded_gap + else ReasonDef(f"no {value.label} rows in this index") + ) + chips.append(_chip(value.key, value.label, state, count, reason, match=value.match)) + # L0's unresolvable None match must not consume the observed null bucket. + declared_matches = {v.match for v in facet.declared if v.match is not None} + observed_chips = [] + for observed, count in counts.items(): + if observed in declared_matches: + continue + if observed is None: + if facet.null_label is None: + raise ValueError(f"facet {facet.facet_id} has no null label") + key = facet.null_label + else: + key = observed + reason = strikes.get((facet.facet_id, key)) + observed_chips.append( + _chip( + key, + key, + ChipState.STRUCK if reason else ChipState.LIVE, + 0 if reason else count, + reason, + match=observed, + ) + ) + return chips + sorted(observed_chips, key=lambda c: (-c["count"], c["key"])) + + +def _window_chips( + view: DeckViewDef, + rows: Sequence[Mapping[str, Any]], + *, + generated_at_ms: int, + history: Mapping[str, Any], +) -> list[dict[str, Any]]: + strikes = {s.key: s.reason for s in view.strikes if s.facet_id == WINDOW_FACET_ID} + chips = [] + for window in WINDOWS: + reason = strikes.get(window.key) + count = 0 + if reason: + state = ChipState.STRUCK + elif window.days is None: + state, count = ChipState.LIVE, len(rows) + elif history["first_ms"] is None: + state = ChipState.ABSENT + reason = ReasonDef("no session row in this index carries a timestamp") + else: + first = history["first_ms"] + retained = (generated_at_ms - first) // DAY_MS + if retained < window.days: + state = ChipState.ABSENT + date = (_EPOCH + timedelta(milliseconds=first)).date().isoformat() + reason = ReasonDef( + f"index history begins {date} — {retained} days retained", WINDOW_HISTORY_ISSUE + ) + else: + count = sum( + 1 + for r in rows + if r.get("time_ms") is not None + and r["time_ms"] >= generated_at_ms - window.days * DAY_MS + ) + state = ChipState.LIVE if count else ChipState.ABSENT + if not count: + reason = ReasonDef(f"no rows in the last {window.label}") + chips.append(_chip(window.key, window.label, state, count, reason, days=window.days)) + return chips + + +def resolve_view_chips( + view: DeckViewDef, + rows: Sequence[Mapping[str, Any]], + *, + generated_at_ms: int, + history: Mapping[str, Any], +) -> dict[str, list[dict[str, Any]]]: + return {f.facet_id: _value_chips(view, f, rows) for f in FACETS} | { + WINDOW_FACET_ID: _window_chips( + view, rows, generated_at_ms=generated_at_ms, history=history + ) + } + + +def build_deck_payload( + session_rows: Iterable[Mapping[str, Any]], *, generated_at: datetime, index_schema_version: int +) -> dict[str, Any]: + if generated_at.utcoffset() is None: + raise ValueError("generated_at must be timezone-aware") + generated_at_ms = (generated_at - _EPOCH) // timedelta(milliseconds=1) + rows = sorted( + (dict(r) for r in session_rows), + key=lambda r: (r.get("time_ms") is None, r.get("time_ms") or 0, r.get("key") or ""), + ) + history = _history(rows) + return { + "generated_at_ms": generated_at_ms, + "index_schema_version": index_schema_version, + "landing": LANDING_VIEW, + "history": history, + "tables": {SESSION_TABLE: encode_table(rows, SESSION_COLUMNS)}, + "facets": [ + {"id": f.facet_id, "label": f.label, "column": f.column, "kind": "values"} + for f in FACETS + ] + + [{"id": WINDOW_FACET_ID, "label": "window", "column": "time_ms", "kind": "window"}], + "views": [ + { + "id": v.view_id, + "question": v.question, + "decision": v.decision, + "group": v.group, + "status": "built" if v.planned_issue is None else "planned", + "issue": v.planned_issue, + } + for v in DECK_VIEWS + ], + "chips": { + v.view_id: resolve_view_chips( + v, rows, generated_at_ms=generated_at_ms, history=history + ) + for v in DECK_VIEWS + if v.planned_issue is None + }, + "availability": [ + { + "state": s.value, + "label": AVAILABILITY_VOCABULARY[s][0], + "description": AVAILABILITY_VOCABULARY[s][1], + } + for s in TokenMeasureState + ], + } diff --git a/src/autoskillit/report/deck/_registry.py b/src/autoskillit/report/deck/_registry.py new file mode 100644 index 0000000000..0d69449de4 --- /dev/null +++ b/src/autoskillit/report/deck/_registry.py @@ -0,0 +1,263 @@ +"""Questions, facets, and availability vocabulary for the observability deck.""" + +from collections.abc import Mapping +from enum import StrEnum +from types import MappingProxyType +from typing import NamedTuple + +from autoskillit.core import SessionType, TokenMeasureState + + +class ChipState(StrEnum): + LIVE = "live" + STRUCK = "struck" + ABSENT = "absent" + + +class ReasonDef(NamedTuple): + text: str + issue: int | None = None + + +class FacetValueDef(NamedTuple): + key: str + label: str + match: str | None + unresolvable_in: tuple[tuple[str, ReasonDef], ...] = () + + +class FacetDef(NamedTuple): + facet_id: str + label: str + column: str + declared: tuple[FacetValueDef, ...] + null_label: str | None + unrecorded_gap: ReasonDef | None + + +class WindowDef(NamedTuple): + key: str + label: str + days: int | None + + +class StrikeDef(NamedTuple): + facet_id: str + key: str + reason: ReasonDef + + +class DeckViewDef(NamedTuple): + view_id: str + question: str + decision: str + group: str + table: str | None + script: str | None + planned_issue: int | None + strikes: tuple[StrikeDef, ...] = () + + +SESSION_TABLE = "sessions" +SESSION_COLUMNS: tuple[str, ...] = ( + "key", + "session_id", + "time_ms", + "harness", + "provider", + "model", + "skill", + "recipe", + "step", + "level", + "kitchen_id", + "order_id", + "dispatch_id", + "campaign_id", + "caller_session_id", + "parent_session_id", + "success", + "subtype", + "adjudication_reason", + "adjudication_subtype", + "duration_seconds", + "input_tokens", + "output_tokens", + "cache_write_tokens", + "cache_read_tokens", + "assistant_turn_count", + "tool_counts", +) +FACETS: tuple[FacetDef, ...] = ( + FacetDef("harness", "harness", "harness", (), None, None), + FacetDef("provider", "provider", "provider", (), None, None), + FacetDef( + "level", + "level", + "level", + ( + FacetValueDef( + "L0", + "L0", + None, + ( + ( + SESSION_TABLE, + ReasonDef( + "L0 leaf agents write no session row; " + "they exist only as subagent transcripts" + ), + ), + ), + ), + FacetValueDef("L1", "L1", SessionType.SKILL.value), + FacetValueDef("L2", "L2", SessionType.ORCHESTRATOR.value), + FacetValueDef("L3", "L3", SessionType.FLEET.value), + ), + "unrecorded", + ReasonDef("no session row in this index records its orchestration level", 4622), + ), +) +WINDOWS = ( + WindowDef("7d", "7 days", 7), + WindowDef("28d", "28 days", 28), + WindowDef("all", "all history", None), +) +WINDOW_FACET_ID = "window" +WINDOW_HISTORY_ISSUE = 4621 +AVAILABILITY_VOCABULARY: Mapping[TokenMeasureState, tuple[str, str]] = MappingProxyType( + { + TokenMeasureState.MEASURED: ("measured", "Reported by the producer"), + TokenMeasureState.MEASURED_ZERO: ("0", "Reported by the producer as zero"), + TokenMeasureState.UNAVAILABLE: ( + "not reported", + "This harness/provider pair does not emit this field — absent, not zero", + ), + TokenMeasureState.UNKNOWN: ( + "unknown", + "The producer emits this field but the value is missing", + ), + TokenMeasureState.NOT_APPLICABLE: ("n/a", "The field does not apply here"), + } +) +LANDING_VIEW = "cohort" +DECK_VIEWS: tuple[DeckViewDef, ...] = ( + DeckViewDef( + "cohort", + "Who is in this cohort?", + "Whether the population on screen is the one you meant, " + "before you read any number from it", + "Start here", + SESSION_TABLE, + "views/cohort.js", + None, + ), + DeckViewDef( + "spend", + "Where do the tokens go?", + "Where it is worth spending optimization time at all", + "What do you need to know?", + None, + None, + 4651, + ), + DeckViewDef( + "efficiency", + "What's inefficient?", + "Which skill or role is wasteful, as opposed to merely big", + "What do you need to know?", + None, + None, + 4651, + ), + DeckViewDef( + "skill", + "What is this skill spending on?", + "Which step, which subagent, how much is re-transmitted prompt", + "What do you need to know?", + None, + None, + 4651, + ), + DeckViewDef( + "role", + "Can this agent be cheaper?", + "Whether to restrict a role to read-only on a smaller model", + "What do you need to know?", + None, + None, + 4651, + ), + DeckViewDef( + "context", + "Where does context build up?", + "Tune a subagent's output, or insert one to filter", + "What do you need to know?", + None, + None, + 4652, + ), + DeckViewDef( + "errors", + "Where are the errors?", + "Which step of which skill to fix", + "What do you need to know?", + None, + None, + 4652, + ), + DeckViewDef( + "trend", + "What's the trend?", + "Whether a change held, and over what window", + "What do you need to know?", + None, + None, + 4652, + ), + DeckViewDef( + "gaps", + "What can't we see?", + "Which telemetry gap to close first", + "What do you need to know?", + None, + None, + 4652, + ), + DeckViewDef( + "parity", + "Every provider, same fields", + "Target state for cross-pair comparison", + "If telemetry were complete", + None, + None, + 4652, + ), +) + + +def _validate_deck_views( + views: tuple[DeckViewDef, ...], + facets: tuple[FacetDef, ...], + landing: str, +) -> None: + ids = [v.view_id for v in views] + if len(ids) != len(set(ids)): + raise ValueError("duplicate view_id") + facet_ids = {f.facet_id for f in facets} | {WINDOW_FACET_ID} + for view in views: + if view.planned_issue is None: + if not view.table or not view.script: + raise ValueError(f"built view {view.view_id} requires table and script") + elif view.table is not None or view.script is not None: + raise ValueError(f"planned view {view.view_id} must not have table or script") + for strike in view.strikes: + if strike.facet_id not in facet_ids: + raise ValueError(f"unknown strike facet {strike.facet_id}") + if strike.facet_id == WINDOW_FACET_ID and strike.key == "all": + raise ValueError("all history cannot be struck") + if not any(v.view_id == landing and v.planned_issue is None for v in views): + raise ValueError("landing must name a built view") + + +_validate_deck_views(DECK_VIEWS, FACETS, LANDING_VIEW) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index a0d3217bf3..a87f6becfe 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -1174,6 +1174,8 @@ class SourceMapRejection(enum.StrEnum): # file-level: the Docker pin guard compares CODEX_CLI_MIN_VERSION from # execution.backends with the Dockerfile's CODEX_VERSION pin. "infra/test_docker_image.py", + # file-level: the deck column drift guard imports execution.ReportSessionRow. + "report/test_deck_registry.py", "_llm_triage", "smoke_utils", } diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py new file mode 100644 index 0000000000..ce327620b2 --- /dev/null +++ b/tests/report/test_deck_payload.py @@ -0,0 +1,291 @@ +"""Tests for encoding the report deck's index data contract.""" + +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from autoskillit.core import TokenMeasureState +from autoskillit.report.deck._payload import ( + build_deck_payload, + encode_table, + resolve_view_chips, +) +from autoskillit.report.deck._registry import ( + AVAILABILITY_VOCABULARY, + DECK_VIEWS, + LANDING_VIEW, + SESSION_COLUMNS, + SESSION_TABLE, + DeckViewDef, + ReasonDef, + StrikeDef, +) + +pytestmark = [pytest.mark.small] +GEN = datetime(2026, 10, 4, tzinfo=UTC) +EPOCH = datetime(1970, 1, 1, tzinfo=UTC) +GEN_MS = (GEN - EPOCH) // timedelta(milliseconds=1) + + +def _row(key: str, **fields: Any) -> dict[str, Any]: + row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) + row.update( + { + "schema_version": 1, + "kind": "session", + "key": key, + "session_id": key, + "harness": "claude-code", + "provider": "anthropic", + "level": None, + "time_ms": None, + "input_tokens": {"state": "unknown", "value": None}, + "output_tokens": {"state": "unknown", "value": None}, + "cache_write_tokens": {"state": "unknown", "value": None}, + "cache_read_tokens": {"state": "unknown", "value": None}, + } + ) + row.update(fields) + return row + + +def _payload(rows: list[dict[str, Any]]) -> dict[str, Any]: + return build_deck_payload(rows, generated_at=GEN, index_schema_version=7) + + +def _chips_for_view( + rows: list[dict[str, Any]], view: DeckViewDef +) -> dict[str, list[dict[str, Any]]]: + payload = _payload(rows) + return resolve_view_chips( + view, + rows, + generated_at_ms=payload["generated_at_ms"], + history=payload["history"], + ) + + +def _chip(chips: dict[str, list[dict[str, Any]]], facet: str, key: str) -> dict[str, Any]: + return next(chip for chip in chips[facet] if chip["key"] == key) + + +def test_encode_table_aligns_rows_to_columns_and_fills_missing_keys() -> None: + encoded = encode_table([{"key": "row-1", "time_ms": 42}], SESSION_COLUMNS) + + assert encoded["columns"] == list(SESSION_COLUMNS) + assert encoded["rows"] == [[{"key": "row-1", "time_ms": 42}.get(c) for c in SESSION_COLUMNS]] + assert encoded["rows"][0][SESSION_COLUMNS.index("time_ms")] == 42 + assert encoded["rows"][0][SESSION_COLUMNS.index("harness")] is None + + +def test_payload_orders_rows_by_timestamp_then_key_and_handles_epoch() -> None: + rows = [ + _row("late", time_ms=2), + _row("untimed"), + _row("same-b", time_ms=1), + _row("epoch", time_ms=0), + _row("same-a", time_ms=1), + ] + payload = _payload(rows) + table = payload["tables"][SESSION_TABLE] + keys = [row[table["columns"].index("key")] for row in table["rows"]] + + assert keys == ["epoch", "same-a", "same-b", "late", "untimed"] + assert payload["generated_at_ms"] == GEN_MS + assert payload["index_schema_version"] == 7 + assert payload["landing"] == LANDING_VIEW + + +def test_payload_rejects_naive_generated_at() -> None: + with pytest.raises(ValueError): + build_deck_payload([], generated_at=datetime(2026, 10, 4), index_schema_version=1) + + +def test_observed_harness_and_provider_chips_are_live_and_sorted_by_count() -> None: + rows = [ + _row("cc-1", provider="anthropic"), + _row("cc-2", provider="anthropic"), + _row("cc-3", provider="anthropic"), + _row("codex-1", harness="codex", provider="unknown"), + ] + chips = _payload(rows)["chips"]["cohort"] + harness = chips["harness"] + provider = chips["provider"] + + assert [(chip["key"], chip["count"], chip["state"]) for chip in harness] == [ + ("claude-code", 3, "live"), + ("codex", 1, "live"), + ] + assert [(chip["key"], chip["count"]) for chip in provider] == [ + ("anthropic", 3), + ("unknown", 1), + ] + assert next(chip for chip in provider if chip["key"] == "unknown")["state"] == "live" + assert all(chip["key"] == chip["match"] for chip in harness + provider) + assert all(chip["key"] == chip["label"] for chip in harness + provider) + + +def test_cohort_l0_chip_is_struck_with_its_registry_reason() -> None: + rows = [_row("one", level="skill")] + chips = _payload(rows)["chips"]["cohort"] + l0 = _chip(chips, "level", "L0") + + assert l0["state"] == "struck" + assert ( + l0["reason"] + == "L0 leaf agents write no session row; they exist only as subagent transcripts" + ) + assert l0["issue"] is None + + +def test_view_strike_marks_an_observed_chip_with_its_issue() -> None: + reason = ReasonDef("test reason", 9999) + view = DeckViewDef( + "test", + "question", + "decision", + "group", + SESSION_TABLE, + "views/test.js", + None, + (StrikeDef("harness", "codex", reason),), + ) + chips = _chips_for_view([_row("cc"), _row("cx", harness="codex")], view) + codex = _chip(chips, "harness", "codex") + + assert codex["state"] == "struck" + assert codex["count"] == 0 + assert codex["reason"] == "test reason" + assert codex["issue"] == 9999 + + +def test_unrecorded_level_values_use_gap_reason_and_keep_null_bucket() -> None: + rows = [_row("one"), _row("two")] + chips = _payload(rows)["chips"]["cohort"] + levels = chips["level"] + + for key in ("L1", "L2", "L3"): + chip = _chip(chips, "level", key) + assert chip["state"] == "absent" + assert chip["issue"] == 4622 + assert chip["reason"] == "no session row in this index records its orchestration level" + + l0 = _chip(chips, "level", "L0") + unrecorded = _chip(chips, "level", "unrecorded") + assert l0["match"] is None + assert unrecorded["key"] == unrecorded["label"] == "unrecorded" + assert unrecorded["match"] is None + assert unrecorded["state"] == "live" + assert unrecorded["count"] == len(rows) + assert len(levels) == 5 + + +def test_absent_fleet_chip_without_level_gap_has_no_issue() -> None: + chips = _payload([_row("skill", level="skill"), _row("orchestrator", level="orchestrator")])[ + "chips" + ]["cohort"] + l3 = _chip(chips, "level", "L3") + + assert l3["state"] == "absent" + assert l3["reason"] == "no L3 rows in this index" + assert l3["issue"] is None + + +def test_windows_report_recent_data_and_limited_retention() -> None: + rows = [ + _row("one-day", time_ms=GEN_MS - 86_400_000), + _row("nine-days", time_ms=GEN_MS - 9 * 86_400_000), + ] + windows = _payload(rows)["chips"]["cohort"]["window"] + seven = _chip({"window": windows}, "window", "7d") + twenty_eight = _chip({"window": windows}, "window", "28d") + all_history = _chip({"window": windows}, "window", "all") + + assert (seven["state"], seven["count"]) == ("live", 1) + assert twenty_eight["state"] == "absent" + assert twenty_eight["issue"] == 4621 + assert twenty_eight["reason"] == "index history begins 2026-09-25 — 9 days retained" + assert (all_history["state"], all_history["count"]) == ("live", 2) + + +def test_windows_without_recent_rows_and_untimed_history() -> None: + older_rows = [ + _row("forty-days", time_ms=GEN_MS - 40 * 86_400_000), + _row("sixty-days", time_ms=GEN_MS - 60 * 86_400_000), + ] + older = _payload(older_rows)["chips"]["cohort"]["window"] + seven = _chip({"window": older}, "window", "7d") + twenty_eight = _chip({"window": older}, "window", "28d") + all_history = _chip({"window": older}, "window", "all") + assert (seven["state"], seven["reason"], seven["issue"]) == ( + "absent", + "no rows in the last 7 days", + None, + ) + assert (twenty_eight["state"], twenty_eight["reason"], twenty_eight["issue"]) == ( + "absent", + "no rows in the last 28 days", + None, + ) + assert (all_history["state"], all_history["count"]) == ("live", 2) + + untimed = _payload([_row("untimed-a"), _row("untimed-b")])["chips"]["cohort"]["window"] + assert all(chip["state"] == "absent" and chip["issue"] is None for chip in untimed[:-1]) + assert untimed[-1]["key"] == "all" + assert (untimed[-1]["state"], untimed[-1]["count"]) == ("live", 2) + + +def test_payload_shape_history_availability_and_json_safety() -> None: + rows = [ + _row("first", time_ms=1), + _row("last", time_ms=9), + _row("untimed"), + ] + payload = _payload(rows) + built_ids = [view.view_id for view in DECK_VIEWS if view.planned_issue is None] + + assert payload["views"] == [ + { + "id": view.view_id, + "question": view.question, + "decision": view.decision, + "group": view.group, + "status": "built" if view.planned_issue is None else "planned", + "issue": view.planned_issue, + } + for view in DECK_VIEWS + ] + assert set(payload["chips"]) == set(built_ids) + assert payload["facets"][-1] == { + "id": "window", + "label": "window", + "column": "time_ms", + "kind": "window", + } + assert payload["availability"] == [ + { + "state": state.value, + "label": AVAILABILITY_VOCABULARY[state][0], + "description": AVAILABILITY_VOCABULARY[state][1], + } + for state in TokenMeasureState + ] + assert payload["history"] == {"first_ms": 1, "last_ms": 9, "untimed": 1} + json.dumps(payload, allow_nan=False) + + +def test_empty_index_has_absent_declared_values_and_empty_observed_facets() -> None: + payload = _payload([]) + chips = payload["chips"]["cohort"] + + assert payload["tables"][SESSION_TABLE]["rows"] == [] + assert payload["history"] == {"first_ms": None, "last_ms": None, "untimed": 0} + assert chips["harness"] == [] + assert chips["provider"] == [] + for key in ("L1", "L2", "L3"): + chip = _chip(chips, "level", key) + assert (chip["state"], chip["issue"]) == ("absent", 4622) + assert all(chip["state"] == "absent" for chip in chips["window"][:-1]) + assert (chips["window"][-1]["key"], chips["window"][-1]["state"]) == ("all", "live") diff --git a/tests/report/test_deck_registry.py b/tests/report/test_deck_registry.py new file mode 100644 index 0000000000..383d30840d --- /dev/null +++ b/tests/report/test_deck_registry.py @@ -0,0 +1,127 @@ +"""Tests for the report deck's view and facet registry.""" + +import pytest + +from autoskillit.core import SessionType, TokenMeasureState +from autoskillit.execution._report_index_rows import ReportSessionRow +from autoskillit.report.deck._registry import ( + AVAILABILITY_VOCABULARY, + DECK_VIEWS, + FACETS, + LANDING_VIEW, + SESSION_COLUMNS, + SESSION_TABLE, + WINDOWS, + DeckViewDef, + FacetDef, + ReasonDef, + StrikeDef, + _validate_deck_views, +) + +pytestmark = [pytest.mark.small] + + +def _built_view( + *, + view_id: str = "cohort", + table: str | None = SESSION_TABLE, + script: str | None = "views/cohort.js", + strikes: tuple[StrikeDef, ...] = (), +) -> DeckViewDef: + return DeckViewDef( + view_id, + "question", + "decision", + "group", + table, + script, + None, + strikes, + ) + + +def _planned_view( + *, + view_id: str = "future", + table: str | None = None, + script: str | None = None, +) -> DeckViewDef: + return DeckViewDef( + view_id, + "question", + "decision", + "group", + table, + script, + 9999, + ) + + +def _invalid_registry_cases() -> list[ + tuple[str, tuple[DeckViewDef, ...], tuple[FacetDef, ...], str] +]: + built = _built_view() + return [ + ("duplicate view id", (built, built), FACETS, "cohort"), + ("built view missing script", (_built_view(script=None),), FACETS, "cohort"), + ("built view missing table", (_built_view(table=None),), FACETS, "cohort"), + ("planned view has script", (_planned_view(script="views/future.js"),), FACETS, "cohort"), + ("planned view has table", (_planned_view(table=SESSION_TABLE),), FACETS, "cohort"), + ( + "strike names unknown facet", + (_built_view(strikes=(StrikeDef("missing", "x", ReasonDef("reason")),)),), + FACETS, + "cohort", + ), + ( + "all history cannot be struck", + (_built_view(strikes=(StrikeDef("window", "all", ReasonDef("reason")),)),), + FACETS, + "cohort", + ), + ("landing is planned", (built, _planned_view(view_id="future")), FACETS, "future"), + ] + + +@pytest.mark.parametrize( + ("views", "facets", "landing"), + [(views, facets, landing) for _, views, facets, landing in _invalid_registry_cases()], + ids=[name for name, *_ in _invalid_registry_cases()], +) +def test_validate_deck_views_rejects_invalid_registries( + views: tuple[DeckViewDef, ...], facets: tuple[FacetDef, ...], landing: str +) -> None: + with pytest.raises(ValueError): + _validate_deck_views(views, facets, landing) + + +def test_shipped_deck_view_registry_is_valid() -> None: + _validate_deck_views(DECK_VIEWS, FACETS, LANDING_VIEW) + + +def test_level_facet_tracks_session_type_values_and_l0_gap() -> None: + level = next(facet for facet in FACETS if facet.facet_id == "level") + values = {value.key: value for value in level.declared} + + assert values["L1"].match == SessionType.SKILL.value + assert values["L2"].match == SessionType.ORCHESTRATOR.value + assert values["L3"].match == SessionType.FLEET.value + assert values["L0"].match is None + assert SESSION_TABLE in dict(values["L0"].unresolvable_in) + + +def test_session_columns_follow_report_session_row_schema() -> None: + row_keys = ReportSessionRow.__required_keys__ | ReportSessionRow.__optional_keys__ + + assert set(SESSION_COLUMNS) == row_keys - {"schema_version", "kind"} + assert len(SESSION_COLUMNS) == len(set(SESSION_COLUMNS)) + + +def test_availability_vocabulary_covers_every_token_measure_state() -> None: + assert set(AVAILABILITY_VOCABULARY) == set(TokenMeasureState) + + +def test_window_presets_match_the_prototype() -> None: + assert [window.key for window in WINDOWS] == ["7d", "28d", "all"] + assert [window.key for window in WINDOWS if window.days is None] == ["all"] From 9b28b8d9642d2d171254b8ce52d178100aa06ea3 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 14:43:28 -0700 Subject: [PATCH 02/27] fix(report): construct deck epoch within payload builder --- src/autoskillit/report/deck/_payload.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/report/deck/_payload.py b/src/autoskillit/report/deck/_payload.py index d67091d3a9..9165441926 100644 --- a/src/autoskillit/report/deck/_payload.py +++ b/src/autoskillit/report/deck/_payload.py @@ -24,7 +24,6 @@ ) DAY_MS = 86_400_000 -_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) def encode_table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str]) -> dict[str, Any]: @@ -128,7 +127,7 @@ def _window_chips( retained = (generated_at_ms - first) // DAY_MS if retained < window.days: state = ChipState.ABSENT - date = (_EPOCH + timedelta(milliseconds=first)).date().isoformat() + date = datetime.fromtimestamp(first / 1000, UTC).date().isoformat() reason = ReasonDef( f"index history begins {date} — {retained} days retained", WINDOW_HISTORY_ISSUE ) @@ -165,7 +164,8 @@ def build_deck_payload( ) -> dict[str, Any]: if generated_at.utcoffset() is None: raise ValueError("generated_at must be timezone-aware") - generated_at_ms = (generated_at - _EPOCH) // timedelta(milliseconds=1) + epoch = datetime(1970, 1, 1, tzinfo=UTC) + generated_at_ms = (generated_at - epoch) // timedelta(milliseconds=1) rows = sorted( (dict(r) for r in session_rows), key=lambda r: (r.get("time_ms") is None, r.get("time_ms") or 0, r.get("key") or ""), From 3536d82dcaaee6055bd2f8ac9687eb9dcd08f0f0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 15:08:07 -0700 Subject: [PATCH 03/27] feat(report): add DOM-free deck routing and cohort logic --- .autoskillit/test-filter-manifest.yaml | 1 + .gitattributes | 3 +- pyproject.toml | 1 + src/autoskillit/assets/deck/core.js | 205 ++++++++ .../test_manifest_directory_completeness.py | 1 + tests/report/conftest.py | 111 +++++ tests/report/test_deck_core_js.py | 437 ++++++++++++++++++ uv.lock | 22 +- 8 files changed, 778 insertions(+), 3 deletions(-) create mode 100644 src/autoskillit/assets/deck/core.js create mode 100644 tests/report/conftest.py create mode 100644 tests/report/test_deck_core_js.py diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index de5edf7a36..093b3344f7 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -194,6 +194,7 @@ README.md: src/autoskillit/assets/**/*: - assets/ - contracts/ + - report/ tests/arch/fixtures/*: - arch/ diff --git a/.gitattributes b/.gitattributes index fc74f35c96..6e39f05ee3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ # Vendored binary assets — suppress diff spam on updates -src/autoskillit/assets/**/*.js binary -src/autoskillit/assets/**/*.css binary +src/autoskillit/assets/mermaid/*.js binary diff --git a/pyproject.toml b/pyproject.toml index 02bdf69398..e710758808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -483,4 +483,5 @@ api-simulator = { git = "https://github.com/TalonT-Org/api-simulator", rev = "8f [dependency-groups] dev = [ "hypothesis>=6.156.2", + "mini-racer>=0.14.1", ] diff --git a/src/autoskillit/assets/deck/core.js b/src/autoskillit/assets/deck/core.js new file mode 100644 index 0000000000..6640fa1b9f --- /dev/null +++ b/src/autoskillit/assets/deck/core.js @@ -0,0 +1,205 @@ +globalThis.DeckCore = (() => { + "use strict"; + const CHIP_STATES = Object.freeze({LIVE: "live", STRUCK: "struck", ABSENT: "absent"}); + const DAY_MS = 86400000; + const enc = encodeURIComponent; + const formatCount = n => new Intl.NumberFormat("en-US").format(n); + const formatDate = ms => new Date(ms).toISOString().slice(0, 10); + const decodeTable = ({columns, rows}) => rows.map(r => + Object.fromEntries(columns.map((c, i) => [c, r[i]]))); + + function encodeRoute(route, cohortKeys) { + let path = "#/" + enc(route.view); + if (route.entity != null) path += "/" + enc(route.entity); + const params = route.params || {}; + const keys = [...new Set([...cohortKeys, "sort", ...Object.keys(params).sort()])]; + const pairs = []; + for (const key of keys) { + const values = params[key]; + if (values == null || values.length === 0) continue; + pairs.push(enc(key) + "=" + values.map(enc).join(",")); + } + return path + (pairs.length ? "?" + pairs.join("&") : ""); + } + + function decodeRoute(hash, landing) { + const text = hash.replace(/^#/, "").replace(/^\//, ""); + const split = text.indexOf("?"); + const path = (split < 0 ? text : text.slice(0, split)).split("/"); + const query = split < 0 ? "" : text.slice(split + 1); + let view = landing, entity = null; + try { view = decodeURIComponent(path[0]) || landing; } catch (_) {} + if (path.length > 1) { + try { entity = decodeURIComponent(path[1]); } catch (_) {} + } + const params = {}; + for (const pair of query.split("&")) { + const at = pair.indexOf("="); + if (at <= 0 || at === pair.length - 1) continue; + try { + const key = decodeURIComponent(pair.slice(0, at)); + const values = pair.slice(at + 1).split(",").map(decodeURIComponent); + if (key && values.every(v => v !== "")) { + Object.defineProperty(params, key, {value: values, enumerable: true, + writable: true, configurable: true}); + } + } catch (_) {} + } + return {view, entity, params}; + } + + function hrefFor(route, target, cohortKeys) { + const params = Object.fromEntries(cohortKeys.filter(k => + Object.hasOwn(route.params, k)).map(k => [k, [...route.params[k]]])); + for (const [key, value] of Object.entries(target.params || {})) { + if (value === null) delete params[key]; + else Object.defineProperty(params, key, {value, enumerable: true, + writable: true, configurable: true}); + } + return encodeRoute({view: target.view, entity: target.entity ?? null, params}, cohortKeys); + } + + function effectiveSelection(chips, selected) { + const live = chips.filter(c => c.state === "live").map(c => c.key); + if (selected == null) return {keys: live, dropped: [], widened: false}; + const keys = live.filter(k => selected.includes(k)); + const dropped = selected.filter(k => !live.includes(k)); + return {keys: keys.length ? keys : live, dropped, widened: keys.length === 0}; + } + + function toggleSelection(chips, selected, key) { + const live = chips.filter(c => c.state === "live").map(c => c.key); + const current = effectiveSelection(chips, selected).keys; + const next = live.filter(k => k === key ? !current.includes(k) : current.includes(k)); + if (!next.length) return current; + return next.length === live.length ? null : next; + } + + function windowSelection(chips, selected) { + const want = selected?.[0]; + const chip = chips.find(c => c.state === "live" && c.key === want) || + chips.find(c => c.key === "all"); + return {chip, dropped: want && chip.key !== want ? [want] : []}; + } + + function filterRows(model, rows, chips, route) { + let filtered = rows; + for (const facet of model.facets.filter(f => f.kind === "values")) { + const keys = effectiveSelection(chips[facet.id], route.params[facet.id] ?? null).keys; + const allowed = new Set(chips[facet.id].filter(c => keys.includes(c.key)).map(c => c.match)); + filtered = filtered.filter(r => allowed.has(r[facet.column] ?? null)); + } + let untimed = 0; + const window = windowSelection(chips.window, route.params.window ?? null).chip; + if (window.days != null) { + filtered = filtered.filter(row => { + if (row.time_ms == null) { untimed += 1; return false; } + return row.time_ms >= model.generated_at_ms - window.days * DAY_MS; + }); + } + return {rows: filtered, untimed}; + } + + const reasonText = chip => chip.reason + (chip.issue != null ? " (#" + chip.issue + ")" : ""); + function droppedNote(chips, key) { + const chip = chips.find(c => c.key === key); + return chip ? chip.label + " is not selectable on this view — " + reasonText(chip) : + key + " does not appear in this index"; + } + + function populationSentence(model, chips, route, result) { + const n = result.rows.length; + const notes = [], parts = []; + for (const facet of model.facets.filter(f => f.kind === "values")) { + const values = chips[facet.id]; + const selection = effectiveSelection(values, route.params[facet.id] ?? null); + const live = values.filter(c => c.state === "live"); + const labels = values.filter(c => selection.keys.includes(c.key)).map(c => c.label); + parts.push(facet.label + " " + (!live.length ? "none recorded" : labels.join(" + ") + + (selection.keys.length === live.length ? " (all)" : ""))); + notes.push(...selection.dropped.map(k => droppedNote(values, k))); + if (selection.widened) notes.push("no selected " + facet.label + + " is selectable here — showing every selectable " + facet.label); + } + const selection = windowSelection(chips.window, route.params.window ?? null); + const window = selection.chip; + if (window.days != null) { + parts.push("window last " + window.label + " (" + + formatDate(model.generated_at_ms - window.days * DAY_MS) + " → " + + formatDate(model.generated_at_ms) + ")"); + } else { + parts.push("window all history" + (model.history.first_ms == null ? "" : + " (" + formatDate(model.history.first_ms) + " → " + formatDate(model.generated_at_ms) + ")")); + } + notes.push(...selection.dropped.map(k => droppedNote(chips.window, k))); + if (result.untimed) notes.push(formatCount(result.untimed) + (result.untimed === 1 ? + " run without a timestamp falls outside every window" : + " runs without a timestamp fall outside every window")); + return {headline: formatCount(n) + (n === 1 ? " run" : " runs"), detail: parts.join(" · "), notes}; + } + + function summarizePairs(rows) { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.harness, row.provider]); + if (!groups.has(key)) groups.set(key, {harness: row.harness, provider: row.provider, + runs: 0, skills: new Set(), first_ms: null, last_ms: null}); + const group = groups.get(key); + group.runs += 1; + if (row.skill != null) group.skills.add(row.skill); + if (row.time_ms != null) { + group.first_ms = group.first_ms == null ? row.time_ms : Math.min(group.first_ms, row.time_ms); + group.last_ms = group.last_ms == null ? row.time_ms : Math.max(group.last_ms, row.time_ms); + } + } + return [...groups.values()].map(g => ({...g, skills: g.skills.size})).sort((a, b) => + b.runs - a.runs || compare(a.harness, b.harness) || compare(a.provider, b.provider)); + } + + const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0; + function sortRows(rows, key, dir) { + return rows.map((row, i) => ({row, i})).sort((a, b) => { + const av = a.row[key], bv = b.row[key]; + if (av == null || bv == null) { + return av == null && bv == null ? a.i - b.i : av == null ? 1 : -1; + } + const order = typeof av === "number" && typeof bv === "number" ? compare(av, bv) : + compare(String(av), String(bv)); + return order * (dir === "desc" ? -1 : 1) || a.i - b.i; + }).map(x => x.row); + } + + function parseSort(value, keys, fallback) { + const [key, dir] = (value || "").split(":"); + return keys.includes(key) && (dir === "asc" || dir === "desc") && + value === key + ":" + dir ? {key, dir} : fallback; + } + + function chipPresentation(chip, selected) { + const live = chip.state === "live"; + return {text: chip.label + (chip.state === "absent" ? " ✕" : ""), + className: "chip" + (live ? "" : " chip--" + chip.state), disabled: !live, + pressed: live && selected, reason: live ? null : reasonText(chip)}; + } + + function availabilityPresentation(measure, vocabulary) { + const entry = vocabulary.find(v => v.state === measure.state); + if (!entry) throw new Error("unknown availability state: " + measure.state); + return {text: measure.state === "measured" ? formatCount(measure.value) : + measure.state === "measured_zero" ? "0" : entry.label, + className: "av av--" + measure.state, title: entry.description}; + } + + function barLayout(items, {width, labelWidth, valueWidth, rowHeight, gap}) { + const plot = width - labelWidth - valueWidth; + const max = items.reduce((max, item) => item.value == null ? max : Math.max(max, item.value), 0); + return items.map((item, i) => ({label: item.label, value: item.value, href: item.href, + x: labelWidth, y: i * (rowHeight + gap), w: item.value == null ? null : + max > 0 ? item.value / max * plot : 0, h: rowHeight})); + } + + return Object.freeze({decodeTable, encodeRoute, decodeRoute, hrefFor, effectiveSelection, + toggleSelection, windowSelection, filterRows, populationSentence, summarizePairs, sortRows, + parseSort, chipPresentation, availabilityPresentation, barLayout, formatCount, formatDate, + CHIP_STATES, DAY_MS}); +})(); diff --git a/tests/infra/test_manifest_directory_completeness.py b/tests/infra/test_manifest_directory_completeness.py index 03d583692d..0d3e093d65 100644 --- a/tests/infra/test_manifest_directory_completeness.py +++ b/tests/infra/test_manifest_directory_completeness.py @@ -67,6 +67,7 @@ } ), "contracts": frozenset({"src/autoskillit/assets/**/*", "src/autoskillit/agents/**"}), + "report": frozenset({"src/autoskillit/assets/**/*"}), "recipe": frozenset( { "src/autoskillit/skills_extended/*/SKILL.md", diff --git a/tests/report/conftest.py b/tests/report/conftest.py new file mode 100644 index 0000000000..862b60a17f --- /dev/null +++ b/tests/report/conftest.py @@ -0,0 +1,111 @@ +"""Shared fixtures for the standalone observability-deck tests.""" + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from py_mini_racer import MiniRacer + +from autoskillit.core.io.paths import pkg_root +from autoskillit.report.deck._payload import build_deck_payload +from autoskillit.report.deck._registry import SESSION_COLUMNS + +DECK_GENERATED_AT = datetime(2026, 10, 4, tzinfo=UTC) + + +@pytest.fixture +def deck_asset() -> Any: + def read(rel: str) -> str: + return (pkg_root() / "assets" / "deck" / rel).read_text(encoding="utf-8") + + return read + + +def _time_ms(value: datetime) -> int: + epoch = datetime(1970, 1, 1, tzinfo=UTC) + return (value - epoch) // timedelta(milliseconds=1) + + +def _session_row( + key: str, + *, + time_ms: int | None, + harness: str, + provider: str, + skill: str | None, +) -> dict[str, Any]: + row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) + row.update( + { + "schema_version": 1, + "kind": "session", + "key": key, + "session_id": key, + "time_ms": time_ms, + "harness": harness, + "provider": provider, + "skill": skill, + "input_tokens": {"state": "unknown", "value": None}, + "output_tokens": {"state": "unknown", "value": None}, + "cache_write_tokens": {"state": "unknown", "value": None}, + "cache_read_tokens": {"state": "unknown", "value": None}, + } + ) + if key == "s4": + row["cache_write_tokens"] = {"state": "unavailable", "value": None} + return row + + +@pytest.fixture +def deck_rows() -> list[dict[str, Any]]: + return [ + _session_row( + "s1", + time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=1)), + harness="claude-code", + provider="anthropic", + skill="a", + ), + _session_row( + "s2", + time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=2)), + harness="claude-code", + provider="anthropic", + skill="a", + ), + _session_row( + "s3", + time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=9)), + harness="claude-code", + provider="anthropic", + skill="b", + ), + _session_row( + "s4", + time_ms=None, + harness="codex", + provider="codex", + skill=None, + ), + ] + + +@pytest.fixture +def deck_payload(deck_rows: list[dict[str, Any]]) -> dict[str, Any]: + return build_deck_payload(deck_rows, generated_at=DECK_GENERATED_AT, index_schema_version=1) + + +@pytest.fixture +def deck_js(deck_asset: Any) -> Any: + with MiniRacer() as ctx: + ctx.eval(deck_asset("core.js")) + yield ctx + + +@pytest.fixture +def deck_model(deck_js: Any, deck_payload: dict[str, Any]) -> dict[str, Any]: + model = {**deck_payload, "tables": {**deck_payload["tables"]}} + model["tables"]["sessions"] = deck_js.call( + "DeckCore.decodeTable", deck_payload["tables"]["sessions"] + ) + return model diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py new file mode 100644 index 0000000000..73f6122075 --- /dev/null +++ b/tests/report/test_deck_core_js.py @@ -0,0 +1,437 @@ +"""Browser-free contract tests for the observability deck's client logic.""" + +import json +from typing import Any + +import pytest +from py_mini_racer import JSUndefined + +from autoskillit.report.deck._registry import ChipState + +pytestmark = [pytest.mark.small] + +COHORT_KEYS = ["harness", "provider", "level", "window"] + + +def _route(**params: list[str]) -> dict[str, object]: + return {"view": "cohort", "entity": None, "params": params} + + +def test_routes_encode_decode_and_reject_bad_query_values(deck_js: Any) -> None: + route = { + "view": "skill", + "entity": "a/b c", + "params": {"harness": ["x,y", "%"], "sort": ["runs:desc"]}, + } + encoded = deck_js.call("DeckCore.encodeRoute", route, COHORT_KEYS) + assert encoded == "#/skill/a%2Fb%20c?harness=x%2Cy,%25&sort=runs%3Adesc" + assert deck_js.call("DeckCore.decodeRoute", encoded, "cohort") == { + "view": "skill", + "entity": "a/b c", + "params": {"harness": ["x,y", "%"], "sort": ["runs:desc"]}, + } + + for empty_hash in ("", "#", "#/"): + assert deck_js.call("DeckCore.decodeRoute", empty_hash, "cohort") == { + "view": "cohort", + "entity": None, + "params": {}, + } + assert deck_js.call("DeckCore.decodeRoute", "#/cohort?harness=%E0&provider=a", "cohort") == { + "view": "cohort", + "entity": None, + "params": {"provider": ["a"]}, + } + assert ( + deck_js.call( + "DeckCore.encodeRoute", + {"view": "cohort", "entity": None, "params": {"harness": [], "provider": None}}, + COHORT_KEYS, + ) + == "#/cohort" + ) + + +def test_href_for_keeps_cohort_params_and_applies_target_changes(deck_js: Any) -> None: + route = { + "view": "cohort", + "entity": None, + "params": { + "harness": ["codex"], + "window": ["28d"], + "sort": ["runs:desc"], + }, + } + assert ( + deck_js.call("DeckCore.hrefFor", route, {"view": "spend"}, COHORT_KEYS) + == "#/spend?harness=codex&window=28d" + ) + assert ( + deck_js.call( + "DeckCore.hrefFor", + route, + {"view": "spend", "params": {"window": None}}, + COHORT_KEYS, + ) + == "#/spend?harness=codex" + ) + + +def test_facet_selection_drops_struck_keys_and_preserves_one_live_key( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + chips = deck_model["chips"]["cohort"] + harness = chips["harness"] + levels = chips["level"] + + assert deck_js.call("DeckCore.effectiveSelection", harness, None) == { + "keys": ["claude-code", "codex"], + "dropped": [], + "widened": False, + } + mixed = deck_js.call("DeckCore.effectiveSelection", levels, ["unrecorded", "L0"]) + assert mixed["keys"] == ["unrecorded"] + assert mixed["dropped"] == ["L0"] + assert mixed["widened"] is False + + widened = deck_js.call("DeckCore.effectiveSelection", levels, ["L0"]) + assert widened["keys"] == ["unrecorded"] + assert widened["widened"] is True + + selected = deck_js.call("DeckCore.toggleSelection", harness, None, "codex") + assert selected == ["claude-code"] + assert deck_js.call("DeckCore.toggleSelection", harness, selected, "claude-code") == [ + "claude-code" + ] + assert deck_js.call("DeckCore.toggleSelection", harness, selected, "codex") is None + + +def test_window_selection_falls_back_to_all_for_absent_window( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + windows = deck_model["chips"]["cohort"]["window"] + assert deck_js.call("DeckCore.windowSelection", windows, None)["chip"]["key"] == "all" + absent = deck_js.call("DeckCore.windowSelection", windows, ["28d"]) + assert absent["chip"]["key"] == "all" + assert absent["dropped"] == ["28d"] + assert deck_js.call("DeckCore.windowSelection", windows, ["7d"])["chip"]["key"] == "7d" + + +def test_filter_rows_applies_facets_windows_and_untimed_count( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + model = deck_model + rows = model["tables"]["sessions"] + chips = model["chips"]["cohort"] + + unrecorded = deck_js.call( + "DeckCore.filterRows", model, rows, chips, _route(level=["unrecorded"]) + ) + assert sorted(row["key"] for row in unrecorded["rows"]) == ["s1", "s2", "s3", "s4"] + assert unrecorded["untimed"] == 0 + + codex = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(harness=["codex"])) + assert [row["key"] for row in codex["rows"]] == ["s4"] + assert codex["untimed"] == 0 + + last_week = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(window=["7d"])) + assert sorted(row["key"] for row in last_week["rows"]) == ["s1", "s2"] + assert last_week["untimed"] == 1 + + no_timed_codex = deck_js.call( + "DeckCore.filterRows", model, rows, chips, _route(harness=["codex"], window=["7d"]) + ) + assert no_timed_codex["rows"] == [] + assert no_timed_codex["untimed"] == 1 + + absent_window = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(window=["28d"])) + assert sorted(row["key"] for row in absent_window["rows"]) == [ + "s1", + "s2", + "s3", + "s4", + ] + + +def test_population_sentence_explains_the_effective_population( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + model = deck_model + chips = model["chips"]["cohort"] + rows = model["tables"]["sessions"] + + all_route = _route() + all_result = deck_js.call("DeckCore.filterRows", model, rows, chips, all_route) + sentence = deck_js.call("DeckCore.populationSentence", model, chips, all_route, all_result) + assert sentence == { + "headline": "4 runs", + "detail": ( + "harness claude-code + codex (all) · provider anthropic + codex (all) · " + "level unrecorded (all) · window all history " + "(2026-09-25 → 2026-10-04)" + ), + "notes": [], + } + + codex_route = _route(harness=["codex"]) + codex_result = deck_js.call("DeckCore.filterRows", model, rows, chips, codex_route) + codex_sentence = deck_js.call( + "DeckCore.populationSentence", model, chips, codex_route, codex_result + ) + assert codex_sentence["headline"] == "1 run" + assert codex_sentence["detail"].startswith("harness codex · ") + + week_route = _route(window=["7d"]) + week_result = deck_js.call("DeckCore.filterRows", model, rows, chips, week_route) + week_sentence = deck_js.call( + "DeckCore.populationSentence", model, chips, week_route, week_result + ) + assert week_sentence["detail"].endswith("window last 7 days (2026-09-27 → 2026-10-04)") + assert "1 run without a timestamp falls outside every window" in week_sentence["notes"] + + struck_route = _route(level=["L0"]) + struck_result = deck_js.call("DeckCore.filterRows", model, rows, chips, struck_route) + struck_sentence = deck_js.call( + "DeckCore.populationSentence", model, chips, struck_route, struck_result + ) + assert ( + "L0 is not selectable on this view — L0 leaf agents write no session row; " + "they exist only as subagent transcripts" + ) in struck_sentence["notes"] + assert ( + "no selected level is selectable here — showing every selectable level" + in struck_sentence["notes"] + ) + + unknown_route = _route(harness=["nope"]) + unknown_result = deck_js.call("DeckCore.filterRows", model, rows, chips, unknown_route) + unknown_sentence = deck_js.call( + "DeckCore.populationSentence", model, chips, unknown_route, unknown_result + ) + assert "nope does not appear in this index" in unknown_sentence["notes"] + assert ( + "no selected harness is selectable here — showing every selectable harness" + in unknown_sentence["notes"] + ) + + old_window_route = _route(window=["28d"]) + old_window_result = deck_js.call("DeckCore.filterRows", model, rows, chips, old_window_route) + old_window_sentence = deck_js.call( + "DeckCore.populationSentence", model, chips, old_window_route, old_window_result + ) + assert ( + "28 days is not selectable on this view — index history begins 2026-09-25 — " + "9 days retained (#4621)" + ) in old_window_sentence["notes"] + assert old_window_sentence["detail"].endswith("window all history (2026-09-25 → 2026-10-04)") + + +def test_chip_presentation_covers_live_struck_absent_and_issue_reasons( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + cohort = deck_model["chips"]["cohort"] + live = next(chip for chip in cohort["harness"] if chip["key"] == "codex") + assert deck_js.call("DeckCore.chipPresentation", live, True) == { + "text": live["label"], + "className": "chip", + "disabled": False, + "pressed": True, + "reason": None, + } + + struck = next(chip for chip in cohort["level"] if chip["key"] == "L0") + struck_view = deck_js.call("DeckCore.chipPresentation", struck, True) + assert struck_view == { + "text": struck["label"], + "className": "chip chip--struck", + "disabled": True, + "pressed": False, + "reason": struck["reason"], + } + + absent_level = next(chip for chip in cohort["level"] if chip["key"] == "L1") + absent_view = deck_js.call("DeckCore.chipPresentation", absent_level, False) + assert absent_view["text"] == absent_level["label"] + " ✕" + assert absent_view["className"] == "chip chip--absent" + assert absent_view["disabled"] is True + assert absent_view["pressed"] is False + assert absent_view["reason"].endswith("(#4622)") + + absent_window = next(chip for chip in cohort["window"] if chip["key"] == "28d") + assert deck_js.call("DeckCore.chipPresentation", absent_window, False)["reason"].endswith( + "(#4621)" + ) + custom_strike = { + "key": "future", + "label": "future", + "state": ChipState.STRUCK.value, + "reason": "A custom strike", + "issue": 9999, + } + assert deck_js.call("DeckCore.chipPresentation", custom_strike, False)["reason"] == ( + "A custom strike (#9999)" + ) + + +def test_availability_presentation_uses_the_payload_vocabulary( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + vocabulary = deck_model["availability"] + measured = deck_js.call( + "DeckCore.availabilityPresentation", + {"state": "measured", "value": 1234567}, + vocabulary, + ) + assert measured == { + "text": "1,234,567", + "className": "av av--measured", + "title": "Reported by the producer", + } + assert ( + deck_js.call( + "DeckCore.availabilityPresentation", + {"state": "measured_zero", "value": 0}, + vocabulary, + )["text"] + == "0" + ) + + for state in ("unavailable", "unknown", "not_applicable"): + entry = next(item for item in vocabulary if item["state"] == state) + assert deck_js.call( + "DeckCore.availabilityPresentation", {"state": state, "value": None}, vocabulary + ) == { + "text": entry["label"], + "className": f"av av--{state}", + "title": entry["description"], + } + + with pytest.raises(Exception, match="unknown availability state: invalid"): + deck_js.call( + "DeckCore.availabilityPresentation", + {"state": "invalid", "value": None}, + vocabulary, + ) + + +def test_sort_rows_orders_numbers_strings_nulls_and_stable_ties(deck_js: Any) -> None: + numeric = [ + {"value": 2, "id": "two-first"}, + {"value": None, "id": "null"}, + {"id": "undefined"}, + {"value": 1, "id": "one"}, + {"value": 2, "id": "two-second"}, + ] + assert [row["id"] for row in deck_js.call("DeckCore.sortRows", numeric, "value", "asc")] == [ + "one", + "two-first", + "two-second", + "null", + "undefined", + ] + assert [row["id"] for row in deck_js.call("DeckCore.sortRows", numeric, "value", "desc")] == [ + "two-first", + "two-second", + "one", + "null", + "undefined", + ] + + strings = [ + {"value": "b", "id": "b"}, + {"value": "a", "id": "a-first"}, + {"value": "A", "id": "upper"}, + {"value": "a", "id": "a-second"}, + ] + assert [row["id"] for row in deck_js.call("DeckCore.sortRows", strings, "value", "asc")] == [ + "upper", + "a-first", + "a-second", + "b", + ] + assert [row["id"] for row in deck_js.call("DeckCore.sortRows", strings, "value", "desc")] == [ + "b", + "a-first", + "a-second", + "upper", + ] + + fallback = {"key": "runs", "dir": "asc"} + assert deck_js.call("DeckCore.parseSort", "runs:desc", ["runs"], fallback) == { + "key": "runs", + "dir": "desc", + } + assert deck_js.call("DeckCore.parseSort", "bogus", ["runs"], fallback) == fallback + assert deck_js.call("DeckCore.parseSort", JSUndefined, ["runs"], fallback) == fallback + + +def test_bar_layout_scales_values_and_handles_zero_and_null( + deck_js: Any, +) -> None: + options = {"width": 640, "labelWidth": 200, "valueWidth": 80, "rowHeight": 18, "gap": 6} + items = [ + {"label": "six", "value": 6, "href": "#/six"}, + {"label": "three", "value": 3, "href": "#/three"}, + {"label": "unknown", "value": None, "href": "#/unknown"}, + ] + assert deck_js.call("DeckCore.barLayout", items, options) == [ + {"label": "six", "value": 6, "href": "#/six", "x": 200, "y": 0, "w": 360, "h": 18}, + {"label": "three", "value": 3, "href": "#/three", "x": 200, "y": 24, "w": 180, "h": 18}, + { + "label": "unknown", + "value": None, + "href": "#/unknown", + "x": 200, + "y": 48, + "w": None, + "h": 18, + }, + ] + zeros = deck_js.call( + "DeckCore.barLayout", + [ + {"label": "zero-a", "value": 0, "href": "#/zero-a"}, + {"label": "zero-b", "value": 0, "href": "#/zero-b"}, + ], + options, + ) + assert [item["w"] for item in zeros] == [0, 0] + + +def test_summarize_pairs_groups_skills_and_time_bounds( + deck_js: Any, deck_model: dict[str, Any] +) -> None: + rows = deck_model["tables"]["sessions"] + generated_at_ms = deck_model["generated_at_ms"] + day_ms = 86_400_000 + assert deck_js.call("DeckCore.summarizePairs", rows) == [ + { + "harness": "claude-code", + "provider": "anthropic", + "runs": 3, + "skills": 2, + "first_ms": generated_at_ms - 9 * day_ms, + "last_ms": generated_at_ms - day_ms, + }, + { + "harness": "codex", + "provider": "codex", + "runs": 1, + "skills": 0, + "first_ms": None, + "last_ms": None, + }, + ] + + +def test_formatters_and_chip_state_parity(deck_js: Any, deck_model: dict[str, Any]) -> None: + assert deck_js.call("DeckCore.formatCount", 1234567) == "1,234,567" + assert deck_js.call("DeckCore.formatDate", deck_model["generated_at_ms"]) == "2026-10-04" + states = json.loads(deck_js.eval("JSON.stringify(Object.values(DeckCore.CHIP_STATES))")) + assert sorted(states) == sorted(state.value for state in ChipState) + + +def test_core_js_has_no_browser_global_dependency(deck_js: Any) -> None: + assert deck_js.eval("typeof window + typeof document + typeof location") == ( + "undefinedundefinedundefined" + ) diff --git a/uv.lock b/uv.lock index 8bf91d0172..05cc6d16c9 100644 --- a/uv.lock +++ b/uv.lock @@ -181,6 +181,7 @@ dev = [ [package.dev-dependencies] dev = [ { name = "hypothesis" }, + { name = "mini-racer" }, ] [package.metadata] @@ -225,7 +226,10 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "hypothesis", specifier = ">=6.156.2" }] +dev = [ + { name = "hypothesis", specifier = ">=6.156.2" }, + { name = "mini-racer", specifier = ">=0.14.1" }, +] [[package]] name = "backports-tarfile" @@ -1222,6 +1226,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mini-racer" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/b0/5b200bdf093433f1933f783fbc908c23349a1a575c28c81aff75b609c7c1/mini_racer-0.14.1.tar.gz", hash = "sha256:0df25889b7c4e753520324a1687d85e41f9f64984efa81963339ed400f004d49", size = 41771, upload-time = "2026-02-01T05:53:27.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/2c/5857ee4e1714db8956aa878dc90255557458c124f93163704e7de948be03/mini_racer-0.14.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:a401ecdf5f73d4714b76dc3a4c9b5780059cf4c59a5b64cff7497dc6c219d5a0", size = 19988474, upload-time = "2026-02-01T05:53:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/ecaad0c208b9d8bd8f2141f7fa5e520b66915945a2ed56c520524df75fcb/mini_racer-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56cc6965a1665a50d8d613bc43aa83b4cec6b1f09acc69dc4c2845dacb201463", size = 18511166, upload-time = "2026-02-01T05:53:07.059Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/5260cc29908777c91391dd8b61be9042a3d1c089e6bfc798cd403e44e87d/mini_racer-0.14.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7f93d91973ddb2da4e899e06ecb426bfe7ea103cd1c92687ec9c38f206f375eb", size = 21929528, upload-time = "2026-02-01T05:53:10.22Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3c/c5bd479784826bbbc69f713aae2bcfd5ef353ba4e5e0e661666938474535/mini_racer-0.14.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:cdf3a088e1363f16a695288f882abf76b3705b8e1df21418208b87ed010037a4", size = 22178205, upload-time = "2026-02-01T05:53:13.407Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/89905a4238be7ea9afec2f10533668f68898c82914ddd91b2729be547eca/mini_racer-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6788f31608c6c5d0faac4e6b4f36fc5498885f951e510717e8825eaebae6a4c5", size = 21837359, upload-time = "2026-02-01T05:53:16.762Z" }, + { url = "https://files.pythonhosted.org/packages/11/1d/735c5d74239bd0d7d3b271d7f3c91f337c7f937d748dadc2c1c9579df475/mini_racer-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ad4b5c15993caab571fac3d67e0d55157b358bd439e629e0fef26c45a12c8bab", size = 22178200, upload-time = "2026-02-01T05:53:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/78/60/e0708ea8533e928f10f985be35af4c02dd2b61f4a7501dc88e8c927d85e5/mini_racer-0.14.1-py3-none-win_amd64.whl", hash = "sha256:4abd58c62c9955988dbc0cbf5a798914334fe570d2b192f8890bec20135ab6d1", size = 15515732, upload-time = "2026-02-01T05:53:22.276Z" }, + { url = "https://files.pythonhosted.org/packages/99/fd/7fb43e269c44e5d44ef02fbd164fc11833d8293b47ddcd48e4fb1649f4d2/mini_racer-0.14.1-py3-none-win_arm64.whl", hash = "sha256:440bef1269655b1da94b550612b50669de9881c3d88b805c139f7f1b5ec8ae7b", size = 14829128, upload-time = "2026-02-01T05:53:24.983Z" }, +] + [[package]] name = "more-itertools" version = "11.1.0" From 555bf1f0ee1f09903efd4268ce06d3fbb082025f Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 15:09:06 -0700 Subject: [PATCH 04/27] test(report): evaluate undefined sort input inside V8 --- tests/report/test_deck_core_js.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index 73f6122075..35e738f4a2 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -4,7 +4,6 @@ from typing import Any import pytest -from py_mini_racer import JSUndefined from autoskillit.report.deck._registry import ChipState @@ -362,7 +361,14 @@ def test_sort_rows_orders_numbers_strings_nulls_and_stable_ties(deck_js: Any) -> "dir": "desc", } assert deck_js.call("DeckCore.parseSort", "bogus", ["runs"], fallback) == fallback - assert deck_js.call("DeckCore.parseSort", JSUndefined, ["runs"], fallback) == fallback + assert ( + json.loads( + deck_js.eval( + 'JSON.stringify(DeckCore.parseSort(undefined, ["runs"], {key:"runs", dir:"asc"}))' + ) + ) + == fallback + ) def test_bar_layout_scales_values_and_handles_zero_and_null( From 8aef974e0c6f5e4ec8da381a421a8ac5455eae45 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 15:23:50 -0700 Subject: [PATCH 05/27] refactor(cli): share report-index refresh and session fixtures --- src/autoskillit/cli/ops/_sessions.py | 52 +++++++++++++++------------- tests/cli/AGENTS.md | 2 +- tests/cli/_sessions_helpers.py | 26 ++++++++++++++ tests/cli/test_sessions_index_cmd.py | 22 +----------- 4 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 tests/cli/_sessions_helpers.py diff --git a/src/autoskillit/cli/ops/_sessions.py b/src/autoskillit/cli/ops/_sessions.py index 07cf039934..a9898dbd95 100644 --- a/src/autoskillit/cli/ops/_sessions.py +++ b/src/autoskillit/cli/ops/_sessions.py @@ -1,24 +1,47 @@ from __future__ import annotations import sys +from pathlib import Path from cyclopts import App sessions_app = App(name="sessions", help="Session diagnostics and analysis.") +def _refresh_report_index(log_root: Path, index_dir: Path, *, rebuild: bool) -> None: + from autoskillit.core import ArtifactLeaseContention + from autoskillit.execution import rebuild_report_index, update_report_index + + try: + result = ( + rebuild_report_index(log_root, index_dir) + if rebuild + else update_report_index(log_root, index_dir) + ) + except ArtifactLeaseContention: + print( + "report index: another operation holds a required index or source lease", + file=sys.stderr, + ) + raise SystemExit(1) from None + + print(f"report index: walked {result.items_walked} items, wrote {result.rows_written} rows") + for source in result.source_gaps: + print( + f"report index: {source} cursor no longer retained; re-walked retained {source} data", + file=sys.stderr, + ) + + @sessions_app.command(name="index") def sessions_index(*, update: bool = False, rebuild: bool = False) -> None: """Report the derived report index; refresh with --update or re-derive with --rebuild.""" from autoskillit.config import load_config - from autoskillit.core import ArtifactLeaseContention from autoskillit.execution import ( REPORT_INDEX_SCHEMA_VERSION, read_report_index, - rebuild_report_index, report_index_dir, resolve_log_dir, - update_report_index, ) cfg = load_config() @@ -26,28 +49,7 @@ def sessions_index(*, update: bool = False, rebuild: bool = False) -> None: index_dir = report_index_dir(log_root) if rebuild or update: - try: - result = ( - rebuild_report_index(log_root, index_dir) - if rebuild - else update_report_index(log_root, index_dir) - ) - except ArtifactLeaseContention: - print( - "report index: another operation holds a required index or source lease", - file=sys.stderr, - ) - raise SystemExit(1) from None - - print( - f"report index: walked {result.items_walked} items, wrote {result.rows_written} rows" - ) - for source in result.source_gaps: - print( - f"report index: {source} cursor no longer retained; " - f"re-walked retained {source} data", - file=sys.stderr, - ) + _refresh_report_index(log_root, index_dir, rebuild=rebuild) report = read_report_index(index_dir) print( diff --git a/tests/cli/AGENTS.md b/tests/cli/AGENTS.md index 7144a64f0e..61df585fe8 100644 --- a/tests/cli/AGENTS.md +++ b/tests/cli/AGENTS.md @@ -7,4 +7,4 @@ install-over-something as well as install-from-nothing. ## Architecture Notes -`conftest.py` auto-patches `sync_hooks_to_settings()` worktree guard so CLI init tests don't fail in worktree environments. `_fleet_helpers.py` and `_update_checks_helpers.py` are shared factory modules used across split test files for fleet and update-checks functionality respectively. `_split_helpers.py` is a shared module for structural guard helpers (e.g., `_has_pytestmark_cli`) used by multiple split-guard test files. +`conftest.py` auto-patches `sync_hooks_to_settings()` worktree guard so CLI init tests don't fail in worktree environments. `_fleet_helpers.py` and `_update_checks_helpers.py` are shared factory modules used across split test files for fleet and update-checks functionality respectively. `_split_helpers.py` is a shared module for structural guard helpers (e.g., `_has_pytestmark_cli`) used by multiple split-guard test files. `_sessions_helpers.py` is a shared factory module (`_configure_log_root`, `_seed_session`) used by `test_sessions_index_cmd.py` and `test_sessions_deck_cmd.py`. diff --git a/tests/cli/_sessions_helpers.py b/tests/cli/_sessions_helpers.py new file mode 100644 index 0000000000..cd6747b48d --- /dev/null +++ b/tests/cli/_sessions_helpers.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _configure_log_root(monkeypatch: pytest.MonkeyPatch, log_root: Path) -> None: + monkeypatch.setattr( + "autoskillit.config.load_config", + lambda: SimpleNamespace(linux_tracing=SimpleNamespace(log_dir=str(log_root))), + ) + + +def _seed_session(log_root: Path) -> None: + log_root.mkdir(parents=True, exist_ok=True) + session = { + "dir_name": "session-1", + "session_id": "sid-1", + "backend": "claude-code", + "provider_used": "anthropic", + "timestamp": "2020-01-01T00:00:00Z", + } + (log_root / "sessions.jsonl").write_text(json.dumps(session) + "\n", encoding="utf-8") diff --git a/tests/cli/test_sessions_index_cmd.py b/tests/cli/test_sessions_index_cmd.py index f5a6032a49..e7dfb8e15f 100644 --- a/tests/cli/test_sessions_index_cmd.py +++ b/tests/cli/test_sessions_index_cmd.py @@ -2,37 +2,17 @@ from __future__ import annotations -import json from pathlib import Path -from types import SimpleNamespace import pytest from autoskillit.core import ArtifactLease from autoskillit.execution import REPORT_INDEX_SCHEMA_VERSION +from tests.cli._sessions_helpers import _configure_log_root, _seed_session pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] -def _configure_log_root(monkeypatch: pytest.MonkeyPatch, log_root: Path) -> None: - monkeypatch.setattr( - "autoskillit.config.load_config", - lambda: SimpleNamespace(linux_tracing=SimpleNamespace(log_dir=str(log_root))), - ) - - -def _seed_session(log_root: Path) -> None: - log_root.mkdir(parents=True, exist_ok=True) - session = { - "dir_name": "session-1", - "session_id": "sid-1", - "backend": "claude-code", - "provider_used": "anthropic", - "timestamp": "2020-01-01T00:00:00Z", - } - (log_root / "sessions.jsonl").write_text(json.dumps(session) + "\n", encoding="utf-8") - - def test_sessions_index_reports_without_creating_rows( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 2b399542fe52b177c1e566c699737d0ba438729b Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 15:44:25 -0700 Subject: [PATCH 06/27] feat(report): ship self-contained observability deck shell --- docs/cli.md | 11 + docs/decisions/0017-deck-renderer.md | 50 ++++ docs/decisions/README.md | 1 + docs/developer/diagnostics.md | 78 ++++++ src/autoskillit/assets/deck/deck.css | 100 +++++++ src/autoskillit/assets/deck/deck.html | 34 +++ src/autoskillit/assets/deck/shell.js | 280 ++++++++++++++++++++ src/autoskillit/assets/deck/views/cohort.js | 87 ++++++ src/autoskillit/cli/ops/_sessions.py | 27 ++ src/autoskillit/report/__init__.py | 3 +- src/autoskillit/report/deck/__init__.py | 3 +- src/autoskillit/report/deck/_html.py | 76 ++++++ tests/cli/test_sessions_deck_cmd.py | 66 +++++ tests/report/test_deck_core_js.py | 15 +- tests/report/test_deck_html.py | 219 +++++++++++++++ 15 files changed, 1047 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/0017-deck-renderer.md create mode 100644 src/autoskillit/assets/deck/deck.css create mode 100644 src/autoskillit/assets/deck/deck.html create mode 100644 src/autoskillit/assets/deck/shell.js create mode 100644 src/autoskillit/assets/deck/views/cohort.js create mode 100644 src/autoskillit/report/deck/_html.py create mode 100644 tests/cli/test_sessions_deck_cmd.py create mode 100644 tests/report/test_deck_html.py diff --git a/docs/cli.md b/docs/cli.md index bdbdeabd1b..7ed7286e9e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -141,6 +141,17 @@ required report-index or source lease. --- +## autoskillit sessions deck + + autoskillit sessions deck + +Incrementally refreshes the derived report index, then writes a self-contained HTML +observability deck to ``. Open the file directly in a browser; it needs no +server or network connection. The command exits with status 1 without writing the file +when another operation holds a required report-index or source lease. + +--- + ## autoskillit codex-orphans autoskillit codex-orphans [--reap] [--output-json] diff --git a/docs/decisions/0017-deck-renderer.md b/docs/decisions/0017-deck-renderer.md new file mode 100644 index 0000000000..86b0f394e4 --- /dev/null +++ b/docs/decisions/0017-deck-renderer.md @@ -0,0 +1,50 @@ +# ADR-0017: Hand-written SVG renders the observability deck + +**Status:** Accepted +**Date:** 2026-10-04 +**Issue:** [#4650](https://github.com/TalonT-Org/AutoSkillit/issues/4650) + +## Context + +The observability deck must open as one self-contained file, work without a network +connection, and print its charts as vector graphics. Research recommended Apache +ECharts for charting, so the prototype's “Where do the tokens go?” view was built both +with ECharts and with first-party SVG using the same real data. + +## Decision + +Render charts with hand-written SVG. Include no third-party chart library in the deck. + +## Rationale + +Both prototype files were checked in offline headless Chromium and printed to PDF. The +PDF content streams were inspected for vector and raster content. + +| | Hand-written SVG | Apache ECharts 6.1.0, SVG renderer | +|---|---:|---:| +| Total HTML | 13,283 B | 532,044 B | +| Chart-drawing code | 44 lines | 43 lines | +| Toolchain | None | npm and `npx esbuild` | +| Light PDF | 60,760 B; vector, 0 raster | 588,351 B; vector, 0 raster | +| Theme colors | Standard CSS variables | CSS variable passthrough is undocumented; default theme hardcodes hex | +| SVG accessibility | ARIA and a named list can be authored | `AriaComponent` emitted no ARIA attributes | + +ECharts contributed 518,096 B (97.4%) of its HTML. Its often-cited 177,738 B figure is +the gzip size; a `file://` page receives no transport compression. ECharts also embedded +duplicated font subsets in the PDF. The hand-written version needs no build tool and +keeps colors in the deck's CSS palettes. + +The alternatives survey found ApexCharts under a proprietary revenue-gated license; +Chart.js and uPlot use canvas and therefore do not meet the vector-print requirement. +Frappe Charts supports SVG under MIT, but had no repository push in the preceding 15 +months at the time of the survey. + +## Re-evaluation trigger + +Re-run the build comparison if a view needs zoom, brushing, or linked hover across charts +shown at once. Compare both approaches against that concrete interaction need. + +## Enforcement + +Deck tests enforce the asset closure, prohibit network access, and check the content +security policy. The deck assets must not acquire a third-party chart library. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 39bc69479b..2b2f946476 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -12,3 +12,4 @@ - [0010-systemd-scope-defense-in-depth.md](0010-systemd-scope-defense-in-depth.md) — systemd scope wrapping is a best-effort kernel backstop on top of the tether sweep, never the ceiling of record - [0015-write-containment.md](0015-write-containment.md) — typed skill write scopes composed by union across interactive and headless sessions - [0016-owner-scope.md](0016-owner-scope.md) — a fleet dispatch settles every funnel-spawned descendant of its L2, selected by owner-scope registration, before it persists or returns +- [0017-deck-renderer.md](0017-deck-renderer.md) — hand-written SVG, no chart library, for the self-contained observability deck diff --git a/docs/developer/diagnostics.md b/docs/developer/diagnostics.md index 38d905c705..5aaa80267f 100644 --- a/docs/developer/diagnostics.md +++ b/docs/developer/diagnostics.md @@ -367,6 +367,84 @@ Codex OTLP token events are not projected: they do not carry a stable request identity for deduplication. Codex token measures enter the report index through the session rows. +## Observability deck + +`autoskillit sessions deck ` incrementally refreshes the report index and writes +one self-contained HTML deck. It can be opened from `file://` without a server or network +connection. The deck embeds the index's session rows and filters them in the browser. +ADR-0017 records why charts use first-party SVG. + +### Payload contract + +The payload keys are `generated_at_ms`, `index_schema_version`, `landing`, and `history`; +`tables.sessions{columns,rows}`; `facets[{id,label,column,kind}]`; +`views[{id,question,decision,group,status,issue}]`; +`chips{view:{facet:[{key,label,state,count,reason,issue,match|days}]}}`; and +`availability[{state,label,description}]`. `match` is used for value facets and `days` +for windows. `views[].issue` tracks the issue for building a planned view; a chip's +`issue` identifies why that choice is unavailable. These fields have separate meanings. + +### Chip states + +Each cohort choice is `live`, `struck`, or `absent`. Live choices filter the rows. +Struck choices remain visible with a reason and cannot be selected. Absent choices have +no matching rows. L0 is struck because L0 leaf agents write no session row and exist +only as subagent transcripts. On a legacy index, unavailable orchestration levels cite +[#4622](https://github.com/TalonT-Org/AutoSkillit/issues/4622); windows extending before +the retained history cite [#4621](https://github.com/TalonT-Org/AutoSkillit/issues/4621). + +### Routing + +Routes use `#/[/][?k=v1,v2]`. Cohort selections survive navigation between +views and entities; the `sort` choice applies only to its view. Every state change is a +hash navigation, so browser back and forward restore prior selections. Planned views +show a notice while keeping the cohort bar available. + +### Offline rendering and client code + +The generated HTML embeds its assets and JSON payload. Its content security policy +disallows network requests, and the JSON is escaped before embedding. The asset set is +`deck.html`, `deck.css`, `core.js`, `shell.js`, and the built view scripts under +`assets/deck/views/`; no other files are allowed. See +[ADR-0017](../decisions/0017-deck-renderer.md) for the renderer decision. + +`core.js` is DOM-free and tested in V8 with `mini-racer`. `shell.js` renders the page +without putting data in `innerHTML` or using inline event handlers. Availability uses +the five producer states: `measured`, `measured_zero`, `unavailable`, `unknown`, and +`not_applicable`. + +### Adding a view + +Mark its `DeckViewDef` as built by providing its table and script, add +`assets/deck/views/.js`, and register it with `DeckShell.registerView`. Use +`ctx.sortableTable`, `ctx.href` or `entityLink`, `ctx.availabilityCell`, and +`ctx.barChart` for shared rendering. Pair each `ctx.barChart` with a visible +`ctx.sortableTable` over the same rows so exact values remain available in a sortable +list. Do not give the chart `role="img"`; that hides its bar labels and links from +assistive technology. + +### Manual check + +1. Generate a deck at a repository-local temporary path: + + autoskillit sessions deck .autoskillit/temp/deck.html + +2. Open that file from `file://` with DevTools Network set to offline. The landing view + and cohort bar should render with no hash, and the page should make zero requests. +3. Toggle harness and provider chips; confirm the population sentence changes. +4. Confirm L0 is struck with its reason. On a legacy index, L1–L3 should show ✕ with + #4622; windows longer than retained history should show ✕ with #4621. +5. Follow a harness link, then use back and forward; confirm the cohort remains selected. +6. Open a planned route such as `#/spend`; confirm its notice appears and the cohort bar + remains visible. +7. Emulate `prefers-color-scheme: dark`, then `light`; confirm both palettes render. +8. In print preview, confirm the light palette, vector bars, printed cohort sentence, and + hidden navigation rail. +9. In the Accessibility tree, confirm that “Runs by harness and provider” is a named + list whose items expose their label link and value (with the SVG rectangles hidden), + each chip button has a name, disabled-chip reasons are exposed, and planned nav + entries are `aria-disabled`. + ## Child Terminal Reasons Issue #4623. Every observed L0 child run (native Claude subagent, native Codex diff --git a/src/autoskillit/assets/deck/deck.css b/src/autoskillit/assets/deck/deck.css new file mode 100644 index 0000000000..c1c25d4d72 --- /dev/null +++ b/src/autoskillit/assets/deck/deck.css @@ -0,0 +1,100 @@ +:root { + color-scheme: light; + --paper: #f6f7f9; --surface: #ffffff; --surface-2: #eef0f4; --surface-3: #e3e6ec; + --ink: #12151b; --ink-2: #454c59; --ink-3: #6b7280; --rule: #d5d9e2; --rule-2: #c2c8d4; + --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; + --good: #1a7f4b; --warn: #a86a00; --serious: #c2410c; --crit: #b3261e; + --good-bg: #e4f2ea; --warn-bg: #faf0dc; --serious-bg: #fbe9df; --crit-bg: #fbe5e3; + --hatch: rgba(107,114,128,.42); + --mono: ui-monospace,"SFMono-Regular",Menlo,Consolas,"Liberation Mono",monospace; + --sans: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; +} +@media screen and (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --paper: #0e1116; --surface: #161a21; --surface-2: #1d222b; --surface-3: #272d38; + --ink: #eef1f6; --ink-2: #aeb6c4; --ink-3: #7f8899; --rule: #2b323d; --rule-2: #3a424f; + --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; + --good: #4ec38a; --warn: #e0a63a; --serious: #f08050; --crit: #f0736b; + --good-bg: #15291f; --warn-bg: #2b2415; --serious-bg: #2e1d15; --crit-bg: #301a19; + --hatch: rgba(174,182,196,.36); + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 var(--sans); } +a { color: var(--s1); text-decoration-thickness: 1px; text-underline-offset: 2px; } +button { font: inherit; } +button, a { touch-action: manipulation; } +:focus-visible { outline: 2px solid var(--s1); outline-offset: 3px; } +.shell { display: grid; grid-template-columns: 246px minmax(0,1fr); min-height: 100vh; } +.rail { padding: 24px 14px; background: var(--surface); border-right: 1px solid var(--rule); } +.brand { padding: 0 12px 24px; font-weight: 700; letter-spacing: -.02em; font-size: 20px; } +.brand span { display: block; margin-top: 4px; font: 11px var(--mono); color: var(--ink-3); } +.nav-group { margin: 16px 12px 7px; color: var(--ink-3); font-size: 11px; text-transform: uppercase; } +.navq { display: block; border-left: 3px solid transparent; padding: 10px 11px; margin: 4px 0; + color: var(--ink-2); text-decoration: none; border-radius: 0 5px 5px 0; } +.navq small { display: block; margin-top: 4px; color: var(--ink-3); font-size: 11px; } +.navq[aria-current="true"] { border-left-color: var(--s1); background: var(--surface-2); color: var(--ink); } +.navq--planned { background: repeating-linear-gradient(135deg,transparent 0 3px,var(--hatch) 3px 4px); + color: var(--ink-3); cursor: not-allowed; } +main { min-width: 0; } +.cohort { position: sticky; top: 0; z-index: 6; padding: 20px 28px; background: var(--surface); + border-bottom: 1px solid var(--rule); box-shadow: 0 4px 14px rgba(0,0,0,.03); } +.cohort-sentence { margin-bottom: 14px; color: var(--ink-2); } +.cohort-sentence b { display: block; margin-bottom: 3px; color: var(--ink); font-size: 26px; font-weight: 650; } +.cohort-group { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 7px 0; } +.cohort-group > span:first-child { min-width: 64px; color: var(--ink-3); font: 11px var(--mono); } +.chip { border: 1px solid var(--rule-2); border-radius: 16px; padding: 4px 10px; + color: var(--ink-2); background: var(--surface); font: 11px/1.4 var(--mono); cursor: pointer; } +.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); } +.chip[aria-pressed="true"]::before { content: "✓ "; } +.chip--struck { text-decoration: line-through; opacity: .5; cursor: not-allowed; } +.chip--absent { opacity: .6; cursor: not-allowed; + background: repeating-linear-gradient(135deg,transparent 0 3px,var(--hatch) 3px 4px); } +.cohort-notes, .cohort-reasons { color: var(--ink-3); font-size: 11px; margin: 8px 0; padding-left: 18px; } +.cohort-notes { color: var(--warn); } +.cohort-reset { display: inline-block; font-size: 11px; margin-top: 8px; } +#deck-view { padding: 24px 28px; } +h1 { font-size: 24px; letter-spacing: -.025em; margin: 0 0 18px; } +h2 { font-size: 16px; font-weight: 650; margin: 0 0 16px; } +.card { background: var(--surface); border: 1px solid var(--rule); border-radius: 8px; + padding: 20px; margin-bottom: 18px; overflow-x: auto; } +.card p { color: var(--ink-2); } +table { border-collapse: collapse; width: 100%; font-size: 12px; } +thead { position: sticky; top: 0; background: var(--surface-2); } +th, td { padding: 9px 10px; border-bottom: 1px solid var(--rule); text-align: left; } +th { color: var(--ink-3); font-size: 11px; font-weight: 500; } +th button { border: 0; padding: 0; color: inherit; background: transparent; cursor: pointer; } +td.numeric, th.numeric { text-align: right; font: 12px var(--mono); font-variant-numeric: tabular-nums; } +th[aria-sort="ascending"] button::after { content: " ▲"; } +th[aria-sort="descending"] button::after { content: " ▼"; } +.av { display: inline-block; border-radius: 4px; padding: 2px 6px; font: 11px var(--mono); } +.av--measured, .av--measured_zero { font-variant-numeric: tabular-nums; } +.av--unavailable { background: repeating-linear-gradient(135deg,transparent 0 3px,var(--hatch) 3px 4px); } +.av--unknown { border: 1px dotted var(--ink-3); } +.av--not_applicable { color: var(--ink-3); } +dl { display: grid; grid-template-columns: max-content minmax(0,1fr); gap: 10px 18px; } +dt, dd { margin: 0; } +dd { color: var(--ink-2); } +svg { display: block; max-width: 100%; height: auto; } +.bar { fill: var(--s1); } +.bar--absent { fill: url(#deck-hatch); } +.hatch-line { stroke: var(--hatch); stroke-width: 2; } +.bar-label, .bar-value { fill: var(--ink-2); font: 11px var(--mono); } +.defs { position: absolute; } +.foot { color: var(--ink-3); font: 10px/1.5 var(--mono); padding: 0 28px 24px; } +@media screen and (max-width: 860px) { + .shell { grid-template-columns: minmax(0,1fr); } + .rail { border-right: 0; border-bottom: 1px solid var(--rule); } + .cohort { position: static; } + .cohort, #deck-view { padding: 18px; } +} +@media print { + @page { margin: 12mm; } + * { print-color-adjust: exact; -webkit-print-color-adjust: exact; } + .rail { display: none; } + .shell { display: block; } + .cohort { position: static; } + .card, table, svg { break-inside: avoid; } + #deck-view { padding: 18px 0; } +} diff --git a/src/autoskillit/assets/deck/deck.html b/src/autoskillit/assets/deck/deck.html new file mode 100644 index 0000000000..79c228b8fe --- /dev/null +++ b/src/autoskillit/assets/deck/deck.html @@ -0,0 +1,34 @@ + + + + + + + AutoSkillit observability deck + + + +
+ +
+
+
+
+
+
+ + + + + + diff --git a/src/autoskillit/assets/deck/shell.js b/src/autoskillit/assets/deck/shell.js new file mode 100644 index 0000000000..a42d05b890 --- /dev/null +++ b/src/autoskillit/assets/deck/shell.js @@ -0,0 +1,280 @@ +globalThis.DeckShell = (() => { + "use strict"; + + const renderers = new Map(); + + function el(tag, attrs = {}, children = null) { + const node = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) continue; + node.setAttribute(name, value === true ? "" : String(value)); + } + if (Array.isArray(children)) { + for (const child of children) { + if (child !== null && child !== undefined) node.appendChild(child); + } + } else if (children !== null && children !== undefined) { + if (typeof children === "object") node.appendChild(children); + else node.textContent = String(children); + } + return node; + } + + function registerView(id, render) { + if (renderers.has(id)) throw new Error("duplicate deck view: " + id); + renderers.set(id, render); + } + + function registeredViews() { + return Array.from(renderers.keys()); + } + + function boot(payload) { + const model = {...payload, tables: Object.fromEntries( + Object.entries(payload.tables).map(([key, table]) => [key, DeckCore.decodeTable(table)]))}; + const cohortKeys = payload.facets.map(facet => facet.id); + let route; + + function entityLink(text, target) { + return el("a", {href: DeckCore.hrefFor(route, target, cohortKeys)}, text); + } + + function sortableTable({columns, rows, defaultSort}) { + const keys = columns.map(column => column.key); + const sort = DeckCore.parseSort(route.params.sort?.[0], keys, defaultSort); + const head = el("thead", {}, el("tr", {}, columns.map(column => { + const direction = column.key === sort.key ? sort.dir : "none"; + const th = el("th", { + "aria-sort": direction === "asc" ? "ascending" : + direction === "desc" ? "descending" : "none", + class: column.numeric ? "numeric" : null + }); + const button = el("button", {type: "button"}, column.label); + button.addEventListener("click", () => { + const params = {...route.params}; + params.sort = [column.key + ":" + + (column.key === sort.key && sort.dir === "asc" ? "desc" : "asc")]; + location.hash = DeckCore.encodeRoute({...route, params}, cohortKeys); + }); + th.appendChild(button); + return th; + }))); + const body = el("tbody", {}, DeckCore.sortRows(rows, sort.key, sort.dir).map(row => + el("tr", {}, columns.map(column => { + const value = column.cell ? column.cell(row) : row[column.key]; + return el("td", {class: column.numeric ? "numeric" : null}, + value === null || value === undefined ? "—" : value); + })))); + return el("table", {}, [head, body]); + } + + function availabilityCell(measure) { + const presentation = DeckCore.availabilityPresentation(measure, model.availability); + return el("span", {class: presentation.className, title: presentation.title}, + presentation.text); + } + + function barChart(items, {label, width = 640}) { + const NS = "http://www.w3.org/2000/svg"; + const labelWidth = 200, valueWidth = 80, rowHeight = 18, gap = 6; + const layout = DeckCore.barLayout(items, {width, labelWidth, valueWidth, rowHeight, gap}); + const height = layout.length ? layout[layout.length - 1].y + rowHeight : rowHeight; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("role", "list"); + svg.setAttribute("aria-label", label); + svg.setAttribute("viewBox", "0 0 " + width + " " + height); + + layout.forEach(bar => { + const group = document.createElementNS(NS, "g"); + group.setAttribute("role", "listitem"); + + const labelText = document.createElementNS(NS, "text"); + labelText.setAttribute("class", "bar-label"); + labelText.setAttribute("x", "0"); + labelText.setAttribute("y", String(bar.y + 13)); + labelText.textContent = bar.label; + if (bar.href) { + const link = document.createElementNS(NS, "a"); + link.setAttribute("href", bar.href); + link.appendChild(labelText); + group.appendChild(link); + } else { + group.appendChild(labelText); + } + + const rect = document.createElementNS(NS, "rect"); + rect.setAttribute("class", bar.w === null ? "bar bar--absent" : "bar"); + rect.setAttribute("aria-hidden", "true"); + rect.setAttribute("x", String(bar.x)); + rect.setAttribute("y", String(bar.y)); + rect.setAttribute("width", String(bar.w === null ? width - labelWidth - valueWidth : bar.w)); + rect.setAttribute("height", String(bar.h)); + group.appendChild(rect); + + const valueText = document.createElementNS(NS, "text"); + valueText.setAttribute("class", "bar-value"); + valueText.setAttribute("x", String(width - valueWidth + 4)); + valueText.setAttribute("y", String(bar.y + 13)); + valueText.textContent = bar.value == null ? "not reported" : DeckCore.formatCount(bar.value); + group.appendChild(valueText); + svg.appendChild(group); + }); + return svg; + } + + function renderNav() { + const nav = document.getElementById("deck-nav"); + nav.replaceChildren(); + const groups = new Map(); + for (const view of model.views) { + if (!groups.has(view.group)) groups.set(view.group, []); + groups.get(view.group).push(view); + } + for (const [group, views] of groups) { + nav.appendChild(el("div", {class: "nav-group"}, group)); + for (const view of views) { + const current = view.id === route.view; + let item; + if (view.status === "built") { + item = el("a", { + class: "navq", + "data-view": view.id, + href: DeckCore.hrefFor(route, {view: view.id}, cohortKeys) + }, [el("span", {}, view.question), el("small", {}, view.decision)]); + } else { + item = el("span", { + class: "navq navq--planned", + "data-view": view.id, + "aria-disabled": "true" + }, [el("span", {}, view.question), + el("small", {}, "arrives in #" + view.issue)]); + } + if (current) item.setAttribute("aria-current", "true"); + nav.appendChild(item); + } + } + } + + function renderCohort(chips, result, sentence) { + const cohort = document.getElementById("deck-cohort"); + cohort.replaceChildren(); + cohort.appendChild(el("div", {class: "cohort-sentence"}, [ + el("b", {}, sentence.headline), el("span", {}, sentence.detail) + ])); + cohort.appendChild(el("ul", {class: "cohort-notes"}, sentence.notes.map(note => + el("li", {}, note)))); + + for (const facet of model.facets) { + const facetChips = facet.kind === "window" ? chips.window : chips[facet.id]; + const selectedKeys = facet.kind === "window" ? + [DeckCore.windowSelection(chips.window, route.params.window ?? null).chip.key] : + DeckCore.effectiveSelection(chips[facet.id], route.params[facet.id] ?? null).keys; + const group = el("div", {class: "cohort-group"}, [el("span", {}, facet.label)]); + const unavailable = []; + facetChips.forEach((chip, index) => { + const presentation = DeckCore.chipPresentation(chip, selectedKeys.includes(chip.key)); + if (!presentation.disabled) { + const button = el("button", { + type: "button", + class: presentation.className, + "aria-pressed": String(presentation.pressed), + "data-facet": facet.id, + "data-key": chip.key + }, presentation.text); + button.addEventListener("click", () => { + const params = {...route.params}; + if (facet.kind === "window") { + params.window = chip.key === "all" ? null : [chip.key]; + } else { + params[facet.id] = DeckCore.toggleSelection(chips[facet.id], + route.params[facet.id] ?? null, chip.key); + } + location.hash = DeckCore.encodeRoute({...route, params}, cohortKeys); + }); + group.appendChild(button); + } else { + const reasonId = "deck-reason-" + facet.id + "-" + index; + group.appendChild(el("button", { + type: "button", + class: presentation.className, + disabled: true, + "aria-describedby": reasonId + }, presentation.text)); + unavailable.push(el("li", {id: reasonId}, chip.label + " — " + presentation.reason)); + } + }); + cohort.appendChild(group); + if (unavailable.length) { + const reasons = el("ul", {class: "cohort-reasons"}, unavailable); + cohort.appendChild(reasons); + } + } + + const resetParams = Object.fromEntries(cohortKeys.map(key => [key, null])); + cohort.appendChild(el("a", { + class: "cohort-reset", + href: DeckCore.hrefFor(route, { + view: route.view, entity: route.entity, params: resetParams + }, cohortKeys) + }, "Reset cohort")); + } + + function render() { + route = DeckCore.decodeRoute(location.hash, model.landing); + renderNav(); + const view = model.views.find(item => item.id === route.view); + let chips, renderer = null, notice = null; + if (view && view.status === "built") { + renderer = renderers.get(view.id); + if (!renderer) throw new Error("built deck view is not registered: " + view.id); + chips = model.chips[view.id]; + } else if (view) { + chips = model.chips[model.landing]; + notice = view.question + " arrives in #" + view.issue; + } else { + chips = model.chips[model.landing]; + notice = "No view named " + route.view; + } + + const table = model.tables.sessions; + const result = DeckCore.filterRows(model, table, chips, route); + const sentence = DeckCore.populationSentence(model, chips, route, result); + renderCohort(chips, result, sentence); + + const content = document.getElementById("deck-view"); + content.replaceChildren(); + if (renderer) { + const ctx = {model, route, rows: result.rows, chips, + href: target => DeckCore.hrefFor(route, target, cohortKeys), + el, entityLink, sortableTable, availabilityCell, barChart}; + content.appendChild(renderer(ctx)); + } else { + const noticeCard = el("section", {class: "card"}, [el("p", {}, notice)]); + if (!view) { + noticeCard.appendChild(el("a", { + href: DeckCore.hrefFor(route, {view: model.landing}, cohortKeys) + }, "Return to the cohort view")); + } + content.appendChild(noticeCard); + } + + const first = model.history.first_ms; + const last = model.history.last_ms; + const history = first === null || last === null ? "—" : + DeckCore.formatDate(first) + " → " + DeckCore.formatDate(last); + const generated = new Date(model.generated_at_ms).toISOString().slice(0, 16); + document.getElementById("deck-foot").textContent = "report index v" + + model.index_schema_version + " · " + DeckCore.formatCount(table.length) + + " session rows · history " + history + " · generated " + generated + " UTC"; + } + + window.addEventListener("hashchange", render); + render(); + } + + return Object.freeze({registerView, registeredViews, boot}); +})(); + +if (typeof document !== "undefined") + document.addEventListener("DOMContentLoaded", () => + DeckShell.boot(JSON.parse(document.getElementById("deck-data").textContent))); diff --git a/src/autoskillit/assets/deck/views/cohort.js b/src/autoskillit/assets/deck/views/cohort.js new file mode 100644 index 0000000000..a286a320a0 --- /dev/null +++ b/src/autoskillit/assets/deck/views/cohort.js @@ -0,0 +1,87 @@ +DeckShell.registerView("cohort", ctx => { + const fragment = document.createDocumentFragment(); + const pairs = DeckCore.summarizePairs(ctx.rows); + const chartItems = pairs.map(pair => ({ + label: pair.harness + " · " + pair.provider, + value: pair.runs, + href: ctx.href({view: "cohort", params: { + harness: [pair.harness], provider: [pair.provider] + }}) + })); + const chartCard = ctx.el("section", {class: "card"}, [ + ctx.el("h2", {}, "Runs by harness · provider"), + ctx.barChart(chartItems, {label: "Runs by harness and provider"}), + ctx.sortableTable({ + columns: [ + {key: "harness", label: "Harness", cell: pair => ctx.entityLink(pair.harness, { + view: "cohort", params: {harness: [pair.harness]} + })}, + {key: "provider", label: "Provider", cell: pair => ctx.entityLink(pair.provider, { + view: "cohort", params: {provider: [pair.provider]} + })}, + {key: "runs", label: "Runs", numeric: true}, + {key: "skills", label: "Skills", numeric: true}, + {key: "first_ms", label: "First run", cell: pair => pair.first_ms === null ? + "—" : DeckCore.formatDate(pair.first_ms)}, + {key: "last_ms", label: "Last run", cell: pair => pair.last_ms === null ? + "—" : DeckCore.formatDate(pair.last_ms)} + ], + rows: pairs, + defaultSort: {key: "runs", dir: "desc"} + }) + ]); + fragment.appendChild(chartCard); + + const availabilityRows = []; + for (const entry of ctx.model.availability) { + const value = entry.state === "measured" ? 1234 : + entry.state === "measured_zero" ? 0 : null; + availabilityRows.push(ctx.el("dt", {}, + ctx.availabilityCell({state: entry.state, value}))); + availabilityRows.push(ctx.el("dd", {}, entry.description)); + } + + const samples = [ + { + chip: {key: "x", label: "live", state: "live"}, selected: true, + meaning: "This value can be selected on the current view." + }, + { + chip: {key: "x", label: "struck", state: "struck", reason: "not selectable here", + issue: 4622}, selected: false, + meaning: "This value is defined but cannot be selected on the current view." + }, + { + chip: {key: "x", label: "absent", state: "absent", reason: "not recorded", + issue: 4622}, selected: false, + meaning: "This value is not recorded in the index, so it is not zero." + } + ]; + const helpCard = ctx.el("section", {class: "card"}, [ + ctx.el("h2", {}, "How to read this deck"), + ctx.el("dl", {}, availabilityRows), + ...samples.map(sample => { + const presentation = DeckCore.chipPresentation(sample.chip, sample.selected); + return ctx.el("p", {}, [ + ctx.el("span", {class: presentation.className, "aria-hidden": "true"}, + presentation.text), + ctx.el("span", {}, " " + sample.meaning) + ]); + }) + ]); + fragment.appendChild(helpCard); + + if (ctx.rows.length === 0) { + const emptyChildren = [ctx.el("p", {}, "No runs match this cohort.")]; + if (ctx.model.tables.sessions.length === 0) { + emptyChildren.push(ctx.el("p", {}, [ + ctx.el("span", {}, "Run "), + ctx.el("code", {}, "autoskillit sessions index --update"), + ctx.el("span", {}, " to populate the report index.") + ])); + } + fragment.appendChild(ctx.el("section", {class: "card"}, emptyChildren)); + } + + return fragment; +}); diff --git a/src/autoskillit/cli/ops/_sessions.py b/src/autoskillit/cli/ops/_sessions.py index a9898dbd95..409a5609a0 100644 --- a/src/autoskillit/cli/ops/_sessions.py +++ b/src/autoskillit/cli/ops/_sessions.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from datetime import UTC, datetime from pathlib import Path from cyclopts import App @@ -59,6 +60,32 @@ def sessions_index(*, update: bool = False, rebuild: bool = False) -> None: ) +@sessions_app.command(name="deck") +def sessions_deck(output: str) -> None: + """Render the observability deck from the report index into one self-contained HTML file.""" + from autoskillit.config import load_config + from autoskillit.core import atomic_write + from autoskillit.execution import ( + REPORT_INDEX_SCHEMA_VERSION, + read_report_index, + report_index_dir, + resolve_log_dir, + ) + from autoskillit.report import render_deck + + log_root = resolve_log_dir(load_config().linux_tracing.log_dir) + index_dir = report_index_dir(log_root) + _refresh_report_index(log_root, index_dir, rebuild=False) + report = read_report_index(index_dir) + html = render_deck( + report.sessions.values(), + generated_at=datetime.now(UTC), + index_schema_version=REPORT_INDEX_SCHEMA_VERSION, + ) + atomic_write(Path(output), html) + print(f"deck: wrote {output} ({len(report.sessions)} session rows)") + + @sessions_app.command(name="analyze") def sessions_analyze( recipe: str = "", diff --git a/src/autoskillit/report/__init__.py b/src/autoskillit/report/__init__.py index 46a5fcc842..7b6101f9ff 100644 --- a/src/autoskillit/report/__init__.py +++ b/src/autoskillit/report/__init__.py @@ -1,3 +1,4 @@ +from .deck import render_deck from .renderer import HTML_TEMPLATE, VALIDATION_KEYWORDS, main -__all__ = ["HTML_TEMPLATE", "VALIDATION_KEYWORDS", "main"] +__all__ = ["HTML_TEMPLATE", "VALIDATION_KEYWORDS", "main", "render_deck"] diff --git a/src/autoskillit/report/deck/__init__.py b/src/autoskillit/report/deck/__init__.py index 12820d2cf8..18fc133a8a 100644 --- a/src/autoskillit/report/deck/__init__.py +++ b/src/autoskillit/report/deck/__init__.py @@ -1,3 +1,4 @@ +from ._html import render_deck from ._payload import build_deck_payload -__all__ = ["build_deck_payload"] +__all__ = ["build_deck_payload", "render_deck"] diff --git a/src/autoskillit/report/deck/_html.py b/src/autoskillit/report/deck/_html.py new file mode 100644 index 0000000000..14c57aa916 --- /dev/null +++ b/src/autoskillit/report/deck/_html.py @@ -0,0 +1,76 @@ +"""Assemble the deck's first-party assets and escaped index payload.""" + +import base64 +import hashlib +import json +from collections.abc import Iterable, Mapping +from datetime import datetime +from typing import Any + +import regex as re + +from autoskillit.core import pkg_root + +from ._payload import build_deck_payload +from ._registry import DECK_VIEWS + + +def _asset_text(rel: str) -> str: + return (pkg_root() / "assets" / "deck" / rel).read_text(encoding="utf-8") + + +def deck_script_assets() -> tuple[str, ...]: + return ( + "core.js", + "shell.js", + *(v.script for v in DECK_VIEWS if v.planned_issue is None and v.script is not None), + ) + + +def embed_json(payload: Mapping[str, Any]) -> str: + return ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + .replace("&", r"\u0026") + .replace("<", r"\u003c") + .replace(">", r"\u003e") + ) + + +def script_hash(source: str) -> str: + digest = hashlib.sha256(source.encode("utf-8")).digest() + return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'" + + +def render_deck_html(payload: Mapping[str, Any]) -> str: + scripts = [_asset_text(rel) for rel in deck_script_assets()] + css = _asset_text("deck.css") + if any("" + s + "" for s in scripts), + } + template = _asset_text("deck.html") + pattern = r"" + if sorted(re.findall(pattern, template)) != sorted(values): + raise ValueError("deck template must contain each insertion marker exactly once") + return re.sub(pattern, lambda m: values[m.group(1)], template) + + +def render_deck( + session_rows: Iterable[Mapping[str, Any]], *, generated_at: datetime, index_schema_version: int +) -> str: + return render_deck_html( + build_deck_payload( + session_rows, generated_at=generated_at, index_schema_version=index_schema_version + ) + ) diff --git a/tests/cli/test_sessions_deck_cmd.py b/tests/cli/test_sessions_deck_cmd.py new file mode 100644 index 0000000000..f1551cb256 --- /dev/null +++ b/tests/cli/test_sessions_deck_cmd.py @@ -0,0 +1,66 @@ +"""CLI checks for rendering the report index as a self-contained deck.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from autoskillit.core import ArtifactLease +from tests.cli._sessions_helpers import _configure_log_root, _seed_session + +pytestmark = [pytest.mark.layer("cli"), pytest.mark.medium] + + +def test_sessions_deck_refreshes_index_and_writes_session_data( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from autoskillit.cli.ops._sessions import sessions_deck + + log_root = tmp_path / "logs" + _seed_session(log_root) + _configure_log_root(monkeypatch, log_root) + out = tmp_path / "deck.html" + + sessions_deck(str(out)) + + assert capsys.readouterr().out == ( + f"report index: walked 2 items, wrote 1 rows\ndeck: wrote {out} (1 session rows)\n" + ) + html = out.read_text(encoding="utf-8") + match = re.search(r']*id="deck-data"[^>]*>(.*?)', html, re.DOTALL) + assert match is not None + data = json.loads(match.group(1)) + sessions = data["tables"]["sessions"] + assert len(sessions["rows"]) == 1 + assert sessions["rows"][0][sessions["columns"].index("harness")] == "claude-code" + + +def test_sessions_deck_reports_lease_contention_without_writing_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from autoskillit.cli.ops._sessions import sessions_deck + + log_root = tmp_path / "logs" + _seed_session(log_root) + _configure_log_root(monkeypatch, log_root) + index_dir = log_root / "report-index" + index_dir.mkdir() + out = tmp_path / "deck.html" + + with ArtifactLease.acquire_exclusive(index_dir / "index.lock", timeout=0.0): + with pytest.raises(SystemExit) as raised: + sessions_deck(str(out)) + + captured = capsys.readouterr() + assert raised.value.code == 1 + assert captured.err.strip() == ( + "report index: another operation holds a required index or source lease" + ) + assert not out.exists() diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index 35e738f4a2..ae07f41ff6 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -4,8 +4,10 @@ from typing import Any import pytest +from py_mini_racer import MiniRacer -from autoskillit.report.deck._registry import ChipState +from autoskillit.report.deck._html import deck_script_assets +from autoskillit.report.deck._registry import DECK_VIEWS, ChipState pytestmark = [pytest.mark.small] @@ -441,3 +443,14 @@ def test_core_js_has_no_browser_global_dependency(deck_js: Any) -> None: assert deck_js.eval("typeof window + typeof document + typeof location") == ( "undefinedundefinedundefined" ) + + +def test_shell_registers_built_views_without_document(deck_asset: Any) -> None: + with MiniRacer() as ctx: + assert ctx.eval("typeof document") == "undefined" + for rel in deck_script_assets(): + ctx.eval(deck_asset(rel)) + registered = ctx.call("DeckShell.registeredViews") + + expected = [view.view_id for view in DECK_VIEWS if view.planned_issue is None] + assert sorted(registered) == sorted(expected) diff --git a/tests/report/test_deck_html.py b/tests/report/test_deck_html.py new file mode 100644 index 0000000000..2c3dc4fe81 --- /dev/null +++ b/tests/report/test_deck_html.py @@ -0,0 +1,219 @@ +"""Contract tests for the observability deck's self-contained HTML document.""" + +import json +import re +from datetime import UTC, datetime +from html.parser import HTMLParser +from typing import Any + +import pytest + +from autoskillit.core import pkg_root +from autoskillit.report import render_deck +from autoskillit.report.deck import _html as deck_html +from autoskillit.report.deck import render_deck as render_deck_from_deck +from autoskillit.report.deck._html import render_deck_html, script_hash +from autoskillit.report.deck._payload import build_deck_payload +from autoskillit.report.deck._registry import DECK_VIEWS + +pytestmark = [pytest.mark.small] + +GEN = datetime(2026, 10, 4, tzinfo=UTC) + + +class _HTMLCollector(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self.elements: list[tuple[str, dict[str, str | None]]] = [] + self.scripts: list[tuple[dict[str, str | None], str]] = [] + self._script_attrs: dict[str, str | None] | None = None + self._script_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + self.elements.append((tag, attributes)) + if tag == "script": + self._script_attrs = attributes + self._script_parts = [] + + def handle_data(self, data: str) -> None: + if self._script_attrs is not None: + self._script_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "script" and self._script_attrs is not None: + self.scripts.append((self._script_attrs, "".join(self._script_parts))) + self._script_attrs = None + self._script_parts = [] + + +def _collect(html: str) -> _HTMLCollector: + collector = _HTMLCollector() + collector.feed(html) + collector.close() + return collector + + +def test_rendered_deck_has_no_network_dependency(deck_payload: dict[str, Any]) -> None: + html = render_deck_html(deck_payload) + collector = _collect(html) + + assert all("src" not in attrs for _, attrs in collector.elements) + assert not { + "link", + "img", + "iframe", + "object", + "embed", + "audio", + "video", + "source", + }.intersection(tag for tag, _ in collector.elements) + assert all( + attrs.get("href") is not None and attrs["href"].startswith("#") + for tag, attrs in collector.elements + if tag == "a" and "href" in attrs + ) + + for forbidden in ( + "@import", + "fetch(", + "XMLHttpRequest", + "WebSocket", + "EventSource", + "sendBeacon", + "import(", + "new Worker", + ): + assert forbidden not in html + assert all( + target.strip().startswith("#") for target in re.findall(r"url\(([^)]*)\)", html, re.I) + ) + assert re.findall(r"https?://[^\s\"'<>)]*", html) == ["http://www.w3.org/2000/svg"] + + +def test_csp_hashes_each_executable_script(deck_payload: dict[str, Any]) -> None: + collector = _collect(render_deck_html(deck_payload)) + policies = [ + attrs["content"] + for tag, attrs in collector.elements + if tag == "meta" and attrs.get("http-equiv", "").lower() == "content-security-policy" + ] + + assert len(policies) == 1 + policy = policies[0] + assert policy is not None and policy.startswith("default-src 'none';") + directives = { + directive.strip().split()[0]: directive.strip().split()[1:] + for directive in policy.split(";") + if directive.strip() + } + script_tokens = directives["script-src"] + expected = [script_hash(source) for attrs, source in collector.scripts if "type" not in attrs] + assert sorted(script_tokens) == sorted(expected) + assert "'unsafe-inline'" not in script_tokens + + +def test_embedded_json_escapes_markup_and_round_trips(deck_rows: list[dict[str, Any]]) -> None: + rows = [ + *deck_rows, + { + **deck_rows[0], + "key": "s5", + "skill": "", + }, + ] + payload = build_deck_payload(rows, generated_at=GEN, index_schema_version=1) + html = render_deck_html(payload) + collector = _collect(html) + data = next(source for attrs, source in collector.scripts if attrs.get("id") == "deck-data") + + assert json.loads(data) == payload + assert not any(tag == "img" for tag, _ in collector.elements) + assert all( + marker not in html + for marker in ( + "", + "", + "", + "", + ) + ) + + +def test_rendered_scripts_follow_core_shell_and_registry_order( + deck_asset: Any, deck_payload: dict[str, Any] +) -> None: + collector = _collect(render_deck_html(deck_payload)) + actual = [source for attrs, source in collector.scripts if "type" not in attrs] + view_assets = [ + deck_asset(view.script) + for view in DECK_VIEWS + if view.planned_issue is None and view.script is not None + ] + + assert actual == [deck_asset("core.js"), deck_asset("shell.js"), *view_assets] + + +@pytest.mark.parametrize("invalid_asset", ["script", "css", "missing_marker", "duplicate_marker"]) +def test_render_deck_html_rejects_unsafe_assets_and_template_markers( + invalid_asset: str, + monkeypatch: pytest.MonkeyPatch, + deck_asset: Any, + deck_payload: dict[str, Any], +) -> None: + if invalid_asset == "script": + target, replacement = "core.js", "const bad = ''" + elif invalid_asset == "css": + target, replacement = "deck.css", "x { color: red; }" + elif invalid_asset in {"missing_marker", "duplicate_marker"}: + target = "deck.html" + template = deck_asset(target) + if invalid_asset == "missing_marker": + replacement = template.replace("", "", 1) + else: + replacement = template.replace( + "", "", 1 + ) + else: + raise AssertionError(f"unhandled test case: {invalid_asset}") + + def asset_text(rel: str) -> str: + return replacement if rel == target else deck_asset(rel) + + monkeypatch.setattr(deck_html, "_asset_text", asset_text) + with pytest.raises(ValueError): + render_deck_html(deck_payload) + + +def test_deck_asset_directory_contains_only_registered_shell_assets() -> None: + assets = pkg_root() / "assets" / "deck" + actual = {path.relative_to(assets).as_posix() for path in assets.rglob("*") if path.is_file()} + view_assets = { + view.script + for view in DECK_VIEWS + if view.planned_issue is None and view.script is not None + } + + assert actual == {"deck.html", "deck.css", "core.js", "shell.js"} | view_assets + + +def test_embedded_cohort_chips_cover_all_states(deck_payload: dict[str, Any]) -> None: + collector = _collect(render_deck_html(deck_payload)) + data = next(source for attrs, source in collector.scripts if attrs.get("id") == "deck-data") + chips = json.loads(data)["chips"]["cohort"] + + assert {chip["state"] for facet in chips.values() for chip in facet} >= { + "live", + "struck", + "absent", + } + + +def test_render_deck_facades_match_html_wrapper( + deck_rows: list[dict[str, Any]], deck_payload: dict[str, Any] +) -> None: + expected = render_deck_html(deck_payload) + + assert render_deck(deck_rows, generated_at=GEN, index_schema_version=1) == expected + assert render_deck_from_deck(deck_rows, generated_at=GEN, index_schema_version=1) == expected From d68a11bad1f513ce33992a04b1e51f99785bc8f5 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 15:45:51 -0700 Subject: [PATCH 07/27] test(report): cascade report changes to CLI consumers --- tests/_test_filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index a87f6becfe..40394b5bdf 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -1553,7 +1553,7 @@ class SourceMapRejection(enum.StrEnum): ), # Standalone modules (not subpackage directories) # L1 - "report": frozenset({"report", "skills_extended"}), + "report": frozenset({"report", "skills_extended", "cli"}), "exploration": frozenset({"exploration", "server", "cli"}), "planner": frozenset( { From b91d944daa4514de8ca4b469624465790d08770c Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:17:59 -0700 Subject: [PATCH 08/27] fix(review): remove unused per-view strike machinery --- src/autoskillit/report/deck/_payload.py | 24 ++++--------- src/autoskillit/report/deck/_registry.py | 16 +-------- tests/report/test_deck_payload.py | 37 -------------------- tests/report/test_deck_registry.py | 43 +++++++----------------- 4 files changed, 20 insertions(+), 100 deletions(-) diff --git a/src/autoskillit/report/deck/_payload.py b/src/autoskillit/report/deck/_payload.py index 9165441926..8374f0ea00 100644 --- a/src/autoskillit/report/deck/_payload.py +++ b/src/autoskillit/report/deck/_payload.py @@ -58,12 +58,9 @@ def _value_chips( ) -> list[dict[str, Any]]: counts = Counter(row.get(facet.column) for row in rows) recorded = any(v is not None for v in counts) - strikes = {(s.facet_id, s.key): s.reason for s in view.strikes} chips = [] for value in facet.declared: - reason = ( - dict(value.unresolvable_in).get(view.table) if view.table is not None else None - ) or strikes.get((facet.facet_id, value.key)) + reason = dict(value.unresolvable_in).get(view.table) if view.table is not None else None count = counts.get(value.match, 0) if value.match is not None else 0 if reason: state, count = ChipState.STRUCK, 0 @@ -89,14 +86,13 @@ def _value_chips( key = facet.null_label else: key = observed - reason = strikes.get((facet.facet_id, key)) observed_chips.append( _chip( key, key, - ChipState.STRUCK if reason else ChipState.LIVE, - 0 if reason else count, - reason, + ChipState.LIVE, + count, + None, match=observed, ) ) @@ -104,20 +100,16 @@ def _value_chips( def _window_chips( - view: DeckViewDef, rows: Sequence[Mapping[str, Any]], *, generated_at_ms: int, history: Mapping[str, Any], ) -> list[dict[str, Any]]: - strikes = {s.key: s.reason for s in view.strikes if s.facet_id == WINDOW_FACET_ID} chips = [] for window in WINDOWS: - reason = strikes.get(window.key) + reason = None count = 0 - if reason: - state = ChipState.STRUCK - elif window.days is None: + if window.days is None: state, count = ChipState.LIVE, len(rows) elif history["first_ms"] is None: state = ChipState.ABSENT @@ -153,9 +145,7 @@ def resolve_view_chips( history: Mapping[str, Any], ) -> dict[str, list[dict[str, Any]]]: return {f.facet_id: _value_chips(view, f, rows) for f in FACETS} | { - WINDOW_FACET_ID: _window_chips( - view, rows, generated_at_ms=generated_at_ms, history=history - ) + WINDOW_FACET_ID: _window_chips(rows, generated_at_ms=generated_at_ms, history=history) } diff --git a/src/autoskillit/report/deck/_registry.py b/src/autoskillit/report/deck/_registry.py index 0d69449de4..cc857b9626 100644 --- a/src/autoskillit/report/deck/_registry.py +++ b/src/autoskillit/report/deck/_registry.py @@ -41,12 +41,6 @@ class WindowDef(NamedTuple): days: int | None -class StrikeDef(NamedTuple): - facet_id: str - key: str - reason: ReasonDef - - class DeckViewDef(NamedTuple): view_id: str question: str @@ -55,7 +49,6 @@ class DeckViewDef(NamedTuple): table: str | None script: str | None planned_issue: int | None - strikes: tuple[StrikeDef, ...] = () SESSION_TABLE = "sessions" @@ -238,26 +231,19 @@ class DeckViewDef(NamedTuple): def _validate_deck_views( views: tuple[DeckViewDef, ...], - facets: tuple[FacetDef, ...], landing: str, ) -> None: ids = [v.view_id for v in views] if len(ids) != len(set(ids)): raise ValueError("duplicate view_id") - facet_ids = {f.facet_id for f in facets} | {WINDOW_FACET_ID} for view in views: if view.planned_issue is None: if not view.table or not view.script: raise ValueError(f"built view {view.view_id} requires table and script") elif view.table is not None or view.script is not None: raise ValueError(f"planned view {view.view_id} must not have table or script") - for strike in view.strikes: - if strike.facet_id not in facet_ids: - raise ValueError(f"unknown strike facet {strike.facet_id}") - if strike.facet_id == WINDOW_FACET_ID and strike.key == "all": - raise ValueError("all history cannot be struck") if not any(v.view_id == landing and v.planned_issue is None for v in views): raise ValueError("landing must name a built view") -_validate_deck_views(DECK_VIEWS, FACETS, LANDING_VIEW) +_validate_deck_views(DECK_VIEWS, LANDING_VIEW) diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py index ce327620b2..006010c69f 100644 --- a/tests/report/test_deck_payload.py +++ b/tests/report/test_deck_payload.py @@ -10,7 +10,6 @@ from autoskillit.report.deck._payload import ( build_deck_payload, encode_table, - resolve_view_chips, ) from autoskillit.report.deck._registry import ( AVAILABILITY_VOCABULARY, @@ -18,9 +17,6 @@ LANDING_VIEW, SESSION_COLUMNS, SESSION_TABLE, - DeckViewDef, - ReasonDef, - StrikeDef, ) pytestmark = [pytest.mark.small] @@ -55,18 +51,6 @@ def _payload(rows: list[dict[str, Any]]) -> dict[str, Any]: return build_deck_payload(rows, generated_at=GEN, index_schema_version=7) -def _chips_for_view( - rows: list[dict[str, Any]], view: DeckViewDef -) -> dict[str, list[dict[str, Any]]]: - payload = _payload(rows) - return resolve_view_chips( - view, - rows, - generated_at_ms=payload["generated_at_ms"], - history=payload["history"], - ) - - def _chip(chips: dict[str, list[dict[str, Any]]], facet: str, key: str) -> dict[str, Any]: return next(chip for chip in chips[facet] if chip["key"] == key) @@ -140,27 +124,6 @@ def test_cohort_l0_chip_is_struck_with_its_registry_reason() -> None: assert l0["issue"] is None -def test_view_strike_marks_an_observed_chip_with_its_issue() -> None: - reason = ReasonDef("test reason", 9999) - view = DeckViewDef( - "test", - "question", - "decision", - "group", - SESSION_TABLE, - "views/test.js", - None, - (StrikeDef("harness", "codex", reason),), - ) - chips = _chips_for_view([_row("cc"), _row("cx", harness="codex")], view) - codex = _chip(chips, "harness", "codex") - - assert codex["state"] == "struck" - assert codex["count"] == 0 - assert codex["reason"] == "test reason" - assert codex["issue"] == 9999 - - def test_unrecorded_level_values_use_gap_reason_and_keep_null_bucket() -> None: rows = [_row("one"), _row("two")] chips = _payload(rows)["chips"]["cohort"] diff --git a/tests/report/test_deck_registry.py b/tests/report/test_deck_registry.py index 383d30840d..83549fa0ba 100644 --- a/tests/report/test_deck_registry.py +++ b/tests/report/test_deck_registry.py @@ -13,9 +13,6 @@ SESSION_TABLE, WINDOWS, DeckViewDef, - FacetDef, - ReasonDef, - StrikeDef, _validate_deck_views, ) @@ -27,7 +24,6 @@ def _built_view( view_id: str = "cohort", table: str | None = SESSION_TABLE, script: str | None = "views/cohort.js", - strikes: tuple[StrikeDef, ...] = (), ) -> DeckViewDef: return DeckViewDef( view_id, @@ -37,7 +33,6 @@ def _built_view( table, script, None, - strikes, ) @@ -58,46 +53,32 @@ def _planned_view( ) -def _invalid_registry_cases() -> list[ - tuple[str, tuple[DeckViewDef, ...], tuple[FacetDef, ...], str] -]: +def _invalid_registry_cases() -> list[tuple[str, tuple[DeckViewDef, ...], str]]: built = _built_view() return [ - ("duplicate view id", (built, built), FACETS, "cohort"), - ("built view missing script", (_built_view(script=None),), FACETS, "cohort"), - ("built view missing table", (_built_view(table=None),), FACETS, "cohort"), - ("planned view has script", (_planned_view(script="views/future.js"),), FACETS, "cohort"), - ("planned view has table", (_planned_view(table=SESSION_TABLE),), FACETS, "cohort"), - ( - "strike names unknown facet", - (_built_view(strikes=(StrikeDef("missing", "x", ReasonDef("reason")),)),), - FACETS, - "cohort", - ), - ( - "all history cannot be struck", - (_built_view(strikes=(StrikeDef("window", "all", ReasonDef("reason")),)),), - FACETS, - "cohort", - ), - ("landing is planned", (built, _planned_view(view_id="future")), FACETS, "future"), + ("duplicate view id", (built, built), "cohort"), + ("built view missing script", (_built_view(script=None),), "cohort"), + ("built view missing table", (_built_view(table=None),), "cohort"), + ("planned view has script", (_planned_view(script="views/future.js"),), "cohort"), + ("planned view has table", (_planned_view(table=SESSION_TABLE),), "cohort"), + ("landing is planned", (built, _planned_view(view_id="future")), "future"), ] @pytest.mark.parametrize( - ("views", "facets", "landing"), - [(views, facets, landing) for _, views, facets, landing in _invalid_registry_cases()], + ("views", "landing"), + [(views, landing) for _, views, landing in _invalid_registry_cases()], ids=[name for name, *_ in _invalid_registry_cases()], ) def test_validate_deck_views_rejects_invalid_registries( - views: tuple[DeckViewDef, ...], facets: tuple[FacetDef, ...], landing: str + views: tuple[DeckViewDef, ...], landing: str ) -> None: with pytest.raises(ValueError): - _validate_deck_views(views, facets, landing) + _validate_deck_views(views, landing) def test_shipped_deck_view_registry_is_valid() -> None: - _validate_deck_views(DECK_VIEWS, FACETS, LANDING_VIEW) + _validate_deck_views(DECK_VIEWS, LANDING_VIEW) def test_level_facet_tracks_session_type_values_and_l0_gap() -> None: From e5c4b3adc9a8c4f7eb260a5fd5048479b15b75b4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:18:46 -0700 Subject: [PATCH 09/27] fix(review): remove redundant shipped-registry validation test --- tests/report/test_deck_registry.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/report/test_deck_registry.py b/tests/report/test_deck_registry.py index 83549fa0ba..7c33a70e6a 100644 --- a/tests/report/test_deck_registry.py +++ b/tests/report/test_deck_registry.py @@ -6,9 +6,7 @@ from autoskillit.execution._report_index_rows import ReportSessionRow from autoskillit.report.deck._registry import ( AVAILABILITY_VOCABULARY, - DECK_VIEWS, FACETS, - LANDING_VIEW, SESSION_COLUMNS, SESSION_TABLE, WINDOWS, @@ -77,10 +75,6 @@ def test_validate_deck_views_rejects_invalid_registries( _validate_deck_views(views, landing) -def test_shipped_deck_view_registry_is_valid() -> None: - _validate_deck_views(DECK_VIEWS, LANDING_VIEW) - - def test_level_facet_tracks_session_type_values_and_l0_gap() -> None: level = next(facet for facet in FACETS if facet.facet_id == "level") values = {value.key: value for value in level.declared} From be62521a274dd43ed5ba678de06ec556ff5ab250 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:19:15 -0700 Subject: [PATCH 10/27] fix(review): identify each browser global in core dependency assertions --- tests/report/test_deck_core_js.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index ae07f41ff6..d08c5f9728 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -440,9 +440,9 @@ def test_formatters_and_chip_state_parity(deck_js: Any, deck_model: dict[str, An def test_core_js_has_no_browser_global_dependency(deck_js: Any) -> None: - assert deck_js.eval("typeof window + typeof document + typeof location") == ( - "undefinedundefinedundefined" - ) + assert deck_js.eval("typeof window") == "undefined" + assert deck_js.eval("typeof document") == "undefined" + assert deck_js.eval("typeof location") == "undefined" def test_shell_registers_built_views_without_document(deck_asset: Any) -> None: From b40fc916d2bf8b36efe0fb8fda7eff8afb1770f7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:20:24 -0700 Subject: [PATCH 11/27] fix(review): separate shell registration tests from core JavaScript tests --- tests/report/test_deck_core_js.py | 15 +-------------- tests/report/test_deck_shell_js.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 tests/report/test_deck_shell_js.py diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index d08c5f9728..d84c77659f 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -4,10 +4,8 @@ from typing import Any import pytest -from py_mini_racer import MiniRacer -from autoskillit.report.deck._html import deck_script_assets -from autoskillit.report.deck._registry import DECK_VIEWS, ChipState +from autoskillit.report.deck._registry import ChipState pytestmark = [pytest.mark.small] @@ -443,14 +441,3 @@ def test_core_js_has_no_browser_global_dependency(deck_js: Any) -> None: assert deck_js.eval("typeof window") == "undefined" assert deck_js.eval("typeof document") == "undefined" assert deck_js.eval("typeof location") == "undefined" - - -def test_shell_registers_built_views_without_document(deck_asset: Any) -> None: - with MiniRacer() as ctx: - assert ctx.eval("typeof document") == "undefined" - for rel in deck_script_assets(): - ctx.eval(deck_asset(rel)) - registered = ctx.call("DeckShell.registeredViews") - - expected = [view.view_id for view in DECK_VIEWS if view.planned_issue is None] - assert sorted(registered) == sorted(expected) diff --git a/tests/report/test_deck_shell_js.py b/tests/report/test_deck_shell_js.py new file mode 100644 index 0000000000..c625446f66 --- /dev/null +++ b/tests/report/test_deck_shell_js.py @@ -0,0 +1,22 @@ +"""Browser-free contract tests for the observability deck's shell.""" + +from typing import Any + +import pytest +from py_mini_racer import MiniRacer + +from autoskillit.report.deck._html import deck_script_assets +from autoskillit.report.deck._registry import DECK_VIEWS + +pytestmark = [pytest.mark.small] + + +def test_shell_registers_built_views_without_document(deck_asset: Any) -> None: + with MiniRacer() as ctx: + assert ctx.eval("typeof document") == "undefined" + for rel in deck_script_assets(): + ctx.eval(deck_asset(rel)) + registered = ctx.call("DeckShell.registeredViews") + + expected = [view.view_id for view in DECK_VIEWS if view.planned_issue is None] + assert sorted(registered) == sorted(expected) From 6c08422d71e119e6a29f82053b6c70cd9a8faa2b Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:21:15 -0700 Subject: [PATCH 12/27] fix(review): isolate deck filtering route scenarios --- tests/report/test_deck_core_js.py | 54 +++++++++++++------------------ 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index d84c77659f..816516accd 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -116,40 +116,32 @@ def test_window_selection_falls_back_to_all_for_absent_window( assert deck_js.call("DeckCore.windowSelection", windows, ["7d"])["chip"]["key"] == "7d" +@pytest.mark.parametrize( + ("params", "expected_keys", "untimed"), + [ + pytest.param({"level": ["unrecorded"]}, ["s1", "s2", "s3", "s4"], 0, id="unrecorded"), + pytest.param({"harness": ["codex"]}, ["s4"], 0, id="codex"), + pytest.param({"window": ["7d"]}, ["s1", "s2"], 1, id="last-week"), + pytest.param({"harness": ["codex"], "window": ["7d"]}, [], 1, id="untimed-codex"), + pytest.param({"window": ["28d"]}, ["s1", "s2", "s3", "s4"], 0, id="absent-window"), + ], +) def test_filter_rows_applies_facets_windows_and_untimed_count( - deck_js: Any, deck_model: dict[str, Any] + deck_js: Any, + deck_model: dict[str, Any], + params: dict[str, list[str]], + expected_keys: list[str], + untimed: int, ) -> None: - model = deck_model - rows = model["tables"]["sessions"] - chips = model["chips"]["cohort"] - - unrecorded = deck_js.call( - "DeckCore.filterRows", model, rows, chips, _route(level=["unrecorded"]) + result = deck_js.call( + "DeckCore.filterRows", + deck_model, + deck_model["tables"]["sessions"], + deck_model["chips"]["cohort"], + _route(**params), ) - assert sorted(row["key"] for row in unrecorded["rows"]) == ["s1", "s2", "s3", "s4"] - assert unrecorded["untimed"] == 0 - - codex = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(harness=["codex"])) - assert [row["key"] for row in codex["rows"]] == ["s4"] - assert codex["untimed"] == 0 - - last_week = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(window=["7d"])) - assert sorted(row["key"] for row in last_week["rows"]) == ["s1", "s2"] - assert last_week["untimed"] == 1 - - no_timed_codex = deck_js.call( - "DeckCore.filterRows", model, rows, chips, _route(harness=["codex"], window=["7d"]) - ) - assert no_timed_codex["rows"] == [] - assert no_timed_codex["untimed"] == 1 - - absent_window = deck_js.call("DeckCore.filterRows", model, rows, chips, _route(window=["28d"])) - assert sorted(row["key"] for row in absent_window["rows"]) == [ - "s1", - "s2", - "s3", - "s4", - ] + assert sorted(row["key"] for row in result["rows"]) == expected_keys + assert result["untimed"] == untimed def test_population_sentence_explains_the_effective_population( From 6dd6ad5d9d7f7919afce7bcd2190f59bcf540ed4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:23:22 -0700 Subject: [PATCH 13/27] fix(review): isolate population sentence routes and explanation notes --- tests/report/test_deck_core_js.py | 139 +++++++++++++++--------------- 1 file changed, 71 insertions(+), 68 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index 816516accd..fab6b11632 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -10,6 +10,10 @@ pytestmark = [pytest.mark.small] COHORT_KEYS = ["harness", "provider", "level", "window"] +ALL_POPULATION_DETAIL = ( + "harness claude-code + codex (all) · provider anthropic + codex (all) · " + "level unrecorded (all) · window all history (2026-09-25 → 2026-10-04)" +) def _route(**params: list[str]) -> dict[str, object]: @@ -144,77 +148,76 @@ def test_filter_rows_applies_facets_windows_and_untimed_count( assert result["untimed"] == untimed -def test_population_sentence_explains_the_effective_population( - deck_js: Any, deck_model: dict[str, Any] -) -> None: - model = deck_model - chips = model["chips"]["cohort"] - rows = model["tables"]["sessions"] - - all_route = _route() - all_result = deck_js.call("DeckCore.filterRows", model, rows, chips, all_route) - sentence = deck_js.call("DeckCore.populationSentence", model, chips, all_route, all_result) - assert sentence == { - "headline": "4 runs", - "detail": ( +@pytest.mark.parametrize( + ("route", "headline", "detail", "notes"), + [ + pytest.param(_route(), "4 runs", ALL_POPULATION_DETAIL, [], id="all"), + pytest.param( + _route(harness=["codex"]), + "1 run", + "harness codex · provider anthropic + codex (all) · " + "level unrecorded (all) · window all history (2026-09-25 → 2026-10-04)", + [], + id="codex", + ), + pytest.param( + _route(window=["7d"]), + "2 runs", "harness claude-code + codex (all) · provider anthropic + codex (all) · " - "level unrecorded (all) · window all history " - "(2026-09-25 → 2026-10-04)" + "level unrecorded (all) · window last 7 days (2026-09-27 → 2026-10-04)", + ["1 run without a timestamp falls outside every window"], + id="last-week", ), - "notes": [], - } - - codex_route = _route(harness=["codex"]) - codex_result = deck_js.call("DeckCore.filterRows", model, rows, chips, codex_route) - codex_sentence = deck_js.call( - "DeckCore.populationSentence", model, chips, codex_route, codex_result - ) - assert codex_sentence["headline"] == "1 run" - assert codex_sentence["detail"].startswith("harness codex · ") - - week_route = _route(window=["7d"]) - week_result = deck_js.call("DeckCore.filterRows", model, rows, chips, week_route) - week_sentence = deck_js.call( - "DeckCore.populationSentence", model, chips, week_route, week_result - ) - assert week_sentence["detail"].endswith("window last 7 days (2026-09-27 → 2026-10-04)") - assert "1 run without a timestamp falls outside every window" in week_sentence["notes"] - - struck_route = _route(level=["L0"]) - struck_result = deck_js.call("DeckCore.filterRows", model, rows, chips, struck_route) - struck_sentence = deck_js.call( - "DeckCore.populationSentence", model, chips, struck_route, struck_result - ) - assert ( - "L0 is not selectable on this view — L0 leaf agents write no session row; " - "they exist only as subagent transcripts" - ) in struck_sentence["notes"] - assert ( - "no selected level is selectable here — showing every selectable level" - in struck_sentence["notes"] - ) - - unknown_route = _route(harness=["nope"]) - unknown_result = deck_js.call("DeckCore.filterRows", model, rows, chips, unknown_route) - unknown_sentence = deck_js.call( - "DeckCore.populationSentence", model, chips, unknown_route, unknown_result - ) - assert "nope does not appear in this index" in unknown_sentence["notes"] - assert ( - "no selected harness is selectable here — showing every selectable harness" - in unknown_sentence["notes"] - ) - - old_window_route = _route(window=["28d"]) - old_window_result = deck_js.call("DeckCore.filterRows", model, rows, chips, old_window_route) - old_window_sentence = deck_js.call( - "DeckCore.populationSentence", model, chips, old_window_route, old_window_result + pytest.param( + _route(level=["L0"]), + "4 runs", + ALL_POPULATION_DETAIL, + [ + "L0 is not selectable on this view — L0 leaf agents write no session row; " + "they exist only as subagent transcripts", + "no selected level is selectable here — showing every selectable level", + ], + id="struck-level", + ), + pytest.param( + _route(harness=["nope"]), + "4 runs", + ALL_POPULATION_DETAIL, + [ + "nope does not appear in this index", + "no selected harness is selectable here — showing every selectable harness", + ], + id="unknown-harness", + ), + pytest.param( + _route(window=["28d"]), + "4 runs", + ALL_POPULATION_DETAIL, + [ + "28 days is not selectable on this view — index history begins 2026-09-25 — " + "9 days retained (#4621)" + ], + id="absent-window", + ), + ], +) +def test_population_sentence_explains_the_effective_population( + deck_js: Any, + deck_model: dict[str, Any], + route: dict[str, object], + headline: str, + detail: str, + notes: list[str], +) -> None: + chips = deck_model["chips"]["cohort"] + result = deck_js.call( + "DeckCore.filterRows", deck_model, deck_model["tables"]["sessions"], chips, route ) - assert ( - "28 days is not selectable on this view — index history begins 2026-09-25 — " - "9 days retained (#4621)" - ) in old_window_sentence["notes"] - assert old_window_sentence["detail"].endswith("window all history (2026-09-25 → 2026-10-04)") + assert deck_js.call("DeckCore.populationSentence", deck_model, chips, route, result) == { + "headline": headline, + "detail": detail, + "notes": notes, + } def test_chip_presentation_covers_live_struck_absent_and_issue_reasons( From 6f2a5e6477f38f2c56f75b750f8d7dda68239878 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:23:53 -0700 Subject: [PATCH 14/27] fix(review): remove unused session-specific token fixture branch --- tests/report/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/report/conftest.py b/tests/report/conftest.py index 862b60a17f..7a7bdd6c54 100644 --- a/tests/report/conftest.py +++ b/tests/report/conftest.py @@ -51,8 +51,6 @@ def _session_row( "cache_read_tokens": {"state": "unknown", "value": None}, } ) - if key == "s4": - row["cache_write_tokens"] = {"state": "unavailable", "value": None} return row From 1395d9fd590d69d1eded66a1da36dfab17f0a032 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:24:43 -0700 Subject: [PATCH 15/27] fix(review): include contended lease path in report-index errors --- src/autoskillit/cli/ops/_sessions.py | 4 ++-- tests/cli/test_sessions_deck_cmd.py | 3 ++- tests/cli/test_sessions_index_cmd.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/cli/ops/_sessions.py b/src/autoskillit/cli/ops/_sessions.py index 409a5609a0..ce3d17f0dd 100644 --- a/src/autoskillit/cli/ops/_sessions.py +++ b/src/autoskillit/cli/ops/_sessions.py @@ -19,9 +19,9 @@ def _refresh_report_index(log_root: Path, index_dir: Path, *, rebuild: bool) -> if rebuild else update_report_index(log_root, index_dir) ) - except ArtifactLeaseContention: + except ArtifactLeaseContention as exc: print( - "report index: another operation holds a required index or source lease", + f"report index: another operation holds a required index or source lease: {exc.path}", file=sys.stderr, ) raise SystemExit(1) from None diff --git a/tests/cli/test_sessions_deck_cmd.py b/tests/cli/test_sessions_deck_cmd.py index f1551cb256..174eeab213 100644 --- a/tests/cli/test_sessions_deck_cmd.py +++ b/tests/cli/test_sessions_deck_cmd.py @@ -61,6 +61,7 @@ def test_sessions_deck_reports_lease_contention_without_writing_output( captured = capsys.readouterr() assert raised.value.code == 1 assert captured.err.strip() == ( - "report index: another operation holds a required index or source lease" + f"report index: another operation holds a required index or source lease: " + f"{index_dir / 'index.lock'}" ) assert not out.exists() diff --git a/tests/cli/test_sessions_index_cmd.py b/tests/cli/test_sessions_index_cmd.py index e7dfb8e15f..fc0747b8dd 100644 --- a/tests/cli/test_sessions_index_cmd.py +++ b/tests/cli/test_sessions_index_cmd.py @@ -82,6 +82,7 @@ def test_sessions_index_reports_lease_contention_without_summary( captured = capsys.readouterr() assert raised.value.code == 1 assert captured.err.strip() == ( - "report index: another operation holds a required index or source lease" + f"report index: another operation holds a required index or source lease: " + f"{index_dir / 'index.lock'}" ) assert captured.out == "" From fac45687dbd099c93db6b34a3b8a58ad33b3295b Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 21:23:03 -0700 Subject: [PATCH 16/27] fix(review): label availability legend values as examples --- src/autoskillit/assets/deck/views/cohort.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/autoskillit/assets/deck/views/cohort.js b/src/autoskillit/assets/deck/views/cohort.js index a286a320a0..a77a05e661 100644 --- a/src/autoskillit/assets/deck/views/cohort.js +++ b/src/autoskillit/assets/deck/views/cohort.js @@ -59,6 +59,7 @@ DeckShell.registerView("cohort", ctx => { ]; const helpCard = ctx.el("section", {class: "card"}, [ ctx.el("h2", {}, "How to read this deck"), + ctx.el("p", {}, "Example availability values:"), ctx.el("dl", {}, availabilityRows), ...samples.map(sample => { const presentation = DeckCore.chipPresentation(sample.chip, sample.selected); From 56dc0166faad233e8bb5e0e0c7223ca9ef6c15c9 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:48:42 -0700 Subject: [PATCH 17/27] test(review): isolate deck sort parser cases --- tests/report/test_deck_core_js.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index fab6b11632..8baccca1d2 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -350,20 +350,23 @@ def test_sort_rows_orders_numbers_strings_nulls_and_stable_ties(deck_js: Any) -> "upper", ] - fallback = {"key": "runs", "dir": "asc"} - assert deck_js.call("DeckCore.parseSort", "runs:desc", ["runs"], fallback) == { - "key": "runs", - "dir": "desc", - } - assert deck_js.call("DeckCore.parseSort", "bogus", ["runs"], fallback) == fallback - assert ( - json.loads( - deck_js.eval( - 'JSON.stringify(DeckCore.parseSort(undefined, ["runs"], {key:"runs", dir:"asc"}))' - ) + +@pytest.mark.parametrize( + ("token", "direction"), + [ + pytest.param('"runs:desc"', "desc", id="valid"), + pytest.param('"bogus"', "asc", id="invalid"), + pytest.param("undefined", "asc", id="missing"), + ], +) +def test_parse_sort_uses_valid_tokens_or_falls_back( + deck_js: Any, token: str, direction: str +) -> None: + assert json.loads( + deck_js.eval( + f'JSON.stringify(DeckCore.parseSort({token}, ["runs"], {{key:"runs", dir:"asc"}}))' ) - == fallback - ) + ) == {"key": "runs", "dir": direction} def test_bar_layout_scales_values_and_handles_zero_and_null( From 62db7ebde2da4960997900931a5728696a642ccb Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:50:02 -0700 Subject: [PATCH 18/27] test(review): separate deck availability presentation cases --- tests/report/test_deck_core_js.py | 50 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index 8baccca1d2..b8f5a76e65 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -267,44 +267,38 @@ def test_chip_presentation_covers_live_struck_absent_and_issue_reasons( ) +@pytest.mark.parametrize( + ("state", "value", "text"), + [ + pytest.param("measured", 1234567, "1,234,567", id="measured"), + pytest.param("measured_zero", 0, "0", id="measured-zero"), + pytest.param("unavailable", None, None, id="unavailable"), + pytest.param("unknown", None, None, id="unknown"), + pytest.param("not_applicable", None, None, id="not-applicable"), + ], +) def test_availability_presentation_uses_the_payload_vocabulary( - deck_js: Any, deck_model: dict[str, Any] + deck_js: Any, deck_model: dict[str, Any], state: str, value: int | None, text: str | None ) -> None: vocabulary = deck_model["availability"] - measured = deck_js.call( - "DeckCore.availabilityPresentation", - {"state": "measured", "value": 1234567}, - vocabulary, - ) - assert measured == { - "text": "1,234,567", - "className": "av av--measured", - "title": "Reported by the producer", + entry = next(item for item in vocabulary if item["state"] == state) + assert deck_js.call( + "DeckCore.availabilityPresentation", {"state": state, "value": value}, vocabulary + ) == { + "text": entry["label"] if text is None else text, + "className": f"av av--{state}", + "title": entry["description"], } - assert ( - deck_js.call( - "DeckCore.availabilityPresentation", - {"state": "measured_zero", "value": 0}, - vocabulary, - )["text"] - == "0" - ) - for state in ("unavailable", "unknown", "not_applicable"): - entry = next(item for item in vocabulary if item["state"] == state) - assert deck_js.call( - "DeckCore.availabilityPresentation", {"state": state, "value": None}, vocabulary - ) == { - "text": entry["label"], - "className": f"av av--{state}", - "title": entry["description"], - } +def test_availability_presentation_rejects_unknown_states( + deck_js: Any, deck_model: dict[str, Any] +) -> None: with pytest.raises(Exception, match="unknown availability state: invalid"): deck_js.call( "DeckCore.availabilityPresentation", {"state": "invalid", "value": None}, - vocabulary, + deck_model["availability"], ) From c608dc9c20a8030073e737745967fad846a8586c Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:52:42 -0700 Subject: [PATCH 19/27] test(review): parameterize deck chip presentation cases --- tests/report/test_deck_core_js.py | 131 ++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 42 deletions(-) diff --git a/tests/report/test_deck_core_js.py b/tests/report/test_deck_core_js.py index b8f5a76e65..ac9f8c6b98 100644 --- a/tests/report/test_deck_core_js.py +++ b/tests/report/test_deck_core_js.py @@ -220,51 +220,98 @@ def test_population_sentence_explains_the_effective_population( } +@pytest.mark.parametrize( + ("facet", "key", "selected", "expected"), + [ + pytest.param( + "harness", + "codex", + True, + { + "text": "codex", + "className": "chip", + "disabled": False, + "pressed": True, + "reason": None, + }, + id="live", + ), + pytest.param( + "level", + "L0", + True, + { + "text": "L0", + "className": "chip chip--struck", + "disabled": True, + "pressed": False, + "reason": ( + "L0 leaf agents write no session row; they exist only as subagent transcripts" + ), + }, + id="struck", + ), + pytest.param( + "level", + "L1", + False, + { + "text": "L1 ✕", + "className": "chip chip--absent", + "disabled": True, + "pressed": False, + "reason": "no session row in this index records its orchestration level (#4622)", + }, + id="absent-level", + ), + pytest.param( + "window", + "28d", + False, + { + "text": "28 days ✕", + "className": "chip chip--absent", + "disabled": True, + "pressed": False, + "reason": "index history begins 2026-09-25 — 9 days retained (#4621)", + }, + id="absent-window", + ), + pytest.param( + None, + "future", + False, + { + "text": "future", + "className": "chip chip--struck", + "disabled": True, + "pressed": False, + "reason": "A custom strike (#9999)", + }, + id="custom-issue", + ), + ], +) def test_chip_presentation_covers_live_struck_absent_and_issue_reasons( - deck_js: Any, deck_model: dict[str, Any] + deck_js: Any, + deck_model: dict[str, Any], + facet: str | None, + key: str, + selected: bool, + expected: dict[str, Any], ) -> None: - cohort = deck_model["chips"]["cohort"] - live = next(chip for chip in cohort["harness"] if chip["key"] == "codex") - assert deck_js.call("DeckCore.chipPresentation", live, True) == { - "text": live["label"], - "className": "chip", - "disabled": False, - "pressed": True, - "reason": None, - } - - struck = next(chip for chip in cohort["level"] if chip["key"] == "L0") - struck_view = deck_js.call("DeckCore.chipPresentation", struck, True) - assert struck_view == { - "text": struck["label"], - "className": "chip chip--struck", - "disabled": True, - "pressed": False, - "reason": struck["reason"], - } - - absent_level = next(chip for chip in cohort["level"] if chip["key"] == "L1") - absent_view = deck_js.call("DeckCore.chipPresentation", absent_level, False) - assert absent_view["text"] == absent_level["label"] + " ✕" - assert absent_view["className"] == "chip chip--absent" - assert absent_view["disabled"] is True - assert absent_view["pressed"] is False - assert absent_view["reason"].endswith("(#4622)") - - absent_window = next(chip for chip in cohort["window"] if chip["key"] == "28d") - assert deck_js.call("DeckCore.chipPresentation", absent_window, False)["reason"].endswith( - "(#4621)" - ) - custom_strike = { - "key": "future", - "label": "future", - "state": ChipState.STRUCK.value, - "reason": "A custom strike", - "issue": 9999, - } - assert deck_js.call("DeckCore.chipPresentation", custom_strike, False)["reason"] == ( - "A custom strike (#9999)" + chip = ( + next(chip for chip in deck_model["chips"]["cohort"][facet] if chip["key"] == key) + if facet is not None + else { + "key": key, + "label": key, + "state": ChipState.STRUCK.value, + "reason": "A custom strike", + "issue": 9999, + } ) + assert deck_js.call("DeckCore.chipPresentation", chip, selected) == expected @pytest.mark.parametrize( From 8f6311443a09bb95e72ac0a53992ff62832046b9 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:53:23 -0700 Subject: [PATCH 20/27] test(review): use the public core path facade --- tests/report/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/report/conftest.py b/tests/report/conftest.py index 7a7bdd6c54..3e433a4c9f 100644 --- a/tests/report/conftest.py +++ b/tests/report/conftest.py @@ -6,7 +6,7 @@ import pytest from py_mini_racer import MiniRacer -from autoskillit.core.io.paths import pkg_root +from autoskillit.core import pkg_root from autoskillit.report.deck._payload import build_deck_payload from autoskillit.report.deck._registry import SESSION_COLUMNS From 11d3bfe4eefe17121c15e860f6f4defca2d2f617 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:54:08 -0700 Subject: [PATCH 21/27] test(review): import deck payload builder through its public facade --- tests/report/conftest.py | 2 +- tests/report/test_deck_html.py | 2 +- tests/report/test_deck_payload.py | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/report/conftest.py b/tests/report/conftest.py index 3e433a4c9f..8fe084a73d 100644 --- a/tests/report/conftest.py +++ b/tests/report/conftest.py @@ -7,7 +7,7 @@ from py_mini_racer import MiniRacer from autoskillit.core import pkg_root -from autoskillit.report.deck._payload import build_deck_payload +from autoskillit.report.deck import build_deck_payload from autoskillit.report.deck._registry import SESSION_COLUMNS DECK_GENERATED_AT = datetime(2026, 10, 4, tzinfo=UTC) diff --git a/tests/report/test_deck_html.py b/tests/report/test_deck_html.py index 2c3dc4fe81..a5c12eba81 100644 --- a/tests/report/test_deck_html.py +++ b/tests/report/test_deck_html.py @@ -11,9 +11,9 @@ from autoskillit.core import pkg_root from autoskillit.report import render_deck from autoskillit.report.deck import _html as deck_html +from autoskillit.report.deck import build_deck_payload from autoskillit.report.deck import render_deck as render_deck_from_deck from autoskillit.report.deck._html import render_deck_html, script_hash -from autoskillit.report.deck._payload import build_deck_payload from autoskillit.report.deck._registry import DECK_VIEWS pytestmark = [pytest.mark.small] diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py index 006010c69f..006a018402 100644 --- a/tests/report/test_deck_payload.py +++ b/tests/report/test_deck_payload.py @@ -7,10 +7,8 @@ import pytest from autoskillit.core import TokenMeasureState -from autoskillit.report.deck._payload import ( - build_deck_payload, - encode_table, -) +from autoskillit.report.deck import build_deck_payload +from autoskillit.report.deck._payload import encode_table from autoskillit.report.deck._registry import ( AVAILABILITY_VOCABULARY, DECK_VIEWS, From 0f53c60fba989df72b91f6d66050d894c95da719 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:55:33 -0700 Subject: [PATCH 22/27] test(review): share deterministic deck session rows --- tests/report/_fixtures.py | 25 ++++++++++++++++++++ tests/report/conftest.py | 38 ++++--------------------------- tests/report/test_deck_payload.py | 23 +------------------ 3 files changed, 31 insertions(+), 55 deletions(-) create mode 100644 tests/report/_fixtures.py diff --git a/tests/report/_fixtures.py b/tests/report/_fixtures.py new file mode 100644 index 0000000000..2e2f533128 --- /dev/null +++ b/tests/report/_fixtures.py @@ -0,0 +1,25 @@ +"""Deterministic session rows shared by deck tests.""" + +from typing import Any + +from autoskillit.report.deck._registry import SESSION_COLUMNS + + +def session_row(key: str, **fields: Any) -> dict[str, Any]: + row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) + row.update( + { + "schema_version": 1, + "kind": "session", + "key": key, + "session_id": key, + "harness": "claude-code", + "provider": "anthropic", + "input_tokens": {"state": "unknown", "value": None}, + "output_tokens": {"state": "unknown", "value": None}, + "cache_write_tokens": {"state": "unknown", "value": None}, + "cache_read_tokens": {"state": "unknown", "value": None}, + } + ) + row.update(fields) + return row diff --git a/tests/report/conftest.py b/tests/report/conftest.py index 8fe084a73d..845b6771b4 100644 --- a/tests/report/conftest.py +++ b/tests/report/conftest.py @@ -8,7 +8,7 @@ from autoskillit.core import pkg_root from autoskillit.report.deck import build_deck_payload -from autoskillit.report.deck._registry import SESSION_COLUMNS +from tests.report._fixtures import session_row DECK_GENERATED_AT = datetime(2026, 10, 4, tzinfo=UTC) @@ -26,59 +26,31 @@ def _time_ms(value: datetime) -> int: return (value - epoch) // timedelta(milliseconds=1) -def _session_row( - key: str, - *, - time_ms: int | None, - harness: str, - provider: str, - skill: str | None, -) -> dict[str, Any]: - row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) - row.update( - { - "schema_version": 1, - "kind": "session", - "key": key, - "session_id": key, - "time_ms": time_ms, - "harness": harness, - "provider": provider, - "skill": skill, - "input_tokens": {"state": "unknown", "value": None}, - "output_tokens": {"state": "unknown", "value": None}, - "cache_write_tokens": {"state": "unknown", "value": None}, - "cache_read_tokens": {"state": "unknown", "value": None}, - } - ) - return row - - @pytest.fixture def deck_rows() -> list[dict[str, Any]]: return [ - _session_row( + session_row( "s1", time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=1)), harness="claude-code", provider="anthropic", skill="a", ), - _session_row( + session_row( "s2", time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=2)), harness="claude-code", provider="anthropic", skill="a", ), - _session_row( + session_row( "s3", time_ms=_time_ms(DECK_GENERATED_AT - timedelta(days=9)), harness="claude-code", provider="anthropic", skill="b", ), - _session_row( + session_row( "s4", time_ms=None, harness="codex", diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py index 006a018402..84a317bf65 100644 --- a/tests/report/test_deck_payload.py +++ b/tests/report/test_deck_payload.py @@ -16,6 +16,7 @@ SESSION_COLUMNS, SESSION_TABLE, ) +from tests.report._fixtures import session_row as _row pytestmark = [pytest.mark.small] GEN = datetime(2026, 10, 4, tzinfo=UTC) @@ -23,28 +24,6 @@ GEN_MS = (GEN - EPOCH) // timedelta(milliseconds=1) -def _row(key: str, **fields: Any) -> dict[str, Any]: - row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) - row.update( - { - "schema_version": 1, - "kind": "session", - "key": key, - "session_id": key, - "harness": "claude-code", - "provider": "anthropic", - "level": None, - "time_ms": None, - "input_tokens": {"state": "unknown", "value": None}, - "output_tokens": {"state": "unknown", "value": None}, - "cache_write_tokens": {"state": "unknown", "value": None}, - "cache_read_tokens": {"state": "unknown", "value": None}, - } - ) - row.update(fields) - return row - - def _payload(rows: list[dict[str, Any]]) -> dict[str, Any]: return build_deck_payload(rows, generated_at=GEN, index_schema_version=7) From f943b90d7ff9f7af2bd739f1aa5c6dd1453061f8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:56:58 -0700 Subject: [PATCH 23/27] test(review): share the deck sample generation timestamp --- tests/report/_fixtures.py | 5 ++++- tests/report/conftest.py | 4 +--- tests/report/test_deck_html.py | 15 +++++++++------ tests/report/test_deck_payload.py | 6 +++--- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/report/_fixtures.py b/tests/report/_fixtures.py index 2e2f533128..8f4e304698 100644 --- a/tests/report/_fixtures.py +++ b/tests/report/_fixtures.py @@ -1,9 +1,12 @@ -"""Deterministic session rows shared by deck tests.""" +"""Deterministic sample data shared by deck tests.""" +from datetime import UTC, datetime from typing import Any from autoskillit.report.deck._registry import SESSION_COLUMNS +DECK_GENERATED_AT = datetime(2026, 10, 4, tzinfo=UTC) + def session_row(key: str, **fields: Any) -> dict[str, Any]: row: dict[str, Any] = dict.fromkeys(SESSION_COLUMNS) diff --git a/tests/report/conftest.py b/tests/report/conftest.py index 845b6771b4..d75c051e33 100644 --- a/tests/report/conftest.py +++ b/tests/report/conftest.py @@ -8,9 +8,7 @@ from autoskillit.core import pkg_root from autoskillit.report.deck import build_deck_payload -from tests.report._fixtures import session_row - -DECK_GENERATED_AT = datetime(2026, 10, 4, tzinfo=UTC) +from tests.report._fixtures import DECK_GENERATED_AT, session_row @pytest.fixture diff --git a/tests/report/test_deck_html.py b/tests/report/test_deck_html.py index a5c12eba81..0a011d1880 100644 --- a/tests/report/test_deck_html.py +++ b/tests/report/test_deck_html.py @@ -2,7 +2,6 @@ import json import re -from datetime import UTC, datetime from html.parser import HTMLParser from typing import Any @@ -15,11 +14,10 @@ from autoskillit.report.deck import render_deck as render_deck_from_deck from autoskillit.report.deck._html import render_deck_html, script_hash from autoskillit.report.deck._registry import DECK_VIEWS +from tests.report._fixtures import DECK_GENERATED_AT pytestmark = [pytest.mark.small] -GEN = datetime(2026, 10, 4, tzinfo=UTC) - class _HTMLCollector(HTMLParser): def __init__(self) -> None: @@ -123,7 +121,7 @@ def test_embedded_json_escapes_markup_and_round_trips(deck_rows: list[dict[str, "skill": "", }, ] - payload = build_deck_payload(rows, generated_at=GEN, index_schema_version=1) + payload = build_deck_payload(rows, generated_at=DECK_GENERATED_AT, index_schema_version=1) html = render_deck_html(payload) collector = _collect(html) data = next(source for attrs, source in collector.scripts if attrs.get("id") == "deck-data") @@ -215,5 +213,10 @@ def test_render_deck_facades_match_html_wrapper( ) -> None: expected = render_deck_html(deck_payload) - assert render_deck(deck_rows, generated_at=GEN, index_schema_version=1) == expected - assert render_deck_from_deck(deck_rows, generated_at=GEN, index_schema_version=1) == expected + assert ( + render_deck(deck_rows, generated_at=DECK_GENERATED_AT, index_schema_version=1) == expected + ) + assert ( + render_deck_from_deck(deck_rows, generated_at=DECK_GENERATED_AT, index_schema_version=1) + == expected + ) diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py index 84a317bf65..9b38773c41 100644 --- a/tests/report/test_deck_payload.py +++ b/tests/report/test_deck_payload.py @@ -16,16 +16,16 @@ SESSION_COLUMNS, SESSION_TABLE, ) +from tests.report._fixtures import DECK_GENERATED_AT from tests.report._fixtures import session_row as _row pytestmark = [pytest.mark.small] -GEN = datetime(2026, 10, 4, tzinfo=UTC) EPOCH = datetime(1970, 1, 1, tzinfo=UTC) -GEN_MS = (GEN - EPOCH) // timedelta(milliseconds=1) +GEN_MS = (DECK_GENERATED_AT - EPOCH) // timedelta(milliseconds=1) def _payload(rows: list[dict[str, Any]]) -> dict[str, Any]: - return build_deck_payload(rows, generated_at=GEN, index_schema_version=7) + return build_deck_payload(rows, generated_at=DECK_GENERATED_AT, index_schema_version=7) def _chip(chips: dict[str, list[dict[str, Any]]], facet: str, key: str) -> dict[str, Any]: From 486c1c54091db41176dda6294ac661ee966d2342 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:57:43 -0700 Subject: [PATCH 24/27] test(review): use an independent expected deck epoch timestamp --- tests/report/test_deck_payload.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/report/test_deck_payload.py b/tests/report/test_deck_payload.py index 9b38773c41..8ca06df34d 100644 --- a/tests/report/test_deck_payload.py +++ b/tests/report/test_deck_payload.py @@ -1,7 +1,7 @@ """Tests for encoding the report deck's index data contract.""" import json -from datetime import UTC, datetime, timedelta +from datetime import datetime from typing import Any import pytest @@ -20,8 +20,7 @@ from tests.report._fixtures import session_row as _row pytestmark = [pytest.mark.small] -EPOCH = datetime(1970, 1, 1, tzinfo=UTC) -GEN_MS = (DECK_GENERATED_AT - EPOCH) // timedelta(milliseconds=1) +GEN_MS = 1_791_072_000_000 def _payload(rows: list[dict[str, Any]]) -> dict[str, Any]: From 27d6411000997c167ce25ef4dd6cab4c2a636032 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:58:52 -0700 Subject: [PATCH 25/27] refactor(review): mark the deck CSP hash helper private --- src/autoskillit/report/deck/_html.py | 4 ++-- tests/report/test_deck_html.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/report/deck/_html.py b/src/autoskillit/report/deck/_html.py index 14c57aa916..bdcbbf2a68 100644 --- a/src/autoskillit/report/deck/_html.py +++ b/src/autoskillit/report/deck/_html.py @@ -36,7 +36,7 @@ def embed_json(payload: Mapping[str, Any]) -> str: ) -def script_hash(source: str) -> str: +def _script_hash(source: str) -> str: digest = hashlib.sha256(source.encode("utf-8")).digest() return "'sha256-" + base64.b64encode(digest).decode("ascii") + "'" @@ -50,7 +50,7 @@ def render_deck_html(payload: Mapping[str, Any]) -> str: raise ValueError("CSS asset contains a closing style tag") csp = ( "default-src 'none'; script-src " - + " ".join(script_hash(s) for s in scripts) + + " ".join(_script_hash(s) for s in scripts) + "; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'" ) values = { diff --git a/tests/report/test_deck_html.py b/tests/report/test_deck_html.py index 0a011d1880..ca60630f17 100644 --- a/tests/report/test_deck_html.py +++ b/tests/report/test_deck_html.py @@ -12,7 +12,7 @@ from autoskillit.report.deck import _html as deck_html from autoskillit.report.deck import build_deck_payload from autoskillit.report.deck import render_deck as render_deck_from_deck -from autoskillit.report.deck._html import render_deck_html, script_hash +from autoskillit.report.deck._html import _script_hash, render_deck_html from autoskillit.report.deck._registry import DECK_VIEWS from tests.report._fixtures import DECK_GENERATED_AT @@ -107,7 +107,7 @@ def test_csp_hashes_each_executable_script(deck_payload: dict[str, Any]) -> None if directive.strip() } script_tokens = directives["script-src"] - expected = [script_hash(source) for attrs, source in collector.scripts if "type" not in attrs] + expected = [_script_hash(source) for attrs, source in collector.scripts if "type" not in attrs] assert sorted(script_tokens) == sorted(expected) assert "'unsafe-inline'" not in script_tokens From 77e67124d33b263e0480a17ded520359db30e817 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 22:59:51 -0700 Subject: [PATCH 26/27] test(review): verify deck CSP hashes independently --- tests/report/test_deck_html.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/report/test_deck_html.py b/tests/report/test_deck_html.py index ca60630f17..a9f7f10ff1 100644 --- a/tests/report/test_deck_html.py +++ b/tests/report/test_deck_html.py @@ -1,5 +1,7 @@ """Contract tests for the observability deck's self-contained HTML document.""" +import base64 +import hashlib import json import re from html.parser import HTMLParser @@ -12,7 +14,7 @@ from autoskillit.report.deck import _html as deck_html from autoskillit.report.deck import build_deck_payload from autoskillit.report.deck import render_deck as render_deck_from_deck -from autoskillit.report.deck._html import _script_hash, render_deck_html +from autoskillit.report.deck._html import render_deck_html from autoskillit.report.deck._registry import DECK_VIEWS from tests.report._fixtures import DECK_GENERATED_AT @@ -107,7 +109,13 @@ def test_csp_hashes_each_executable_script(deck_payload: dict[str, Any]) -> None if directive.strip() } script_tokens = directives["script-src"] - expected = [_script_hash(source) for attrs, source in collector.scripts if "type" not in attrs] + expected = [ + "'sha256-" + + base64.b64encode(hashlib.sha256(source.encode("utf-8")).digest()).decode("ascii") + + "'" + for attrs, source in collector.scripts + if "type" not in attrs + ] assert sorted(script_tokens) == sorted(expected) assert "'unsafe-inline'" not in script_tokens From 88503e0c36f2e7107d068f8893726806bd88d918 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:00:41 -0700 Subject: [PATCH 27/27] refactor(review): share the cohort view identifier --- src/autoskillit/report/deck/_registry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/report/deck/_registry.py b/src/autoskillit/report/deck/_registry.py index cc857b9626..c057f6dbf8 100644 --- a/src/autoskillit/report/deck/_registry.py +++ b/src/autoskillit/report/deck/_registry.py @@ -133,10 +133,11 @@ class DeckViewDef(NamedTuple): TokenMeasureState.NOT_APPLICABLE: ("n/a", "The field does not apply here"), } ) -LANDING_VIEW = "cohort" +COHORT_VIEW = "cohort" +LANDING_VIEW = COHORT_VIEW DECK_VIEWS: tuple[DeckViewDef, ...] = ( DeckViewDef( - "cohort", + COHORT_VIEW, "Who is in this cohort?", "Whether the population on screen is the one you meant, " "before you read any number from it",