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 ? (