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.
This commit is contained in:
TyperBody
2026-09-27 00:11:57 +08:00
parent becb6c78b4
commit 9964f284e1
19 changed files with 216 additions and 131 deletions
+11 -5
View File
@@ -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
@@ -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
@@ -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 (
@@ -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 = {
@@ -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
@@ -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
@@ -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
@@ -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(
@@ -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
@@ -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)
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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,
@@ -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 ---------------------------------------------
@@ -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']
@@ -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:
@@ -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.
@@ -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)
@@ -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)
@@ -639,6 +639,18 @@ export default function OperationTracePanel({
)}
</Badge>
)}
{record.resource_id && (
// Which concrete resource was touched (author/name,
// owner/repo, filename...). Rendered as data, not copy,
// so it is not localized.
<Badge
variant="outline"
className="max-w-[16rem] truncate font-mono"
title={record.resource_id}
>
{record.resource_id}
</Badge>
)}
{record.tampered ? (
<Badge variant="destructive">
<ShieldAlert className="size-3" />