diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
index 87dc2ac30..a8fd38d99 100644
--- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
+++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
@@ -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({})
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
index 6b8d0f61d..3230dad32 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
@@ -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()
diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py
index c7ed22efc..8e443733d 100644
--- a/src/langbot/pkg/api/http/controller/groups/plugins.py
+++ b/src/langbot/pkg/api/http/controller/groups/plugins.py
@@ -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(
diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py
index 7ad970a2e..64a21f8df 100644
--- a/src/langbot/pkg/api/http/controller/groups/provider/models.py
+++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py
@@ -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(
diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py
index 107cb9b7f..9faea27cd 100644
--- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py
+++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py
@@ -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(
diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
index 9633fa58c..bb602c703 100644
--- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
+++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
@@ -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()
diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py
index 59c091917..684e5fd13 100644
--- a/src/langbot/pkg/api/http/controller/groups/skills.py
+++ b/src/langbot/pkg/api/http/controller/groups/skills.py
@@ -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()
diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
index 429affa67..68d92b406 100644
--- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
+++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx
@@ -631,9 +631,12 @@ export default function OperationTracePanel({
L{record.level}
{record.resource_type && (
- {t(`operationTrace.resourceTypes.${record.resource_type}`, {
- defaultValue: record.resource_type,
- })}
+ {t(
+ `operationTrace.resourceTypes.${record.resource_type}`,
+ {
+ defaultValue: record.resource_type,
+ },
+ )}
)}
{record.tampered ? (