feat(operation-trace): lead with the change, fix pipeline extension tracing

The panel answered "who, when, what" poorly: the actual before → after diff
was hidden behind a click, the actor was a muted line, and a whole row went to
the route while duration and hash sat in a permanent right column. The data
already arrives with the page, so the panel now reads top-down in the order an
operator actually looks:

* who and when first, then the action and resource, then the diff spelled out
  inline (long values collapse to their size so a pipeline config does not
  render as two near-identical blobs), with route / method / status / duration
  / evidence hash moved into the expanded diagnostics line;
* the whole card is the expand toggle with a rotating chevron, which removes
  the ambiguity of a small text link whose expanded state looked identical;
* records are bucketed into today / yesterday / dated groups so a long history
  reads as a timeline;
* a "changes only" toggle (reusing the level filter) answers "who changed
  something" in one click -- in the live log 489 of 499 rows are pure views,
  so this is the difference between a haystack and the ten rows that matter.

Tracing fixes found while reading the live log:

* PUT /pipelines/<uuid>/extensions was classified as a plugin config change
  because the generic ('/extensions') rule ran first; it now maps to a
  pipeline-specific action;
* that handler recorded no diff at all, so "modified extension config" could
  never say what changed. It now captures the previous bindings and reports the
  before → after.

Cache: the integrity result is now keyed by (Workspace, listing filters) so
each view reuses its own verification, and a delete invalidates all views for
the Workspace. Fourteen tests cover the projection, the verifier, the cache
lifecycle and the failure modes.
This commit is contained in:
TyperBody
2026-09-28 01:03:02 +08:00
parent ed93e7f1ac
commit 9ecb469d13
12 changed files with 554 additions and 204 deletions
@@ -90,6 +90,12 @@ async def _tail_hash(engine) -> str | None:
return result.scalar_one_or_none()
def _cached_views(service) -> list:
"""Return the cache keys currently held for the test Workspace."""
return [key for key in service._integrity_cache if key[0] == WORKSPACE]
def _counting_verifier(service, monkeypatch):
"""Replace the per-row verifier with one that records the ids it hashes."""
@@ -128,7 +134,9 @@ async def test_cached_read_within_ttl_reuses_without_rehashing(trace_env, monkey
assert first['summary']['scanned'] == 5
assert first['summary']['tampered'] == 0
assert len(calls) == 5
assert set(service._integrity_cache[WORKSPACE]['verified']) == set(ids)
views = _cached_views(service)
assert len(views) == 1
assert set(service._integrity_cache[views[0]]['verified']) == set(ids)
# A warm read inside the TTL must not re-hash anything at all: this is the
# shield that makes a burst of panel opens, refreshes and page turns cheap.
@@ -227,10 +235,43 @@ async def test_age_prune_drops_the_stale_integrity_cache(trace_env):
# Warm the cache: its verified map now records the row retention will drop.
await service.query_logs(WORKSPACE)
assert WORKSPACE in service._integrity_cache
assert _cached_views(service)
await service.prune(WORKSPACE, retention_days=1)
# Deleting the oldest rows invalidated the cached prefix.
assert WORKSPACE not in service._integrity_cache
assert _cached_views(service) == []
assert 'expired' in (await service.prune(WORKSPACE, retention_days=1))
async def test_each_listing_view_is_cached_independently(trace_env, monkeypatch):
service, engine = trace_env
await _append(service, engine, 4)
# Two different listing filters must not collide: each gets its own entry,
# and a view that was never scanned must still be verified from scratch.
all_view = [MODEL.workspace_uuid == WORKSPACE]
mutation_view = [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1]
await service._integrity_summary(MODEL, all_view)
await service._integrity_summary(MODEL, mutation_view)
views = _cached_views(service)
assert len(views) == 2
assert len({key[1] for key in views}) == 2
async def test_invalidation_clears_every_cached_view(trace_env):
service, engine = trace_env
await _append(service, engine, 2)
old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
async with engine.begin() as connection:
await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE])
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1])
assert len(_cached_views(service)) == 2
await service.prune(WORKSPACE, retention_days=1)
# A deletion invalidates *all* views for the Workspace, not just one.
assert _cached_views(service) == []