feat(operation-trace): record which resource was touched and what changed

Granularity follow-up: a record used to say only "installed an extension".

- Add resolve_resource_identity(): derive the acted-on resource from trusted route
  params or the install-style payload (plugin_author/plugin_name), so every
  resource family names its target. Unknown keys are ignored and sensitive keys
  are skipped, keeping the audit trail bounded and secret-free.
- The route wrapper applies it to every traced request; the identity only fills
  in when a handler did not publish a more precise one.
- Publish field-level before/after diffs for plugin config, skill, knowledge
  base, MCP server, pipeline, provider and model updates, reusing
  changed_fields()/build_summary() so secret-looking keys stay redacted and a
  masked round-trip never shows up as a phantom change.
This commit is contained in:
TyperBody
2026-09-26 03:46:20 +08:00
parent 6981b6643f
commit 475420956c
8 changed files with 133 additions and 7 deletions
@@ -2,6 +2,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ... import group
@@ -67,13 +68,33 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
knowledge_base_uuid: str,
request_context: RequestContext,
) -> quart.Response:
# Name the knowledge base and, on update, record which settings moved
# instead of only "a knowledge base was changed".
quart.g.operation_log_resource_id = knowledge_base_uuid
if quart.request.method == 'PUT':
json_data = await quart.request.json
try:
previous = await self.ap.knowledge_service.get_knowledge_base(
request_context,
knowledge_base_uuid,
include_secret=True,
)
except Exception:
previous = None
await self.ap.knowledge_service.update_knowledge_base(
request_context,
knowledge_base_uuid,
json_data,
)
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('knowledge_base_update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success(data={'uuid': knowledge_base_uuid})
await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
return self.success({})
@@ -4,6 +4,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ....service.secrets import redact_secrets
from ... import group
from ......pipeline.extension_preferences import (
@@ -87,15 +88,30 @@ 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
if quart.request.method == 'PUT':
json_data = await quart.request.json
try:
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
except Exception:
previous = None
try:
await self.ap.pipeline_service.update_pipeline(
request_context,
pipeline_uuid,
await quart.request.json,
json_data,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
else:
await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
return self.success()
@@ -18,6 +18,7 @@ from .....core import taskmgr
from .....entity.persistence import plugin as persistence_plugin
from ...authz import Permission
from ...context import ExecutionContext, RequestContext
from ...service import settings as settings_service
from .. import group
from .....workspace.errors import WorkspaceNotFoundError
from .....plugin.github import validate_github_plugin_install_info
@@ -526,15 +527,33 @@ class PluginsRouterGroup(group.RouterGroup):
plugin_name,
plugin,
)
try:
incoming_config = await quart.request.json
except Exception:
incoming_config = None
try:
config = restore_plugin_secret_placeholders(
await quart.request.json,
incoming_config,
current_config,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
await self._require_authenticated_plugin_runtime_context(request_context)
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
# Record which plugin was reconfigured and which keys actually moved.
# The diff is computed over the *requested* payload (secrets restored
# from the stored config) so a masked ``***`` round-trip never shows
# up as a change; sensitive keys are redacted by ``changed_fields``.
config_changes = settings_service.changed_fields(
current_config if isinstance(current_config, dict) else {},
config if isinstance(config, dict) else {},
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('plugin_config')
quart.g.operation_log_resource_id = f'{author}/{plugin_name}'
quart.g.operation_log_changes = config_changes
if rule is not None and config_changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, config_changes)
return self.success(data={})
@self.route(
@@ -3,6 +3,7 @@ import quart
from langbot.pkg.provider.modelmgr import errors as provider_errors
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ... import group
from .query import resolve_include_secret
@@ -85,14 +86,28 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
previous = await self.ap.llm_model_service.get_llm_model(request_context, model_uuid)
except Exception:
previous = None
try:
await self.ap.llm_model_service.update_llm_model(
request_context,
model_uuid,
await quart.request.json,
json_data,
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success()
@self.route(
@@ -2,6 +2,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ....service import settings as settings_service
from ... import group
from .query import resolve_include_secret
@@ -155,10 +156,29 @@ class ModelProvidersRouterGroup(group.RouterGroup):
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
try:
previous = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=True,
)
except Exception:
previous = None
try:
await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
# Record which provider was reconfigured and which fields moved;
# credential-looking keys are redacted by ``changed_fields``.
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
json_data if isinstance(json_data, dict) else {},
ignore=('uuid', 'created_at', 'updated_at', 'llm_count', 'embedding_count', 'rerank_count'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('update')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success()
@self.route(
@@ -5,6 +5,7 @@ from urllib.parse import unquote
from ....authz import Permission
from ....context import RequestContext
from ....service import settings as settings_service
from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
from ... import group
@@ -62,6 +63,10 @@ class MCPRouterGroup(group.RouterGroup):
server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
if server_data is None:
return self.http_status(404, -1, 'Server not found')
# Name the MCP server in the trace, and for an update record which
# fields actually moved so the card answers "what changed" rather
# than only "the MCP config was touched".
quart.g.operation_log_resource_id = server_name
if quart.request.method == 'PUT':
data = await quart.request.json
try:
@@ -70,6 +75,15 @@ class MCPRouterGroup(group.RouterGroup):
return self.http_status(403, exc.code, str(exc))
except ValueError as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
server_data,
data if isinstance(data, dict) else {},
ignore=('uuid',),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('mcp_config')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
else:
await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
return self.success()
@@ -7,6 +7,7 @@ from langbot_plugin.box.errors import BoxError
from ...authz import Permission
from ...context import RequestContext
from ...service import settings as settings_service
from .. import group
@@ -72,14 +73,31 @@ class SkillsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
# The skill name is the resource identity for both verbs; without it
# the card could only say "a skill was updated".
quart.g.operation_log_resource_id = skill_name
if quart.request.method == 'PUT':
data = await quart.request.json
try:
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
except (ValueError, BoxError):
previous = None
try:
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
previous if isinstance(previous, dict) else {},
data if isinstance(data, dict) else {},
ignore=('files', 'package_root', 'created_at', 'updated_at'),
)
rule = settings_service.ACTION_RULES_BY_ACTION.get('skill_view')
quart.g.operation_log_changes = changes
if rule is not None and changes:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
return self.success(data={'skill': skill})
try:
await self.ap.skill_service.delete_skill(request_context, skill_name)
return self.success()
@@ -631,9 +631,12 @@ export default function OperationTracePanel({
<Badge variant="outline">L{record.level}</Badge>
{record.resource_type && (
<Badge variant="secondary">
{t(`operationTrace.resourceTypes.${record.resource_type}`, {
defaultValue: record.resource_type,
})}
{t(
`operationTrace.resourceTypes.${record.resource_type}`,
{
defaultValue: record.resource_type,
},
)}
</Badge>
)}
{record.tampered ? (