mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +08:00
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:
@@ -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) == []
|
||||
|
||||
Reference in New Issue
Block a user