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(
{
+51 -25
View File
@@ -409,6 +409,13 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='mcp_server',
),
# --- Pipelines -------------------------------------------------------
ActionRule(
action='pipeline_extensions_update',
category='resource',
bucket='write',
resource_type='pipeline',
),
# --- Generic resource verbs -----------------------------------------
ActionRule(
action='export',
@@ -491,7 +498,11 @@ _ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = (
(('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'),
(('/plugins/', '/logs'), 'plugin_view', 'plugin_view'),
(('/plugins',), 'plugin_view', 'plugin_view'),
(('/extensions',), 'plugin_view', 'plugin_config'),
# The pipeline extension bindings (plugins / MCP servers / skills) live on
# ``/pipelines/<uuid>/extensions``, not on a plugin. Without this rule the
# generic fragment below would classify the change as a plugin config edit.
# The read keeps the generic ``view`` for backwards-compatible labelling.
(('/extensions',), 'view', 'pipeline_extensions_update'),
# --- Extension lifecycle: skills ------------------------------------
(('/skills/', '/install'), 'skill_view', 'skill_install'),
(('/skills',), 'skill_view', 'skill_view'),
@@ -977,6 +988,11 @@ class WorkspaceSettingsService:
self._policy_cache_ttl = 5.0
# Inserts since the last row-budget check, keyed by Workspace UUID.
self._insert_counters: dict[str, int] = {}
# Verification cache keyed by (Workspace UUID, listing filters). The
# verification result is shareable across whatever narrows the listing,
# so keying on the whole filter set (not just the Workspace) lets the
# panel's common views -- "everything" and "mutations only" -- both hit
# the cache instead of forcing a full rescan on every page turn.
# Single-writer queue. Trace recording must never run on the request
# path: when tracing is enabled the WebUI fires a burst of parallel
# requests (login alone touches a dozen endpoints) and doing several
@@ -1672,10 +1688,12 @@ class WorkspaceSettingsService:
if until is not None:
filters.append(model.created_at <= until)
# Verify the filtered history once per request. This replaces the
# previous per-page sum, which made the counters silently restart at
# zero on every page after the first.
summary = await self._integrity_summary(model, filters)
# Verify the filtered history once per request, keyed by the listing
# filters so a view (all / mutations only / a resource) can reuse an
# already computed result while still paging. ``integrity`` is not
# part of the key: it only narrows *which* records are returned, not
# what the verification finds, so its views share the result too.
summary = await self._integrity_summary(model, filters, resolved_integrity)
# ``total`` follows the active listing filter so the pagination badge
# and the pager stay consistent with what the operator asked to see.
@@ -1758,6 +1776,17 @@ class WorkspaceSettingsService:
'truncated': False,
}
def _invalidate_integrity_cache(self, workspace_uuid: str) -> None:
"""Drop every cached verification for one Workspace.
The cache is keyed by ``(Workspace, listing filters)`` so each view has
its own entry; a deletion invalidates all of them, not just the
unfiltered one.
"""
for key in [key for key in self._integrity_cache if key[0] == workspace_uuid]:
self._integrity_cache.pop(key, None)
@staticmethod
def _verified_lists(verified: dict[int, tuple[bool, bool]]) -> tuple[list[int], list[int], list[int]]:
"""Split a verification map into (tampered, hash-failed, chain-failed) ids."""
@@ -1790,17 +1819,20 @@ class WorkspaceSettingsService:
self,
model: typing.Any,
filters: list[typing.Any],
integrity_filter: str = INTEGRITY_FILTER_ALL,
) -> dict[str, typing.Any]:
"""Verify the filtered history and classify every failing record.
A result computed within :data:`INTEGRITY_CACHE_TTL_SECONDS` is served
from the per-Workspace cache, which is what keeps the panel responsive:
opening, refreshing and paging all land inside that window and pay
nothing. A cache miss re-verifies the whole scan window from scratch --
the scan projects only the hash columns and the verifier only computes
two booleans, so the cost is bounded and, crucially, an edit to a row
that was verified on a previous pass is still caught. The cache is
skipped for filtered queries, which cannot share a full-history scan.
from the cache, which is what keeps the panel responsive: opening,
refreshing and paging all land inside that window and pay nothing. The
cache key is ``(Workspace, listing filters)`` -- an integrity drill-down
reuses the very result it is drilling into, and both "everything" and
"mutations only" are cached independently -- so a cache miss is rare.
A miss re-verifies the *whole* window from scratch: the scan projects
only the hash columns and the verifier only computes two booleans, so
the cost is bounded, and an edit to a row verified on a previous pass is
still caught.
"""
workspace_uuid = None
@@ -1810,17 +1842,13 @@ class WorkspaceSettingsService:
except AttributeError:
continue
break
filter_key = tuple(str(condition) for condition in filters)
cacheable = len(filters) == 1 and workspace_uuid is not None
cache_key = (workspace_uuid, tuple(str(condition) for condition in filters))
cacheable = workspace_uuid is not None
now = time.monotonic()
if cacheable:
cached = self._integrity_cache.get(workspace_uuid)
if (
cached is not None
and cached['filter_key'] == filter_key
and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS
):
cached = self._integrity_cache.get(cache_key)
if cached is not None and now - cached['computed_at'] < INTEGRITY_CACHE_TTL_SECONDS:
return self._cached_integrity_result(cached)
started = time.monotonic()
@@ -1869,10 +1897,8 @@ class WorkspaceSettingsService:
'chain_failed_ids': chain_failed_ids,
}
if cacheable:
self._integrity_cache[workspace_uuid] = {
'filter_key': filter_key,
self._integrity_cache[cache_key] = {
'verified': new_verified,
'oldest_row': previous_row,
'computed_at': now,
'truncated': truncated,
}
@@ -2143,7 +2169,7 @@ class WorkspaceSettingsService:
return 0
await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model).where(model.id.in_(list(oldest_ids))))
# Cached verification no longer matches the surviving prefix.
self._integrity_cache.pop(workspace_uuid, None)
self._invalidate_integrity_cache(workspace_uuid)
return len(oldest_ids)
except Exception as exc: # pragma: no cover - defensive
self.ap.logger.debug(f'Operation log trim skipped: {exc}')
@@ -2186,7 +2212,7 @@ class WorkspaceSettingsService:
# Age-based retention removed the oldest rows, so any cached
# verification -- including its boundary baseline link -- no longer
# describes the surviving chain.
self._integrity_cache.pop(workspace_uuid, None)
self._invalidate_integrity_cache(workspace_uuid)
total = await self.count_logs(workspace_uuid)
trimmed = await self._delete_oldest(workspace_uuid, max(total - budget, 0))