From 9964f284e11c23f84e34598c4b6aab3836ea26d6 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Sat, 26 Sep 2026 23:35:58 +0800 Subject: [PATCH] refactor(operation-trace): isolate subsystem, fix install identity, merge migrations - Move the operation-traceability service and its HTTP routes into a standalone pkg/operation_trace package; the Core controller package no longer depends on it (routes stay auto-discovered by the controller package scan). - Gate the hot path on one module-level boolean exposed as ap.operation_trace_active: while no Workspace has tracing enabled the route wrapper performs zero trace work and zero database round trips. The gate opens when a Workspace enables tracing and is primed once at startup for already-enabled Workspaces. - Record the installed extension identity for GitHub / marketplace / local / upload installs (owner/repo, author/name, filename) so the trace no longer shows a nameless "a plugin was installed" event and the card names the extension. - Merge migrations 0032 + 0033 into a single 0032_operation_traceability revision carrying the full table shape and an index set that matches the ORM. Tests: unit 5165 passed / integration 358 passed; ruff check + format clean. --- src/langbot/pkg/api/http/controller/group.py | 16 ++- .../http/controller/groups/knowledge/base.py | 2 +- .../controller/groups/pipelines/pipelines.py | 2 +- .../pkg/api/http/controller/groups/plugins.py | 11 ++- .../http/controller/groups/provider/models.py | 2 +- .../controller/groups/provider/providers.py | 2 +- .../http/controller/groups/resources/mcp.py | 2 +- .../pkg/api/http/controller/groups/skills.py | 8 +- .../api/http/controller/groups/workspaces.py | 2 +- src/langbot/pkg/api/http/controller/main.py | 6 ++ src/langbot/pkg/core/app.py | 2 +- src/langbot/pkg/core/stages/build_app.py | 10 +- .../pkg/entity/persistence/operation_log.py | 2 +- src/langbot/pkg/operation_trace/__init__.py | 26 +++++ .../settings.py => operation_trace/routes.py} | 8 +- .../service.py} | 98 +++++++++++++++++-- ...logs.py => 0032_operation_traceability.py} | 66 +++++++------ .../versions/0033_operation_log_integrity.py | 70 ------------- .../OperationTracePanel.tsx | 12 +++ 19 files changed, 216 insertions(+), 131 deletions(-) create mode 100644 src/langbot/pkg/operation_trace/__init__.py rename src/langbot/pkg/{api/http/controller/groups/settings.py => operation_trace/routes.py} (98%) rename src/langbot/pkg/{api/http/service/settings.py => operation_trace/service.py} (95%) rename src/langbot/pkg/persistence/alembic/versions/{0032_workspace_operation_logs.py => 0032_operation_traceability.py} (67%) delete mode 100644 src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 19556bb21..181dbe806 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -363,6 +363,13 @@ class RouterGroup(abc.ABC): database, so a disabled Workspace pays nothing at steady state. """ + # Cheap global gate: a single attribute read on the application object. + # While no Workspace has enabled traceability this is ``False`` and the + # isolated subsystem stays entirely off the hot request path, with no + # import, service lookup, JSON parse or database round trip. + if not getattr(self.ap, 'operation_trace_active', False): + return + service = getattr(self.ap, 'workspace_settings_service', None) if service is None: return @@ -394,16 +401,15 @@ class RouterGroup(abc.ABC): # touching each handler. The body is read defensively: an audit path # must never fail (or block) because a request carries no JSON. try: - # Imported lazily so the base controller never participates in an - # import cycle with the service layer at module load time. - from ..service import settings as settings_service - + # Identity resolution lives in the isolated traceability service + # and is reached through the handle, so the Core controller never + # imports that subsystem. request_body: dict[str, typing.Any] | None = None if str(quart.request.method or '').upper() not in ('GET', 'HEAD', 'OPTIONS'): raw_body = await quart.request.get_json(silent=True) if isinstance(raw_body, dict): request_body = raw_body - identity = settings_service.resolve_resource_identity(kwargs, request_body) + identity = service.resolve_resource_identity(kwargs, request_body) if identity: resource_id = identity except Exception: # pragma: no cover - identity resolution is best effort 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 a8fd38d99..d8970d5ad 100644 --- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py +++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py @@ -2,7 +2,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext -from ....service import settings as settings_service +from ......operation_trace import service as settings_service from ... import group 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 3230dad32..79a839bc3 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -4,7 +4,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext -from ....service import settings as settings_service +from ......operation_trace import service as settings_service from ....service.secrets import redact_secrets from ... import group from ......pipeline.extension_preferences import ( diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 8e443733d..4bce79869 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -18,7 +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 .....operation_trace import service as settings_service from .. import group from .....workspace.errors import WorkspaceNotFoundError from .....plugin.github import validate_github_plugin_install_info @@ -872,6 +872,11 @@ class PluginsRouterGroup(group.RouterGroup): owner = install_info['owner'] repo = install_info['repo'] release_tag = install_info['release_tag'] + # Name the installed extension in the trace. GitHub installs carry + # owner/repo in the body, which the audit identity resolver can also + # read, but publishing it here keeps the trace correct regardless of + # how the request was encoded. + quart.g.operation_log_resource_id = f'{owner}/{repo}' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) @@ -912,6 +917,7 @@ class PluginsRouterGroup(group.RouterGroup): plugin_author = data.get('plugin_author', '') plugin_name = data.get('plugin_name', '') + quart.g.operation_log_resource_id = f'{plugin_author}/{plugin_name}' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) ctx = taskmgr.TaskContext.new() @@ -953,6 +959,9 @@ class PluginsRouterGroup(group.RouterGroup): file_bytes = file.read() form = await quart.request.form administrator_force = form.get('administrator_force', '').strip().lower() == 'true' + # A local upload is multipart with no JSON body, so the archive + # filename is the only identity available for the trace. + quart.g.operation_log_resource_id = file.filename or 'local plugin' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) data = { 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 64a21f8df..32a15e947 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -3,7 +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 ......operation_trace import service as settings_service from ... import group from .query import resolve_include_secret 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 9faea27cd..0a7302b58 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -2,7 +2,7 @@ import quart from ....authz import Permission, has_permission from ....context import RequestContext -from ....service import settings as settings_service +from ......operation_trace import service as settings_service from ... import group from .query import resolve_include_secret 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 bb602c703..56192506a 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -5,7 +5,7 @@ from urllib.parse import unquote from ....authz import Permission from ....context import RequestContext -from ....service import settings as settings_service +from ......operation_trace import service as settings_service from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError from ... import group diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 684e5fd13..6e6be90c6 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -7,7 +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 .....operation_trace import service as settings_service from .. import group @@ -185,6 +185,9 @@ class SkillsRouterGroup(group.RouterGroup): if not asset_url.endswith('skill.md') and not data.get('release_tag'): return self.http_status(400, -1, 'Missing required field: release_tag') + # Name the installed skill in the trace (owner/repo from the body). + quart.g.operation_log_resource_id = f'{data.get("owner", "")}/{data.get("repo", "")}' + try: skill = await self.ap.skill_service.install_from_github(request_context, data) return self.success(data={'skills': skill}) @@ -228,6 +231,9 @@ class SkillsRouterGroup(group.RouterGroup): if file is None: return self.http_status(400, -1, 'file is required') form = await quart.request.form + # A zip upload is multipart with no JSON body, so the filename is + # the only identity available for the trace. + quart.g.operation_log_resource_id = file.filename or 'skill' try: skill = await self.ap.skill_service.install_from_zip_upload( diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index b8b6d140f..7873e9f17 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -12,7 +12,7 @@ from .....entity.persistence.workspace import WorkspaceSource from .....workspace.collaboration import WorkspaceMemberView from .....workspace.errors import WorkspaceNotFoundError from .....workspace.invitation_delivery import InvitationDeliveryService -from ...service import settings as settings_service +from .....operation_trace import service as settings_service from .. import group diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py index 4cb11bdb4..b4718443f 100644 --- a/src/langbot/pkg/api/http/controller/main.py +++ b/src/langbot/pkg/api/http/controller/main.py @@ -20,6 +20,12 @@ from .groups import knowledge as groups_knowledge from .groups import resources as groups_resources from ...mcp.mount import MCPMount +# The traceability subsystem is self-contained and auto-registers its routes +# through ``group.group_class``. Importing it here (instead of from within the +# ``groups`` package) keeps the feature decoupled: the Core controller package +# contains no code that depends on it, only this single bootstrap import. +from ....operation_trace import routes as _operation_trace_routes # noqa: F401 + importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_platform) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 77c0c2839..ea21f3196 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -36,7 +36,7 @@ from ..api.http.service import mcp as mcp_service from ..api.http.service import apikey as apikey_service from ..api.http.service import webhook as webhook_service from ..api.http.service import monitoring as monitoring_service -from ..api.http.service import settings as workspace_settings_service +from ..operation_trace import service as workspace_settings_service from ..api.http.service import skill as skill_service from ..api.http.service import maintenance as maintenance_service from ..discover import engine as discover_engine diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index bcc388cbf..579f1049a 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -29,7 +29,7 @@ from ...api.http.service import mcp as mcp_service from ...api.http.service import apikey as apikey_service from ...api.http.service import webhook as webhook_service from ...api.http.service import monitoring as monitoring_service -from ...api.http.service import settings as settings_service +from ...operation_trace import service as operation_trace_service from ...api.http.service import skill as skill_service from ...skill import manager as skill_mgr from ...api.http.service import maintenance as maintenance_service @@ -128,7 +128,7 @@ class BuildAppStage(stage.BootingStage): mcp_service_inst = mcp_service.MCPService(ap) ap.mcp_service = mcp_service_inst - workspace_settings_service_inst = settings_service.WorkspaceSettingsService(ap) + workspace_settings_service_inst = operation_trace_service.WorkspaceSettingsService(ap) ap.workspace_settings_service = workspace_settings_service_inst apikey_service_inst = apikey_service.ApiKeyService(ap) @@ -162,6 +162,12 @@ class BuildAppStage(stage.BootingStage): ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() + # Persistence is ready now. Open the cheap global traceability gate if + # any Workspace already has tracing enabled, so a restart resumes + # recording without ever checking the database on the request path. + if ap.workspace_settings_service is not None: + await ap.workspace_settings_service.prime_global_flag() + if deployment.multi_workspace_enabled: directory_projection_service = DirectoryProjectionService( ap, diff --git a/src/langbot/pkg/entity/persistence/operation_log.py b/src/langbot/pkg/entity/persistence/operation_log.py index 9ed005a45..f5305ac83 100644 --- a/src/langbot/pkg/entity/persistence/operation_log.py +++ b/src/langbot/pkg/entity/persistence/operation_log.py @@ -129,7 +129,7 @@ class WorkspaceOperationLog(Base): Linking rows this way makes silent edits or deletions detectable. """ - dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True) + dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) """Hash used to collapse repeated read observations into one row.""" # --- Client fingerprint --------------------------------------------- diff --git a/src/langbot/pkg/operation_trace/__init__.py b/src/langbot/pkg/operation_trace/__init__.py new file mode 100644 index 000000000..018796ed2 --- /dev/null +++ b/src/langbot/pkg/operation_trace/__init__.py @@ -0,0 +1,26 @@ +"""Isolated operation-traceability subsystem. + +The "成员操作日志溯源" (member operation traceability) feature lives entirely in +this package so it can be decoupled from the hot request path: + +* :mod:`service` owns the capture levels, action catalog, append-only writer, + tamper-evidence chain, dedupe window and read/prune APIs. +* :mod:`routes` exposes the governance / operation-log HTTP surface and is + auto-discovered by the HTTP controller's package scan, so nothing in the + Core controller package needs to import it. + +How the decoupling keeps tracing off the hot path +------------------------------------------------- +Tracing is opt-in per Workspace. The Core route wrapper asks +:func:`operation_trace.service.is_globally_enabled` (a single module-level +boolean, no I/O) before it queues any trace work. While no Workspace has ever +turned tracing on, that flag is ``False`` and the wrapper performs zero +database round trips. The first time an operator enables tracing the service +flips the flag, and the per-Workspace level check then decides whether a given +request is recorded. Recording itself always runs *after* the handler released +its tenant scope, so auditing never extends a business transaction. +""" + +from __future__ import annotations + +__all__ = ['service', 'routes'] diff --git a/src/langbot/pkg/api/http/controller/groups/settings.py b/src/langbot/pkg/operation_trace/routes.py similarity index 98% rename from src/langbot/pkg/api/http/controller/groups/settings.py rename to src/langbot/pkg/operation_trace/routes.py index 515d042e6..e408ae6eb 100644 --- a/src/langbot/pkg/api/http/controller/groups/settings.py +++ b/src/langbot/pkg/operation_trace/routes.py @@ -25,10 +25,10 @@ import typing import quart -from ...authz import Permission -from ...context import RequestContext -from ...service import settings as settings_service -from .. import group +from ..api.http.authz import Permission +from ..api.http.context import RequestContext +from ..api.http.controller import group +from . import service as settings_service def _parse_timestamp(value: str | None) -> datetime.datetime | None: diff --git a/src/langbot/pkg/api/http/service/settings.py b/src/langbot/pkg/operation_trace/service.py similarity index 95% rename from src/langbot/pkg/api/http/service/settings.py rename to src/langbot/pkg/operation_trace/service.py index 1876f62e8..709425cbd 100644 --- a/src/langbot/pkg/api/http/service/settings.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -34,21 +34,46 @@ import typing import sqlalchemy -from ....core import app -from ....entity.persistence import metadata as persistence_metadata -from ....entity.persistence import operation_log as persistence_operation_log -from ....entity.persistence.operation_log import ( +from ..api.http.authz import WorkspaceRole +from ..api.http.context import PrincipalType, RequestContext +from ..core import app +from ..entity.persistence import metadata as persistence_metadata +from ..entity.persistence import operation_log as persistence_operation_log +from ..entity.persistence.operation_log import ( OPERATION_LEVEL_MUTATION, OPERATION_LEVEL_NONE, OPERATION_LEVEL_READ, ) -from ....utils import constants -from ..authz import WorkspaceRole -from ..context import PrincipalType, RequestContext +from ..utils import constants logger = logging.getLogger(__name__) +#: Cheap process-wide gate for the Core route wrapper. It is a module-level +#: boolean, so checking it costs a load and a branch with no import, service +#: lookup, JSON parse or database round trip. It stays ``False`` until some +#: Workspace actually opts into tracing, which keeps the isolated subsystem +#: entirely off the hot path for the default (tracing disabled) instance. +_global_enabled = False + + +def is_globally_enabled() -> bool: + """Return whether any Workspace may currently produce traces. + + The Core route wrapper reads this before doing any trace work. ``False`` + means the whole subsystem is inert and costs the request path nothing. + """ + + return _global_enabled + + +def enable_globally() -> None: + """Open the cheap gate. Idempotent; called when a Workspace turns tracing on.""" + + global _global_enabled + _global_enabled = True + + # --------------------------------------------------------------------------- # Storage keys (WorkspaceMetadata.value is a short string column) # --------------------------------------------------------------------------- @@ -595,6 +620,11 @@ def changed_fields( _IDENTITY_KEY_GROUPS: typing.Final[tuple[tuple[str, ...], ...]] = ( ('plugin_author', 'plugin_name'), ('author', 'plugin_name'), + ('plugin_author', 'name'), + # GitHub installs (plugins and skills) name the target as owner + repo in + # the request body rather than in the URL, so without this group a + # GitHub install would be traced as "a plugin was installed" with no name. + ('owner', 'repo'), ('author', 'name'), ('skill_uuid',), ('knowledge_base_uuid',), @@ -814,6 +844,9 @@ class WorkspaceSettingsService: def __init__(self, ap: app.Application) -> None: self.ap = ap + # Publish the cheap gate on the app so the Core route wrapper can read + # it as a plain attribute without importing this package. + ap.operation_trace_active = is_globally_enabled() # Level cache keyed by Workspace UUID: {"level": int, "expires": float} self._level_cache: dict[str, tuple[int, float]] = {} self._level_cache_ttl = 5.0 @@ -850,6 +883,34 @@ class WorkspaceSettingsService: self._level_cache.pop(workspace_uuid, None) self._policy_cache.pop(workspace_uuid, None) + def _activate(self) -> None: + """Open the cheap global gate once any Workspace enables tracing.""" + + enable_globally() + self.ap.operation_trace_active = True + + async def prime_global_flag(self) -> None: + """Open the cheap global gate at startup if a Workspace already opted in. + + Runs one bounded query during application build, never per request, so a + previously-enabled Workspace keeps recording after a restart while a + disabled instance still pays nothing on the hot path. + """ + + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_metadata.WorkspaceMetadata.workspace_uuid) + .where( + persistence_metadata.WorkspaceMetadata.key == OPERATION_LEVEL_KEY, + persistence_metadata.WorkspaceMetadata.value != str(OPERATION_LEVEL_NONE), + ) + .limit(1) + ) + if result.first() is not None: + self._activate() + except Exception as exc: # pragma: no cover - defensive + self.ap.logger.debug(f'Operation trace global gate prime skipped: {exc}') + async def _read_metadata(self, workspace_uuid: str, key: str) -> str | None: try: result = await self.ap.persistence_mgr.execute_async( @@ -950,7 +1011,12 @@ class WorkspaceSettingsService: raw = await self._read_metadata(workspace_uuid, OPERATION_LEVEL_KEY) if raw is None: return DEFAULT_OPERATION_LEVEL - return clamp_level(raw) + configured = clamp_level(raw) + if configured > OPERATION_LEVEL_NONE: + # Reading an enabled Workspace re-opens the gate, so tracing resumes + # on its own after a restart even before a write occurs. + self._activate() + return configured async def effective_level(self, workspace_uuid: str, role: str | None) -> int: """Return the level actually used for a request by ``role``.""" @@ -979,6 +1045,8 @@ class WorkspaceSettingsService: """ resolved_level = clamp_level(level) + if resolved_level > OPERATION_LEVEL_NONE: + self._activate() values = { OPERATION_LEVEL_KEY: str(resolved_level), } @@ -1006,6 +1074,20 @@ class WorkspaceSettingsService: return (await self._operations_policy(workspace_uuid))['dedupe_window_seconds'] + def resolve_resource_identity( + self, + path_params: typing.Mapping[str, typing.Any] | None, + body: typing.Mapping[str, typing.Any] | None, + ) -> str | None: + """Identify the resource a request acts on. + + Thin instance wrapper over the module-level resolver so the Core route + wrapper can name a resource through the service handle without importing + this package directly. + """ + + return resolve_resource_identity(path_params, body) + async def describe_governance(self, workspace_uuid: str) -> dict[str, typing.Any]: """Return the governance payload rendered by the settings panel. diff --git a/src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py b/src/langbot/pkg/persistence/alembic/versions/0032_operation_traceability.py similarity index 67% rename from src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py rename to src/langbot/pkg/persistence/alembic/versions/0032_operation_traceability.py index 1a5073124..f2e3a9c2d 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0032_workspace_operation_logs.py +++ b/src/langbot/pkg/persistence/alembic/versions/0032_operation_traceability.py @@ -1,6 +1,6 @@ -"""add workspace operation logs for member/resource traceability +"""add workspace operation traceability table -Revision ID: 0032_workspace_operation_logs +Revision ID: 0032_operation_traceability Revises: 0031_merge_totp_assistant Create Date: 2026-09-25 @@ -8,6 +8,12 @@ The table is append-only and tenant-owned. Fresh installs already receive it from SQLAlchemy ``create_all``; this revision makes the change safe for existing databases and mirrors the shared Row Level Security contract so the Cloud release validation can enforce it. + +It carries the full shape in one step: the base traceability columns, the +tamper-evidence hash chain (``record_hash`` / ``prev_hash``) and the +``dedupe_key`` used to collapse repeated read observations. The single +composite index ``ix_workspace_operation_logs_workspace_dedupe`` covers the +dedupe lookup, so no standalone ``dedupe_key`` index exists. """ from __future__ import annotations @@ -16,7 +22,7 @@ import sqlalchemy as sa from alembic import op -revision = '0032_workspace_operation_logs' +revision = '0032_operation_traceability' down_revision = '0031_merge_totp_assistant' branch_labels = None depends_on = None @@ -26,6 +32,22 @@ _TABLE_NAME = 'workspace_operation_logs' _POLICY_NAME = 'langbot_workspace_isolation' _TENANT_SETTING = 'langbot.workspace_uuid' +#: Every index the ORM declares for this table. Kept in one place so the +#: migration and ``Base.metadata.create_all`` stay in lockstep. +_INDEXES: tuple[tuple[str, list[str]], ...] = ( + ('ix_workspace_operation_logs_workspace_created', ['workspace_uuid', 'created_at']), + ('ix_workspace_operation_logs_workspace_resource', ['workspace_uuid', 'resource_type', 'created_at']), + ('ix_workspace_operation_logs_workspace_dedupe', ['workspace_uuid', 'dedupe_key', 'created_at']), + ('ix_workspace_operation_logs_workspace_uuid', ['workspace_uuid']), + ('ix_workspace_operation_logs_created_at', ['created_at']), + ('ix_workspace_operation_logs_action', ['action']), + ('ix_workspace_operation_logs_resource_type', ['resource_type']), + ('ix_workspace_operation_logs_level', ['level']), + ('ix_workspace_operation_logs_actor_account_uuid', ['actor_account_uuid']), + ('ix_workspace_operation_logs_request_id', ['request_id']), + ('ix_workspace_operation_logs_route', ['route']), +) + def _setting(name: str) -> str: return f"NULLIF(current_setting('{name}', true), '')" @@ -66,6 +88,9 @@ def upgrade() -> None: sa.Column('summary', sa.Text(), nullable=True), sa.Column('changes', sa.Text(), nullable=True), sa.Column('detail', sa.Text(), nullable=True), + sa.Column('record_hash', sa.String(64), nullable=True), + sa.Column('prev_hash', sa.String(64), nullable=True), + sa.Column('dedupe_key', sa.String(64), nullable=True), sa.Column('client_ip', sa.String(64), nullable=True), sa.Column('user_agent', sa.String(512), nullable=True), sa.Column('duration_ms', sa.Integer(), nullable=False, server_default='0'), @@ -77,28 +102,8 @@ def upgrade() -> None: name='ck_workspace_operation_logs_outcome', ), ) - op.create_index( - 'ix_workspace_operation_logs_workspace_created', - _TABLE_NAME, - ['workspace_uuid', 'created_at'], - unique=False, - ) - op.create_index( - 'ix_workspace_operation_logs_workspace_resource', - _TABLE_NAME, - ['workspace_uuid', 'resource_type', 'created_at'], - unique=False, - ) - op.create_index( - 'ix_workspace_operation_logs_workspace_uuid', - _TABLE_NAME, - ['workspace_uuid'], - unique=False, - ) - op.create_index('ix_workspace_operation_logs_created_at', _TABLE_NAME, ['created_at'], unique=False) - op.create_index('ix_workspace_operation_logs_action', _TABLE_NAME, ['action'], unique=False) - op.create_index('ix_workspace_operation_logs_resource_type', _TABLE_NAME, ['resource_type'], unique=False) - op.create_index('ix_workspace_operation_logs_level', _TABLE_NAME, ['level'], unique=False) + for index_name, columns in _INDEXES: + op.create_index(index_name, _TABLE_NAME, columns, unique=False) if conn.dialect.name != 'postgresql': return @@ -119,15 +124,12 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() + if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): + return if conn.dialect.name == 'postgresql': table = _quote(conn, _TABLE_NAME) policy = _quote(conn, _POLICY_NAME) op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) - op.drop_index('ix_workspace_operation_logs_level', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_resource_type', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_action', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_created_at', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_workspace_uuid', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_workspace_resource', table_name=_TABLE_NAME) - op.drop_index('ix_workspace_operation_logs_workspace_created', table_name=_TABLE_NAME) + for index_name, _ in reversed(_INDEXES): + op.drop_index(index_name, table_name=_TABLE_NAME) op.drop_table(_TABLE_NAME) diff --git a/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py b/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py deleted file mode 100644 index 126f5b3ed..000000000 --- a/src/langbot/pkg/persistence/alembic/versions/0033_operation_log_integrity.py +++ /dev/null @@ -1,70 +0,0 @@ -"""add tamper-evidence columns to workspace operation logs - -Revision ID: 0033_operation_log_integrity -Revises: 0032_workspace_operation_logs -Create Date: 2026-09-25 - -Adds the append-only hash chain (``record_hash`` / ``prev_hash``) and the -``dedupe_key`` used to collapse repeated read observations. Fresh databases -already receive these columns from SQLAlchemy ``create_all``; this revision -upgrades existing databases and is guarded so neither path fails. -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - - -revision = '0033_operation_log_integrity' -down_revision = '0032_workspace_operation_logs' -branch_labels = None -depends_on = None - - -_TABLE_NAME = 'workspace_operation_logs' -_NEW_COLUMNS = ( - sa.Column('record_hash', sa.String(64), nullable=True), - sa.Column('prev_hash', sa.String(64), nullable=True), - sa.Column('dedupe_key', sa.String(64), nullable=True), -) -_DEDUPE_INDEX = 'ix_workspace_operation_logs_workspace_dedupe' - - -def upgrade() -> None: - conn = op.get_bind() - if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): - return - - existing = {column['name'] for column in sa.inspect(conn).get_columns(_TABLE_NAME)} - missing = [column for column in _NEW_COLUMNS if column.name not in existing] - if missing: - with op.batch_alter_table(_TABLE_NAME, recreate='auto') as batch: - for column in missing: - batch.add_column(column) - - index_names = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE_NAME)} - if _DEDUPE_INDEX not in index_names: - op.create_index( - _DEDUPE_INDEX, - _TABLE_NAME, - ['workspace_uuid', 'dedupe_key', 'created_at'], - unique=False, - ) - - -def downgrade() -> None: - conn = op.get_bind() - if _TABLE_NAME not in set(sa.inspect(conn).get_table_names()): - return - - index_names = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE_NAME)} - if _DEDUPE_INDEX in index_names: - op.drop_index(_DEDUPE_INDEX, table_name=_TABLE_NAME) - - existing = {column['name'] for column in sa.inspect(conn).get_columns(_TABLE_NAME)} - removable = [column.name for column in _NEW_COLUMNS if column.name in existing] - if removable: - with op.batch_alter_table(_TABLE_NAME, recreate='auto') as batch: - for name in removable: - batch.drop_column(name) diff --git a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx index 68d92b406..3b319c6cf 100644 --- a/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx +++ b/web/src/app/home/components/workspace-settings/OperationTracePanel.tsx @@ -639,6 +639,18 @@ export default function OperationTracePanel({ )} )} + {record.resource_id && ( + // Which concrete resource was touched (author/name, + // owner/repo, filename...). Rendered as data, not copy, + // so it is not localized. + + {record.resource_id} + + )} {record.tampered ? (