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