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
@@ -174,7 +174,41 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
quart.g.operation_log_resource_id = pipeline_uuid
json_data = await quart.request.json
# Capture "what was bound before" so the trace answers "what changed
# into what": without this the log could only say an extension was
# configured, never which binding was added or removed.
try:
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
except Exception:
previous = None
# The request uses ``bound_*`` keys while the stored preferences use
# the canonical names, so both sides are normalized before diffing.
requested = {
canonical: json_data[field]
for field, canonical in {
'bound_plugins': 'plugins',
'bound_mcp_servers': 'mcp_servers',
'bound_skills': 'skills',
'bound_mcp_resources': 'mcp_resources',
'enable_all_plugins': 'enable_all_plugins',
'enable_all_mcp_servers': 'enable_all_mcp_servers',
'enable_all_skills': 'enable_all_skills',
'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled',
}.items()
if field in json_data
}
if isinstance(previous, dict):
current = {
**normalize_extension_preferences(previous.get('extensions_preferences')),
**requested,
}
else:
current = requested
changes = settings_service.changed_fields(current, requested)
if changes:
quart.g.operation_log_changes = changes
try:
validate_extension_preferences(
{