Merge pull request #2579 from langbot-app/feat/workspace-operation-traceability

Feat/workspace operation traceability
This commit is contained in:
Type_rBody
2026-09-28 02:27:56 +08:00
committed by GitHub
38 changed files with 6614 additions and 27 deletions
+167 -4
View File
@@ -4,6 +4,7 @@ import abc
import typing
import enum
import quart
import time
import traceback
import inspect
import uuid
@@ -94,12 +95,23 @@ class RouterGroup(abc.ABC):
rule: str,
auth_type: AuthType = AuthType.USER_TOKEN,
permission: Permission | str | None = None,
operation_log_meta: dict[str, typing.Any] | None = None,
**options: typing.Any,
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""Register a route"""
"""Register a route.
``operation_log_meta`` optionally enriches the traceability record for
this route with trusted server-side facts. Supported keys:
* ``resource_param``: handler kwarg holding the target resource id,
* ``changes``: a pre-computed before/after diff to persist,
* ``detail``: extra structured context (sensitive keys are redacted).
"""
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
raise ValueError('Account-token routes cannot declare Workspace permissions')
if operation_log_meta is not None and not isinstance(operation_log_meta, dict):
raise ValueError('operation_log_meta must be a dict')
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
@@ -223,6 +235,7 @@ class RouterGroup(abc.ABC):
except Exception as e:
return self._auth_error_response(e)
observed_at = time.monotonic()
try:
if request_context is not None:
diagnostics.workspace(request_context)
@@ -245,11 +258,40 @@ class RouterGroup(abc.ABC):
# runtimes, uploads, or streamed clients.
# Services that need atomic writes open a UoW.
async with persistence_mgr.tenant_scope(request_context.workspace_uuid):
return await f(*args, **kwargs)
return await f(*args, **kwargs)
return await f(*args, **kwargs)
response = await f(*args, **kwargs)
else:
response = await f(*args, **kwargs)
else:
response = await f(*args, **kwargs)
if request_context is not None:
# Trace after the handler released its tenant scope so
# auditing never extends a business transaction. The
# call is a no-op (and costs no database round trip)
# when tracing is off for this Workspace.
await self._record_operation(
request_context,
rule,
options,
kwargs,
observed_at,
response,
meta=operation_log_meta,
)
return response
except Exception as e: # 自动 500
if request_context is not None:
await self._record_operation(
request_context,
rule,
options,
kwargs,
observed_at,
None,
failure=e,
meta=operation_log_meta,
)
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, AuthorizationError):
@@ -301,6 +343,127 @@ class RouterGroup(abc.ABC):
return decorator
async def _record_operation(
self,
ctx: RequestContext,
route: str,
options: dict[str, typing.Any],
kwargs: dict[str, typing.Any],
observed_at: float,
response: typing.Any,
*,
failure: BaseException | None = None,
meta: dict[str, typing.Any] | None = None,
) -> None:
"""Best-effort traceability for one finished request.
This runs after the handler released its tenant scope. It never raises:
an audit failure must not turn a successful business response into a
500. The service performs its own cheap level check before touching the
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
try:
method = str(quart.request.method or options.get('methods', ['GET'])[0])
status_code = self._extract_status_code(response, failure)
if failure is not None:
outcome = 'denied' if isinstance(failure, AuthorizationError) else 'error'
elif status_code >= 500:
outcome = 'error'
elif status_code >= 400:
outcome = 'denied'
else:
outcome = 'ok'
resolved_meta = meta or {}
resource_id = None
resource_param = resolved_meta.get('resource_param')
if isinstance(resource_param, str):
candidate = kwargs.get(resource_param)
if candidate is not None:
resource_id = str(candidate)
# Routes without an explicit resource_param still name the resource
# they act on, either as a URL parameter or inside the payload of an
# install-style endpoint. Resolving that identity here gives every
# resource family a "which plugin/skill/knowledge base" trace without
# touching each handler. The body is read defensively: an audit path
# must never fail (or block) because a request carries no JSON.
try:
# 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 = service.resolve_resource_identity(kwargs, request_body)
if identity:
resource_id = identity
except Exception: # pragma: no cover - identity resolution is best effort
pass
# Handlers may publish request-local traceability facts when a
# static route declaration cannot describe the runtime diff, e.g.
# the previous and the new role of a member. ``quart.g`` is
# request-local, so concurrent requests never share these values.
# The stored summary is derived by the service from ``changes`` and
# the classified action, so handlers only publish the diff itself.
changes = resolved_meta.get('changes')
detail = resolved_meta.get('detail')
request_changes = getattr(quart.g, 'operation_log_changes', None)
request_detail = getattr(quart.g, 'operation_log_detail', None)
request_resource_id = getattr(quart.g, 'operation_log_resource_id', None)
if request_changes:
changes = request_changes
if request_detail:
detail = request_detail
if request_resource_id:
resource_id = str(request_resource_id)
await service.record_request(
ctx,
method=method,
route=route,
status_code=status_code,
outcome=outcome,
duration_ms=int(max(time.monotonic() - observed_at, 0.0) * 1000),
resource_id=resource_id,
changes=changes or None,
detail=detail or None,
)
except Exception as exc: # pragma: no cover - auditing is best effort
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.debug(f'Operation trace skipped for {route}: {exc}')
@staticmethod
def _extract_status_code(response: typing.Any, failure: BaseException | None) -> int:
"""Resolve the HTTP status of a handler result without consuming it."""
if failure is not None:
status = getattr(failure, 'status_code', None)
if isinstance(status, int):
return status
return 500
if isinstance(response, tuple) and len(response) >= 2 and isinstance(response[1], int):
return response[1]
status = getattr(response, 'status_code', None)
if isinstance(status, int):
return status
return 200
async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
account: typing.Any = None
resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
@@ -2,6 +2,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ......operation_trace import service 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({})
@@ -4,6 +4,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ......operation_trace import service as settings_service
from ....service.secrets import redact_secrets
from ....service.pipeline_run import run_pipeline
from ... import group
@@ -88,15 +89,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()
@@ -159,7 +175,41 @@ 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
json_data = await quart.request.json
# Capture "what was bound before" so the trace answers "what changed
# into what": without this the log could only say an extension was
# configured, never which binding was added or removed.
try:
previous = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
except Exception:
previous = None
# The request uses ``bound_*`` keys while the stored preferences use
# the canonical names, so both sides are normalized before diffing.
requested = {
canonical: json_data[field]
for field, canonical in {
'bound_plugins': 'plugins',
'bound_mcp_servers': 'mcp_servers',
'bound_skills': 'skills',
'bound_mcp_resources': 'mcp_resources',
'enable_all_plugins': 'enable_all_plugins',
'enable_all_mcp_servers': 'enable_all_mcp_servers',
'enable_all_skills': 'enable_all_skills',
'mcp_resource_agent_read_enabled': 'mcp_resource_agent_read_enabled',
}.items()
if field in json_data
}
if isinstance(previous, dict):
current = {
**normalize_extension_preferences(previous.get('extensions_preferences')),
**requested,
}
else:
current = requested
changes = settings_service.changed_fields(current, requested)
if changes:
quart.g.operation_log_changes = changes
try:
validate_extension_preferences(
{
@@ -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 .....operation_trace import service 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(
@@ -853,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)
@@ -893,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()
@@ -934,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,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 ......operation_trace import service 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(
@@ -2,6 +2,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ......operation_trace import service 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(
@@ -5,6 +5,7 @@ from urllib.parse import unquote
from ....authz import Permission
from ....context import RequestContext
from ......operation_trace import service 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()
@@ -7,6 +7,7 @@ from langbot_plugin.box.errors import BoxError
from ...authz import Permission
from ...context import RequestContext
from .....operation_trace import service 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()
@@ -167,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})
@@ -210,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,6 +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 .....operation_trace import service as settings_service
from .. import group
@@ -218,6 +219,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
)
created = await self._run_in_workspace_uow(workspace_uuid, create_invitation)
invite_rule = settings_service.ACTION_RULES_BY_ACTION['member_invite']
invite_changes = [
{
'field': 'invitation',
'before': None,
'after': f'{created.invitation.normalized_email}:{created.invitation.role}',
}
]
quart.g.operation_log_changes = invite_changes
quart.g.operation_log_resource_id = created.invitation.uuid
quart.g.operation_log_detail = {'role': created.invitation.role}
quart.g.operation_log_summary = settings_service.build_summary(invite_rule, invite_changes)
delivery_service = self._invitation_delivery_service()
link = delivery_service.build_invitation_link(created.token)
workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
@@ -266,6 +279,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
request_context: RequestContext,
) -> typing.Any:
self._require_current_workspace(workspace_uuid, request_context)
# Capture the previous role before mutating so the operation trace
# can answer "what was changed into what" for this member.
before_role = await self._member_role(workspace_uuid, account_uuid)
if quart.request.method == 'DELETE':
if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
return self.http_status(403, 'permission_denied', 'Member removal permission is required')
@@ -276,6 +294,17 @@ class WorkspacesRouterGroup(group.RouterGroup):
)
member = await self._run_in_workspace_uow(workspace_uuid, remove_member)
await self._publish_member_trace(
rule_action='member_remove',
account_uuid=account_uuid,
changes=[
{
'field': 'member',
'before': f'{account_uuid}:{before_role or "unknown"}',
'after': 'removed',
}
],
)
return self.success(data={'account_uuid': member.account_uuid})
data = await quart.request.get_json(silent=True) or {}
@@ -290,6 +319,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
member = await self._run_in_workspace_uow(workspace_uuid, update_member_role)
account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
role_changes = [
{'field': 'role', 'before': before_role, 'after': member.role},
]
await self._publish_member_trace(
rule_action='member_role_update',
account_uuid=account_uuid,
changes=role_changes,
detail={
'member_email': account.normalized_email if account is not None else None,
},
)
return self.success(
data={
'member': _membership_payload(
@@ -300,6 +341,41 @@ class WorkspacesRouterGroup(group.RouterGroup):
}
)
async def _member_role(self, workspace_uuid: str, account_uuid: str) -> str | None:
"""Return a member's current role without failing the request."""
try:
members = await self.ap.workspace_collaboration_service.list_members(
workspace_uuid, quart.g.workspace_membership
)
except Exception:
return None
for view in members:
if view.membership.account_uuid == account_uuid:
return view.membership.role
return None
async def _publish_member_trace(
self,
*,
rule_action: str,
account_uuid: str,
changes: list[dict[str, typing.Any]],
detail: dict[str, typing.Any] | None = None,
) -> None:
"""Publish request-local trace facts consumed by the route wrapper.
``quart.g`` is request-local, so concurrent member operations never
share these values.
"""
rule = settings_service.ACTION_RULES_BY_ACTION.get(rule_action)
quart.g.operation_log_changes = changes
quart.g.operation_log_resource_id = account_uuid
quart.g.operation_log_detail = detail or {}
if rule is not None:
quart.g.operation_log_summary = settings_service.build_summary(rule, changes)
@staticmethod
def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
if workspace_uuid != request_context.workspace_uuid:
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import importlib
import os
import typing
@@ -27,6 +28,13 @@ importutil.import_modules_in_pkg(groups_pipelines)
importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
# 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. It
# runs after the package scan so its routes join the same registry.
importlib.import_module('langbot.pkg.operation_trace.routes')
class BoundedJSONRequest(quart.Request):
"""Parse bounded HTTP JSON bodies outside the shared event loop."""
@@ -25,6 +25,10 @@ Do not claim the application has been tested: this experiment has no chat-test o
After creation/configuration show the returned resource URL so the user can open the normal editor,
upload documents and use its existing debug chat. If an operation failed or its result is unknown,
do not repeat a write automatically; explain the result and ask the user to inspect the resource.
When the list_operation_logs tool is available (owner / admin only), use it to answer "who changed or
viewed what" questions with the recorded before/after fields instead of guessing. Always query it with
a narrow filter (search / resource_type / actor) rather than an unfiltered dump, and never repeat the
same empty query: if a filter matched nothing, widen it once instead of re-asking.
"""
@@ -20,6 +20,129 @@ class ListResources(Arguments):
kind: Literal['models', 'embedding_models', 'pipelines', 'knowledge_bases', 'knowledge_engines']
#: Canonical resource types, mirrored from the trace service action table. The
#: enum lets the model pick a value that exists instead of guessing a string
#: that matches nothing.
_RESOURCE_TYPES = (
'bot',
'adapter',
'model_provider',
'llm_model',
'pipeline',
'user',
'workspace',
'monitoring',
'webhook',
'api_key',
'workspace_settings',
'member',
'member_invitation',
'operation_log',
'plugin',
'plugin_page',
'skill',
'knowledge_base',
'mcp_server',
'runtime',
'system',
'resource',
)
class ListOperationLogs(Arguments):
"""Read the Workspace operation trace (owner / admin only)."""
#: Optional free-text filter is mapped to a substring match so the caller
#: can name a plugin, an account or a verb ("install") without knowing the
#: stored action key. Prefer this over ``action`` when unsure of the key.
search: str | None = Field(default=None, max_length=100)
#: Exact stored action key, e.g. ``plugin_install`` (see the action list in
#: the tool description). Only use it when the exact key is known.
action: str | None = Field(default=None, max_length=64)
resource_type: str | None = Field(
default=None,
max_length=64,
description='Optional resource type filter. Valid values: ' + ', '.join(_RESOURCE_TYPES) + '.',
)
actor: str | None = Field(
default=None,
max_length=64,
description='Optional actor filter: an account UUID or part of the actor name.',
)
limit: int = Field(
default=20,
ge=1,
le=50,
description='Maximum records to return. Keep it small (10-20) for a targeted answer.',
)
#: Forensics fields the settings panel needs but an answer does not; dropping
#: them keeps a page of records far below the assistant's per-result budget.
_COMPACT_CHANGE_FIELDS = 6
_COMPACT_CHANGE_CHARS = 160
def _brief(value, limit: int = _COMPACT_CHANGE_CHARS):
"""Render a diff value as a bounded string (mirrors the trace service)."""
if value is None or isinstance(value, (int, float, bool)):
return value
text = value if isinstance(value, str) else str(value)
return text if len(text) <= limit else text[: max(limit - 1, 0)] + '…'
def _compact_operation_records(result: dict) -> dict:
"""Project operation records onto the fields a "who did what" answer needs.
``query_logs`` returns the full panel payload -- hash chain, user agent,
payload details -- which is roughly a kilobyte per row. A whole page of
those overflows the assistant's per-result budget and arrives as a raw
truncated preview the model cannot read, which is why an answer can end up
claiming the data is unavailable. Only the actor, the action, the target,
the timestamp and the verification verdicts are kept here; the heavy
forensic columns stay available through the settings panel export.
"""
def record(row: dict) -> dict:
changes = [
{
'field': change.get('field'),
'before': _brief(change.get('before')),
'after': _brief(change.get('after')),
}
for change in (row.get('changes') or [])[:_COMPACT_CHANGE_FIELDS]
]
return {
'id': row.get('id'),
'created_at': row.get('created_at'),
'actor': row.get('actor_name') or row.get('actor_account_uuid'),
'actor_role': row.get('actor_role'),
'action': row.get('action'),
'action_i18n_key': row.get('action_i18n_key'),
'resource_type': row.get('resource_type'),
'resource_id': row.get('resource_id'),
'summary': row.get('summary'),
'changes': changes,
'outcome': row.get('outcome'),
'http_method': row.get('http_method'),
'route': row.get('route'),
'integrity_ok': row.get('integrity_ok'),
'chain_ok': row.get('chain_ok'),
'tampered': row.get('tampered'),
}
records = result.get('records') or []
return {
'total': result.get('total', len(records)),
'returned': len(records),
'tampered_count': result.get('tampered_count', 0),
'integrity_failed_count': result.get('integrity_failed_count', 0),
'chain_failed_count': result.get('chain_failed_count', 0),
'records': [record(row) for row in records],
}
class PipelineID(Arguments):
pipeline_uuid: UUID
@@ -45,29 +168,72 @@ class CreateKnowledgeBase(CreatePipeline):
retrieval_settings: dict = Field(default_factory=dict)
#: Each entry is ``(schema, description, writes, extra_permission)``. ``writes``
#: selects resource.manage vs resource.view; ``extra_permission`` narrows a tool
#: further (e.g. the audit surface needs ``audit.view``, which only the owner
#: and admin hold) and both are enforced identically when listing and calling.
TOOLS = {
'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False),
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False),
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False),
'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True),
'list_resources': (
ListResources,
'List existing Workspace resources. Discover IDs before using them.',
False,
None,
),
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None),
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None),
'list_operation_logs': (
ListOperationLogs,
'Search the Workspace operation trace (who changed or viewed what, with before/after fields). '
'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" '
'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or '
'search="install"); use the exact "action" key only when known - valid keys include '
'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, '
'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, '
'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, '
'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. '
'Combine resource_type and actor to answer "who installed X".',
False,
Permission.AUDIT_VIEW,
),
'create_pipeline': (
CreatePipeline,
'Create an unconnected Pipeline draft. Requires user confirmation.',
True,
None,
),
'configure_pipeline': (
ConfigurePipeline,
'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.',
True,
None,
),
'create_knowledge_base': (
CreateKnowledgeBase,
'Create a knowledge base using an installed engine. Read its schema first. Requires confirmation.',
True,
None,
),
}
def _required_permission(writes: bool, extra: Permission | None) -> Permission:
"""Return the permission a tool needs beyond the base resource verbs."""
if extra is not None:
return extra
return Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW
def _authorized(context, name: str) -> bool:
_schema, _description, writes, extra = TOOLS[name]
return _required_permission(writes, extra).value in context.workspace.permissions
def validate_call(context, name: str, arguments: dict) -> Arguments:
if name not in TOOLS:
raise ValueError('Unknown management tool')
schema, _, writes = TOOLS[name]
require_permission(context, Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW)
schema, _, writes, extra = TOOLS[name]
require_permission(context, _required_permission(writes, extra))
return schema.model_validate(arguments)
@@ -80,8 +246,8 @@ def tool_definitions(context) -> list[LLMTool]:
parameters=schema.model_json_schema(),
func=execute_tool,
)
for name, (schema, description, writes) in TOOLS.items()
if not writes or Permission.RESOURCE_MANAGE in context.workspace.permissions
for name, (schema, description, _writes, _extra) in TOOLS.items()
if _authorized(context, name)
]
@@ -101,6 +267,23 @@ async def execute_tool(ap, context, name: str, arguments: dict):
fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id')
resources = [{key: item[key] for key in fields if key in item} for item in resources]
return {'total': len(resources), 'items': redact_secrets(resources)}
if name == 'list_operation_logs':
# Delegates to the operation-trace service, so the assistant shows the
# same records and tamper verification as the settings panel, then
# compacts them so a full page still fits the per-result budget.
result = await ap.workspace_settings_service.query_logs(
context.workspace_uuid,
limit=args['limit'],
search=args['search'],
action=args['action'],
resource_type=args['resource_type'],
actor_account_uuid=args['actor'],
)
compact = _compact_operation_records(result)
# Echo the applied filters so the model can see why a result is empty
# and widen a filter instead of repeating an empty targeted query.
compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)}
return compact
if name == 'get_pipeline':
return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid'])
if name == 'get_knowledge_schema':
+43
View File
@@ -36,6 +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 ..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
@@ -198,6 +199,8 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None
workspace_settings_service: workspace_settings_service.WorkspaceSettingsService = None
skill_service: skill_service.SkillService = None
skill_mgr: skill_mgr.SkillManager = None
@@ -434,6 +437,22 @@ class Application:
* 3600
)
# Operation-log retention piggybacks on the shared maintenance loop
# instead of starting another long-lived task, so enabling
# traceability adds no scheduling cost of its own.
operation_log_cleanup_cfg = self.instance_config.data.get('operation_log', {}).get('auto_cleanup', {})
operation_log_enabled = (
operation_log_cleanup_cfg.get('enabled', True) and self.workspace_settings_service is not None
)
operation_log_interval_seconds = (
self._get_positive_float_config(
operation_log_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='operation_log.auto_cleanup.check_interval_hours',
)
* 3600
)
maintenance_intervals: dict[str, float] = {}
if monitoring_enabled:
maintenance_intervals['monitoring'] = monitoring_interval_seconds
@@ -441,6 +460,8 @@ class Application:
maintenance_intervals['storage'] = storage_interval_seconds
if self.workspace_collaboration_service is not None:
maintenance_intervals['invitations'] = 3600.0
if operation_log_enabled:
maintenance_intervals['operation_logs'] = operation_log_interval_seconds
if maintenance_intervals:
@@ -506,6 +527,28 @@ class Application:
self.logger.warning(
f'Storage maintenance failed for Workspace {context.workspace_uuid}: {exc}'
)
if 'operation_logs' in due:
# Retention is per Workspace, so this must run
# inside the binding loop; left outside it only
# the last discovered Workspace would ever be
# pruned while the others grew unbounded.
try:
report = await self.workspace_settings_service.prune(
context.workspace_uuid,
)
removed = report['expired'] + report['trimmed']
if removed > 0:
self.logger.info(
f'Operation log auto-cleanup for Workspace '
f'{context.workspace_uuid}: removed {removed} records'
)
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(
f'Operation log auto-cleanup failed for '
f'Workspace {context.workspace_uuid}: {exc}'
)
if 'invitations' in due:
try:
await self.workspace_collaboration_service.cleanup_expired_invitations(
+10
View File
@@ -29,6 +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 ...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
@@ -127,6 +128,9 @@ class BuildAppStage(stage.BootingStage):
mcp_service_inst = mcp_service.MCPService(ap)
ap.mcp_service = mcp_service_inst
workspace_settings_service_inst = operation_trace_service.WorkspaceSettingsService(ap)
ap.workspace_settings_service = workspace_settings_service_inst
apikey_service_inst = apikey_service.ApiKeyService(ap)
ap.apikey_service = apikey_service_inst
@@ -158,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,
@@ -0,0 +1,179 @@
"""Workspace operation log entity for member action traceability.
This table is the append-only fact source for "who did what to which
resource" inside one Workspace. It is deliberately separate from
:mod:`event_log` (Runner/runtime facts) and from monitoring tables
(LLM/tool telemetry) so that administrative traceability can be enabled,
queried and pruned without touching the hot runtime path.
Design constraints
------------------
* One row per recorded operation; no updates after insert (append-only).
* ``changes`` holds the before/after diff as a compact JSON string so the
log answers the question "what was changed into what".
* Rows are tenant-owned: every row carries ``workspace_uuid`` and is
covered by the shared Row Level Security contract.
"""
from __future__ import annotations
import sqlalchemy
from .base import Base
# Operation levels, ordered by increasing capture scope. They live in the
# entity module so both the persistence layer and the HTTP layer can import
# one single source of truth without a circular dependency.
OPERATION_LEVEL_NONE = 0
"""Record nothing (tracing disabled for the Workspace)."""
OPERATION_LEVEL_MUTATION = 1
"""Record create / update / delete / role changes only."""
OPERATION_LEVEL_READ = 2
"""Additionally record every read / view / list operation."""
OPERATION_LEVEL_NAMES = {
OPERATION_LEVEL_NONE: 'off',
OPERATION_LEVEL_MUTATION: 'mutation',
OPERATION_LEVEL_READ: 'read',
}
class WorkspaceOperationLog(Base):
"""One traceable administrative operation inside a Workspace."""
__tablename__ = 'workspace_operation_logs'
id = sqlalchemy.Column(
sqlalchemy.BigInteger().with_variant(sqlalchemy.Integer, 'sqlite'), primary_key=True, autoincrement=True
)
workspace_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
index=True,
)
"""Owning Workspace; also the tenant isolation key."""
# --- Actor: who performed the operation -----------------------------
actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
"""Account UUID of the actor, when the principal is an Account."""
actor_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Human readable actor identifier captured at write time."""
actor_role = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
"""Workspace role the actor held while performing the operation."""
principal_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
"""Principal kind (account, api_key, support_admin, system)."""
api_key_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""API key UUID when the operation was driven by an API key."""
auth_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=True)
"""Authentication mode used for the request."""
# --- Request correlation --------------------------------------------
request_id = sqlalchemy.Column(sqlalchemy.String(128), nullable=True, index=True)
"""Stable request id shared with logs and error responses."""
http_method = sqlalchemy.Column(sqlalchemy.String(12), nullable=True)
"""HTTP verb of the traced request."""
route = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, index=True)
"""Registered Core route identity (never a raw user-supplied URL)."""
# --- What happened ---------------------------------------------------
action = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True)
"""Normalized action verb (create, update, delete, view, ...)."""
resource_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=True, index=True)
"""Resource family the operation targeted (member, resource, ...)."""
resource_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
"""Identifier of the targeted resource, when known."""
level = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1', index=True)
"""Capture level that produced this row (1 = mutation, 2 = read)."""
outcome = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='ok')
"""Result bucket: ok, denied or error."""
status_code = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
"""HTTP status code when the operation surfaced one."""
# --- Payloads --------------------------------------------------------
summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Short human readable description of the operation."""
changes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Before/after diff as a JSON string: what was changed into what."""
detail = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Additional structured context as a JSON string."""
# --- Tamper evidence (append-only hash chain) ------------------------
record_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
"""Keyed hash over this row's canonical content.
Recomputed on read: a mismatch marks the card as tampered.
"""
prev_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
"""Previous record's ``record_hash`` in the same Workspace.
Linking rows this way makes silent edits or deletions detectable.
"""
dedupe_key = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
"""Hash used to collapse repeated read observations into one row."""
# --- Client fingerprint ---------------------------------------------
client_ip = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
"""Best-effort client address."""
user_agent = sqlalchemy.Column(sqlalchemy.String(512), nullable=True)
"""Client user agent, truncated before insert."""
duration_ms = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='0')
"""Handler wall-clock duration in milliseconds."""
created_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
index=True,
)
"""When the operation was recorded."""
__table_args__ = (
sqlalchemy.Index(
'ix_workspace_operation_logs_workspace_created',
'workspace_uuid',
'created_at',
),
sqlalchemy.Index(
'ix_workspace_operation_logs_workspace_resource',
'workspace_uuid',
'resource_type',
'created_at',
),
sqlalchemy.Index(
'ix_workspace_operation_logs_workspace_dedupe',
'workspace_uuid',
'dedupe_key',
'created_at',
),
sqlalchemy.CheckConstraint(
'level IN (0, 1, 2)',
name='ck_workspace_operation_logs_level',
),
sqlalchemy.CheckConstraint(
"outcome IN ('ok', 'denied', 'error')",
name='ck_workspace_operation_logs_outcome',
),
)
@@ -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']
+250
View File
@@ -0,0 +1,250 @@
"""Workspace governance settings routes.
Exposes the "成员操作日志溯源" (member operation traceability) surface:
* ``GET /api/v1/settings/governance`` read level + template table
* ``PUT /api/v1/settings/governance`` change level / retention
* ``GET /api/v1/settings/operation-logs`` page through records
* ``GET /api/v1/settings/operation-logs/filters`` available filter values
* ``GET /api/v1/settings/operation-logs/export`` download the filtered CSV
Operation records are append-only and there is no route to delete them on
demand: retention is the only deletion path, and it runs automatically when
the governance settings change and from the maintenance loop.
Every route requires ``audit.view``, which is granted to the Workspace owner
and admin only. Write routes additionally require an owning/admin role so a
future permission grant cannot silently widen who may reconfigure tracing.
"""
from __future__ import annotations
import datetime
import json
import typing
import quart
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:
"""Parse an ISO-8601 query parameter, tolerating a trailing ``Z``."""
if not value:
return None
candidate = value.strip()
if not candidate:
return None
if candidate.endswith('Z'):
candidate = candidate[:-1] + '+00:00'
try:
parsed = datetime.datetime.fromisoformat(candidate)
except ValueError:
return None
# Stored timestamps are naive UTC; normalise aware inputs before comparing.
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def _parse_int(value: str | None, default: int) -> int:
try:
return int(value) if value is not None else default
except (TypeError, ValueError):
return default
def _csv_cell(value: typing.Any) -> str:
"""Render one CSV cell with the standard quoting rules."""
if value is None:
return ''
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
return '"' + text.replace('"', '""') + '"'
@group.group_class('settings', '/api/v1/settings')
class SettingsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route(
'/governance',
methods=['GET', 'PUT'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
service = self.ap.workspace_settings_service
if quart.request.method == 'GET':
return self.success(data=await service.describe_governance(request_context.workspace_uuid))
if not settings_service.role_may_configure(request_context.workspace.role):
return self.http_status(
403,
'permission_denied',
'Only Workspace owners and admins can change tracing settings',
)
data = await quart.request.get_json(silent=True) or {}
# The previous level is captured before the write so the trace can
# answer "what was changed into what" for the governance change
# itself, not only for the resources it protects.
before = await service.describe_governance(request_context.workspace_uuid)
updated = await service.set_operation_level(
request_context.workspace_uuid,
data.get('level', before['configured_level']),
retention_days=data.get('retention_days'),
max_rows=data.get('max_rows'),
dedupe_window_seconds=data.get('dedupe_window_seconds'),
)
account_name = None
if request_context.account_uuid and self.ap.user_service is not None:
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
account_name = account.user if account is not None else None
changes = settings_service.changed_fields(
{
'configured_level': before['configured_level'],
'retention_days': before['retention_days'],
'max_rows': before['max_rows'],
'dedupe_window_seconds': before['dedupe_window_seconds'],
},
{
'configured_level': updated['configured_level'],
'retention_days': updated['retention_days'],
'max_rows': updated['max_rows'],
'dedupe_window_seconds': updated['dedupe_window_seconds'],
},
)
if changes:
await service.record(
request_context.workspace_uuid,
rule=settings_service.ACTION_RULES_BY_ACTION['settings_update'],
level=max(
await service.effective_level(
request_context.workspace_uuid,
request_context.workspace.role,
),
settings_service.OPERATION_LEVEL_MUTATION,
),
actor_account_uuid=request_context.account_uuid,
actor_name=account_name,
actor_role=request_context.workspace.role,
principal_type=request_context.principal.principal_type.value,
request_id=request_context.request_id,
http_method=quart.request.method,
route='/api/v1/settings/governance',
outcome='ok',
summary=settings_service.build_summary(
settings_service.ACTION_RULES_BY_ACTION['settings_update'], changes
),
changes=changes,
)
return self.success(data=updated)
@self.route(
'/operation-logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
# Read-only by design: operation records are immutable and cannot
# be deleted on demand. Only the retention policy may prune them.
service = self.ap.workspace_settings_service
args = quart.request.args
level_raw = args.get('level')
result = await service.query_logs(
request_context.workspace_uuid,
limit=_parse_int(args.get('limit'), settings_service.DEFAULT_PAGE_SIZE),
offset=_parse_int(args.get('offset'), 0),
action=args.get('action') or None,
resource_type=args.get('resource_type') or None,
actor_account_uuid=args.get('actor') or None,
level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None,
since=_parse_timestamp(args.get('since')),
until=_parse_timestamp(args.get('until')),
# Optional verification filter: lets the panel turn the three
# tamper counters into a drill-down instead of a dead badge.
integrity=args.get('integrity') or None,
)
return self.success(data=result)
@self.route(
'/operation-logs/filters',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
service = self.ap.workspace_settings_service
options = await service.list_filter_options(request_context.workspace_uuid)
options['governance'] = await service.describe_governance(request_context.workspace_uuid)
return self.success(data=options)
@self.route(
'/operation-logs/export',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> typing.Any:
"""Download the filtered records as a CSV attachment.
The export is read-only and shares the exact filters of the panel,
so what an operator sees is what the artifact contains.
"""
args = quart.request.args
level_raw = args.get('level')
service = self.ap.workspace_settings_service
document = await service.export_logs(
request_context.workspace_uuid,
action=args.get('action') or None,
resource_type=args.get('resource_type') or None,
actor_account_uuid=args.get('actor') or None,
level=_parse_int(level_raw, 0) if level_raw not in (None, '') else None,
since=_parse_timestamp(args.get('since')),
until=_parse_timestamp(args.get('until')),
)
columns = (
'id',
'created_at',
'action',
'resource_type',
'resource_id',
'actor_account_uuid',
'actor_name',
'actor_role',
'principal_type',
'http_method',
'route',
'outcome',
'status_code',
'level_name',
'integrity_ok',
'chain_ok',
'tampered',
'record_hash',
'prev_hash',
'duration_ms',
'summary',
'changes',
'detail',
)
lines = [','.join(columns)]
for record in document['records']:
lines.append(','.join(_csv_cell(record.get(column)) for column in columns))
# Prepend a UTF-8 BOM so spreadsheet tools detect the encoding.
body = '\ufeff' + '\r\n'.join(lines) + '\r\n'
filename = f'operation-logs-{document["exported_at"].replace(":", "")}.csv'
response = quart.Response(body, mimetype='text/csv', content_type='text/csv; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,7 @@ from langbot.pkg.entity.persistence import (
metadata, # noqa: F401
model, # noqa: F401
monitoring, # noqa: F401
operation_log, # noqa: F401
pipeline, # noqa: F401
plugin, # noqa: F401
rag, # noqa: F401
@@ -0,0 +1,135 @@
"""add workspace operation traceability table
Revision ID: 0033_operation_traceability
Revises: 0032_cert_artifact_digest
Create Date: 2026-09-25
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
import sqlalchemy as sa
from alembic import op
revision = '0033_operation_traceability'
down_revision = '0032_cert_artifact_digest'
branch_labels = None
depends_on = None
_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), '')"
def _quote(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, 'sqlite'), nullable=False),
sa.Column(
'workspace_uuid',
sa.String(36),
sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('actor_account_uuid', sa.String(36), nullable=True),
sa.Column('actor_name', sa.String(255), nullable=True),
sa.Column('actor_role', sa.String(32), nullable=True),
sa.Column('principal_type', sa.String(32), nullable=True),
sa.Column('api_key_uuid', sa.String(255), nullable=True),
sa.Column('auth_type', sa.String(32), nullable=True),
sa.Column('request_id', sa.String(128), nullable=True),
sa.Column('http_method', sa.String(12), nullable=True),
sa.Column('route', sa.String(512), nullable=True),
sa.Column('action', sa.String(64), nullable=True),
sa.Column('resource_type', sa.String(64), nullable=True),
sa.Column('resource_id', sa.String(255), nullable=True),
sa.Column('level', sa.Integer(), nullable=False, server_default='1'),
sa.Column('outcome', sa.String(16), nullable=False, server_default='ok'),
sa.Column('status_code', sa.Integer(), nullable=True),
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'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.CheckConstraint('level IN (0, 1, 2)', name='ck_workspace_operation_logs_level'),
sa.CheckConstraint(
"outcome IN ('ok', 'denied', 'error')",
name='ck_workspace_operation_logs_outcome',
),
)
for index_name, columns in _INDEXES:
op.create_index(index_name, _TABLE_NAME, columns, unique=False)
if conn.dialect.name != 'postgresql':
return
table = _quote(conn, _TABLE_NAME)
policy = _quote(conn, _POLICY_NAME)
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
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}'))
for index_name, _ in reversed(_INDEXES):
op.drop_index(index_name, table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
+1
View File
@@ -87,6 +87,7 @@ _ALEMBIC_TENANT_TABLES = {
'monitoring_errors',
'monitoring_embedding_calls',
'monitoring_feedback',
'workspace_operation_logs',
'langbot_vectors',
'directory_projection_states',
'directory_projection_inbox',
@@ -72,6 +72,9 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'monitoring_errors': 'workspace_uuid',
'monitoring_embedding_calls': 'workspace_uuid',
'monitoring_feedback': 'workspace_uuid',
# Administrative traceability for member and resource operations. Owned by
# the Workspace and therefore protected by the same RLS contract.
'workspace_operation_logs': 'workspace_uuid',
# Created by 0013 rather than ORM metadata; it is still part of the same
# business-database RLS contract and permits no discovery policies.
'langbot_vectors': 'workspace_uuid',
@@ -0,0 +1,277 @@
"""Integration tests for the operation-trace integrity cache.
These drive the real service against an in-memory SQLite engine so the whole
read path -- scan projection, chain linking, cache reuse and invalidation -- is
exercised end to end rather than in isolation. The integrity summary is called
directly so the hashing counts are not diluted by the page serializer, which
re-verifies only the handful of rows it returns.
"""
from __future__ import annotations
import datetime
import logging
from types import SimpleNamespace
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence import operation_log as operation_log_module
from langbot.pkg.operation_trace import service as service_module
pytestmark = pytest.mark.asyncio
WORKSPACE = '00000000-0000-0000-0000-00000000000a'
MODEL = operation_log_module.WorkspaceOperationLog
FILTERS = [MODEL.workspace_uuid == WORKSPACE]
class _PersistenceManager:
"""Minimal execute_async shim backed by one SQLite engine."""
def __init__(self, engine) -> None:
self.engine = engine
async def execute_async(self, *args, **kwargs):
async with self.engine.connect() as connection:
result = await connection.execute(*args, **kwargs)
await connection.commit()
return result
def _build_row(index: int, prev_hash: str | None, *, created_at: datetime.datetime | None = None) -> dict:
"""Build one append-only chain row, hash linked to ``prev_hash``."""
row: dict = {
'workspace_uuid': WORKSPACE,
'actor_account_uuid': None,
'actor_name': None,
'actor_role': None,
'principal_type': 'account',
'api_key_uuid': None,
'auth_type': 'user_token',
'http_method': 'GET',
'route': '/api/v1/pipelines',
'action': 'view',
'resource_type': 'pipeline',
'resource_id': None,
'level': 2,
'outcome': 'ok',
'status_code': 200,
'summary': f'row {index}',
'changes': None,
'client_ip': None,
'prev_hash': prev_hash,
}
if created_at is not None:
row['created_at'] = created_at
row['record_hash'] = service_module.compute_record_hash(row)
return row
async def _append(service, engine, count: int, *, prev_hash: str | None = None) -> list[int]:
"""Append ``count`` valid rows and return every row id in insertion order."""
for _ in range(count):
row = _build_row(0, prev_hash)
async with engine.begin() as connection:
await connection.execute(sqlalchemy.insert(MODEL).values(**row))
prev_hash = row['record_hash']
async with engine.begin() as connection:
result = await connection.execute(sqlalchemy.select(MODEL.id).order_by(MODEL.id.asc()))
return [row[0] for row in result.all()]
async def _tail_hash(engine) -> str | None:
async with engine.begin() as connection:
result = await connection.execute(sqlalchemy.select(MODEL.record_hash).order_by(MODEL.id.desc()).limit(1))
return result.scalar_one_or_none()
def _cached_views(service) -> list:
"""Return the cache keys currently held for the test Workspace."""
return [key for key in service._integrity_cache if key[0] == WORKSPACE]
def _counting_verifier(service, monkeypatch):
"""Replace the per-row verifier with one that records the ids it hashes."""
calls: list[int] = []
original = service_module.WorkspaceSettingsService._verify_hash_and_chain
def counting(row, previous_row):
calls.append(row.id)
return original(row, previous_row)
monkeypatch.setattr(service, '_verify_hash_and_chain', counting)
return calls
@pytest.fixture
async def trace_env(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "trace.db"}')
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
application = SimpleNamespace(
persistence_mgr=_PersistenceManager(engine),
logger=logging.getLogger('operation-trace-test'),
)
try:
yield service_module.WorkspaceSettingsService(application), engine
finally:
await engine.dispose()
async def test_cached_read_within_ttl_reuses_without_rehashing(trace_env, monkeypatch):
service, engine = trace_env
ids = await _append(service, engine, 5)
calls = _counting_verifier(service, monkeypatch)
first = await service._integrity_summary(MODEL, FILTERS)
assert first['summary']['scanned'] == 5
assert first['summary']['tampered'] == 0
assert len(calls) == 5
views = _cached_views(service)
assert len(views) == 1
assert set(service._integrity_cache[views[0]]['verified']) == set(ids)
# A warm read inside the TTL must not re-hash anything at all: this is the
# shield that makes a burst of panel opens, refreshes and page turns cheap.
calls.clear()
second = await service._integrity_summary(MODEL, FILTERS)
assert second['summary']['scanned'] == 5
assert calls == []
async def test_cache_miss_reverifies_every_row(trace_env, monkeypatch):
service, engine = trace_env
await _append(service, engine, 6)
# A zero TTL forces every read past the cache short circuit.
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
await service._integrity_summary(MODEL, FILTERS)
calls = _counting_verifier(service, monkeypatch)
await service._integrity_summary(MODEL, FILTERS)
# Every row is re-verified on each miss. Skipping previously verified ids
# would make an edit to an older row permanently invisible.
assert len(calls) == 6
async def test_edit_to_already_verified_row_is_re_detected(trace_env, monkeypatch):
service, engine = trace_env
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
ids = await _append(service, engine, 5)
clean = await service._integrity_summary(MODEL, FILTERS)
assert clean['summary']['tampered'] == 0
# Edit a row that was already verified on the previous pass, without
# re-signing it: the whole point of tamper evidence is to catch this.
async with engine.begin() as connection:
await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='EDITED-OLD'))
after = await service._integrity_summary(MODEL, FILTERS)
assert after['summary']['integrity_failed'] == 1
assert after['integrity_failed_ids'] == [ids[1]]
async def test_content_edit_is_flagged_as_hash_mismatch(trace_env, monkeypatch):
service, engine = trace_env
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
ids = await _append(service, engine, 4)
async with engine.begin() as connection:
await connection.execute(sqlalchemy.update(MODEL).where(MODEL.id == ids[1]).values(summary='silently edited'))
result = await service.query_logs(WORKSPACE)
assert result['integrity_failed_count'] == 1
assert result['chain_failed_count'] == 0
assert result['tampered_count'] == 1
async def test_resigned_link_is_flagged_as_chain_break(trace_env, monkeypatch):
service, engine = trace_env
monkeypatch.setattr(service_module, 'INTEGRITY_CACHE_TTL_SECONDS', 0.0)
ids = await _append(service, engine, 4)
# Re-sign the row from its own content with a forged prev_hash, so its own
# hash still matches and only the link is wrong. The successor's prev_hash
# no longer points at this row's record_hash either, so both are flagged.
async with engine.begin() as connection:
result = await connection.execute(
sqlalchemy.select(*[getattr(MODEL, field) for field in service_module._HASH_FIELDS]).where(
MODEL.id == ids[2]
)
)
content = dict(zip(service_module._HASH_FIELDS, result.first()))
content['prev_hash'] = 'not-the-predecessor-hash'
forged_hash = service_module.compute_record_hash(content)
assert service_module.verify_record_hash(content, forged_hash)
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(MODEL)
.where(MODEL.id == ids[2])
.values(prev_hash=content['prev_hash'], record_hash=forged_hash)
)
result = await service._integrity_summary(MODEL, FILTERS)
assert result['summary']['integrity_failed'] == 0
assert result['summary']['chain_failed'] == 2 # the row and its successor
# Ids come back newest-first; compare as a set to stay order-agnostic.
assert set(result['chain_failed_ids']) == {ids[2], ids[3]}
async def test_age_prune_drops_the_stale_integrity_cache(trace_env):
service, engine = trace_env
await _append(service, engine, 2)
old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
async with engine.begin() as connection:
await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
# Warm the cache: its verified map now records the row retention will drop.
await service.query_logs(WORKSPACE)
assert _cached_views(service)
await service.prune(WORKSPACE, retention_days=1)
# Deleting the oldest rows invalidated the cached prefix.
assert _cached_views(service) == []
assert 'expired' in (await service.prune(WORKSPACE, retention_days=1))
async def test_each_listing_view_is_cached_independently(trace_env, monkeypatch):
service, engine = trace_env
await _append(service, engine, 4)
# Two different listing filters must not collide: each gets its own entry,
# and a view that was never scanned must still be verified from scratch.
all_view = [MODEL.workspace_uuid == WORKSPACE]
mutation_view = [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1]
await service._integrity_summary(MODEL, all_view)
await service._integrity_summary(MODEL, mutation_view)
views = _cached_views(service)
assert len(views) == 2
assert len({key[1] for key in views}) == 2
async def test_invalidation_clears_every_cached_view(trace_env):
service, engine = trace_env
await _append(service, engine, 2)
old_row = _build_row(99, await _tail_hash(engine), created_at=datetime.datetime(2020, 1, 1))
async with engine.begin() as connection:
await connection.execute(sqlalchemy.insert(MODEL).values(**old_row))
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE])
await service._integrity_summary(MODEL, [MODEL.workspace_uuid == WORKSPACE, MODEL.level == 1])
assert len(_cached_views(service)) == 2
await service.prune(WORKSPACE, retention_days=1)
# A deletion invalidates *all* views for the Workspace, not just one.
assert _cached_views(service) == []
@@ -0,0 +1,131 @@
"""Unit tests for the operation-trace integrity scan optimizations.
The read path must stay fast while proving a record is untampered. These tests
pin the two invariants that make that safe:
* the integrity scan projects only the hash columns (never the whole row), and
* the lightweight verifier returns exactly what the full serializer would, so
the two verification paths can never drift apart.
"""
from __future__ import annotations
import types
from langbot.pkg.entity.persistence import operation_log as persistence_operation_log
from langbot.pkg.operation_trace import service as operation_trace_service
def _make_row(**overrides: object) -> types.SimpleNamespace:
"""Build a row exposing every column the verifier and serializer touch."""
fields: dict[str, object] = {
'id': 1,
'workspace_uuid': 'ws-1',
'actor_account_uuid': 'acc-1',
'actor_name': 'Ada',
'actor_role': 'owner',
'principal_type': 'account',
'api_key_uuid': None,
'auth_type': 'user_token',
'request_id': 'req-1',
'http_method': 'PUT',
'route': '/api/v1/settings/governance',
'action': 'settings_update',
'resource_type': 'workspace_settings',
'resource_id': None,
'level': 1,
'outcome': 'ok',
'status_code': 200,
'summary': 'level: 0 → 1',
'changes': '[{"field": "level", "before": 0, "after": 1}]',
'detail': '{}',
'client_ip': '127.0.0.1',
'user_agent': 'pytest',
'duration_ms': 3,
'prev_hash': None,
'record_hash': None,
'created_at': None,
}
fields.update(overrides)
row = types.SimpleNamespace(**fields)
if row.record_hash is None:
payload = {field: getattr(row, field) for field in operation_trace_service._HASH_FIELDS}
row.record_hash = operation_trace_service.compute_record_hash(payload)
return row
class TestIntegrityScanProjection:
"""The scan must not load payload columns it never inspects."""
def test_projection_selects_exactly_the_hash_columns(self):
model = persistence_operation_log.WorkspaceOperationLog
statement = operation_trace_service._integrity_scan_select(model)
keys = {column.key for column in statement.selected_columns}
expected = {'id', 'record_hash', *operation_trace_service._HASH_FIELDS}
assert keys == expected
def test_projection_skips_expensive_payload_columns(self):
model = persistence_operation_log.WorkspaceOperationLog
statement = operation_trace_service._integrity_scan_select(model)
keys = {column.key for column in statement.selected_columns}
# ``detail`` and the client fingerprint are never hashed, so the cold
# scan must not page them in for up to MAX_INTEGRITY_SCAN_ROWS rows.
assert 'detail' not in keys
assert 'user_agent' not in keys
assert 'duration_ms' not in keys
# ``changes`` IS part of the hash, so it must remain selected.
assert 'changes' in keys
class TestLightweightVerifier:
"""The fast verifier and the full serializer must agree byte for byte."""
def test_verifier_matches_serializer_for_a_valid_row(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
previous = _make_row(id=1)
row = _make_row(id=2, prev_hash=previous.record_hash)
fast = service._verify_hash_and_chain(row, previous)
serialized = service._serialize_log(row, previous_row=previous)
assert fast == (serialized['integrity_ok'], serialized['chain_ok'])
assert fast == (True, True)
def test_verifier_detects_a_content_edit(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
row = _make_row(id=2)
# Simulate a silent edit after the hash was computed.
row.summary = 'tampered summary'
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
assert integrity_ok is False
assert chain_ok is True
def test_verifier_detects_a_broken_chain_link(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
previous = _make_row(id=1)
row = _make_row(id=2, prev_hash='not-the-previous-hash')
integrity_ok, chain_ok = service._verify_hash_and_chain(row, previous)
# The row content itself is intact; only the link is wrong.
assert integrity_ok is True
assert chain_ok is False
def test_missing_baseline_leaves_chain_ok(self):
service = operation_trace_service.WorkspaceSettingsService.__new__(
operation_trace_service.WorkspaceSettingsService
)
row = _make_row(id=1, prev_hash=None)
integrity_ok, chain_ok = service._verify_hash_and_chain(row, None)
assert integrity_ok is True
assert chain_ok is True
@@ -45,7 +45,9 @@ export default function AssistantToolResult({
? result
: Array.isArray(data.items)
? data.items
: null;
: Array.isArray(data.records)
? data.records
: null;
const total = typeof data.total === 'number' ? data.total : items?.length;
const kind = tool?.arguments.kind;
const label =
@@ -142,7 +144,13 @@ export default function AssistantToolResult({
: {};
return (
<li key={index} className="truncate">
{String(entry.name || entry.uuid || '—')}
{String(
entry.name ||
entry.summary ||
entry.action ||
entry.uuid ||
'—',
)}
</li>
);
})}
@@ -32,6 +32,7 @@ import {
Github,
Zap,
FilePlus2,
History,
Sparkles,
Server,
Puzzle,
@@ -2220,6 +2221,19 @@ export default function HomeSidebar({
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Operation traceability is an audit surface: only roles holding
audit.view (owner / admin) get a direct sidebar entry. */}
{currentWorkspace?.permissions.includes('audit.view') && (
<SidebarMenuItem data-sidebar-guide="operation-trace">
<SidebarMenuButton
onClick={() => openSettings('operationTrace')}
tooltip={t('operationTrace.title')}
>
<History className="size-4 text-blue-500" />
<span>{t('operationTrace.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
{/* API-key management is available only to authorized Workspace roles. */}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
HardDrive,
History,
KeyRound,
Settings,
Sparkles,
@@ -29,12 +30,14 @@ import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/Ap
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
import OperationTracePanel from '@/app/home/components/workspace-settings/OperationTracePanel';
import { useCurrentWorkspace } from '@/app/infra/http';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'workspace'
| 'operationTrace'
| 'account'
| 'apiIntegration'
| 'models'
@@ -45,6 +48,7 @@ export type SettingsSection =
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
workspace: 'showWorkspaceSettings',
operationTrace: 'showOperationTrace',
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
@@ -101,6 +105,13 @@ export default function SettingsDialog({
description: t('workspace.description'),
icon: <UsersRound className="size-4" />,
},
{
id: 'operationTrace',
label: t('settingsDialog.nav.operationTrace'),
title: t('operationTrace.title'),
description: t('operationTrace.description'),
icon: <History className="size-4" />,
},
{
id: 'models',
label: t('settingsDialog.nav.models'),
@@ -135,6 +146,9 @@ export default function SettingsDialog({
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
// Operation traceability is an audit surface: it is limited to the roles
// that also hold the audit permission (owner / admin).
const canViewOperationTrace = canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
@@ -142,19 +156,24 @@ export default function SettingsDialog({
if (item.id === 'storageAnalysis') {
return canViewStorageAnalysis;
}
if (item.id === 'operationTrace') {
return canViewOperationTrace;
}
return true;
});
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
(section === 'storageAnalysis' && !canViewStorageAnalysis) ||
(section === 'operationTrace' && !canViewOperationTrace);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [
canManageApiKeys,
canViewStorageAnalysis,
canViewOperationTrace,
open,
section,
onSectionChange,
@@ -253,6 +272,11 @@ export default function SettingsDialog({
active={open && section === 'workspace'}
/>
)}
{section === 'operationTrace' && canViewOperationTrace && (
<OperationTracePanel
active={open && section === 'operationTrace'}
/>
)}
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
/**
* Types for the operation traceability surface.
*
* The backend stores one append-only row per traced operation and answers
* "what was changed into what" through the ``changes`` diff. All
* human-readable strings arrive as i18n keys so the panel owns the copy.
*/
/** Capture levels, ordered by increasing scope. */
export type OperationLevel = 0 | 1 | 2;
export interface OperationLevelOption {
level: OperationLevel;
/** Frontend translation key, e.g. ``operationTrace.levels.read``. */
i18n_key: string;
}
export interface OperationLogLimits {
min_retention_days: number;
max_retention_days: number;
min_max_rows: number;
max_max_rows: number;
min_dedupe_window_seconds: number;
max_dedupe_window_seconds: number;
}
export interface OperationGovernance {
configured_level: OperationLevel;
configured_level_name: string;
retention_days: number;
max_rows: number;
/** Seconds during which an identical observation is collapsed into one row. */
dedupe_window_seconds: number;
supported_levels: OperationLevelOption[];
limits: OperationLogLimits;
}
export interface OperationChangeField {
field: string;
before: unknown;
after: unknown;
}
export interface OperationLogRecord {
id: number;
/** Whether the stored hash still matches the row content. */
integrity_ok: boolean;
/** Whether this row still links to its predecessor correctly. */
chain_ok: boolean;
/** Either check failed: the card is flagged as possibly tampered. */
tampered: boolean;
record_hash: string | null;
actor_account_uuid: string | null;
actor_name: string | null;
actor_role: string | null;
http_method: string | null;
action: string | null;
/** Translation key resolved by the panel, e.g. ``operationTrace.actions.view``. */
action_i18n_key: string;
resource_type: string | null;
resource_id: string | null;
/** Registered Core route identity, e.g. ``/api/v1/settings/governance``. */
route: string | null;
level: OperationLevel;
level_name: string;
outcome: 'ok' | 'denied' | 'error';
status_code: number | null;
summary: string | null;
changes: OperationChangeField[];
duration_ms: number;
created_at: string | null;
}
/** Verification filter accepted by the listing endpoint. */
export type OperationIntegrityFilter =
| 'all'
| 'issues'
| 'hash_mismatch'
| 'chain_broken';
export interface OperationLogPage {
records: OperationLogRecord[];
/** Records matching the filters and the active verification filter. */
total: number;
limit: number;
offset: number;
/**
* Workspace-wide counters over the filtered history, not just this page.
* They stay constant while paging so an operator opening page 2 still sees
* the total number of tampered records.
*/
tampered_count: number;
/** Records whose stored hash no longer matches their content. */
integrity_failed_count: number;
/** Records whose predecessor link is broken. */
chain_failed_count: number;
/** How many records the verification scan actually inspected. */
scanned_count: number;
/** The scan stopped at the row cap, so the counters are approximate. */
scan_truncated: boolean;
/** Which verification filter produced this page. */
integrity_filter: OperationIntegrityFilter;
}
export interface OperationLogFilters {
actions: string[];
resource_types: string[];
actors: { account_uuid: string; name: string }[];
}
export interface OperationLogQuery {
limit?: number;
offset?: number;
action?: string;
resource_type?: string;
actor?: string;
level?: OperationLevel;
since?: string;
until?: string;
/** Narrow the listing to records that failed verification. */
integrity?: OperationIntegrityFilter;
}
+56
View File
@@ -89,6 +89,13 @@ import type {
WorkspaceRole,
WorkspaceSpaceBilling,
} from '@/app/infra/entities/workspace';
import type {
OperationGovernance,
OperationLevel,
OperationLogFilters,
OperationLogPage,
OperationLogQuery,
} from '@/app/infra/entities/operation-log';
/**
* 后端服务客户端
@@ -1824,6 +1831,55 @@ export class BackendClient extends BaseHttpClient {
);
}
// ============ Workspace governance / operation traceability ============
public getOperationGovernance(): Promise<OperationGovernance> {
return this.get('/api/v1/settings/governance');
}
public updateOperationGovernance(request: {
level: OperationLevel;
retention_days?: number;
max_rows?: number;
dedupe_window_seconds?: number;
}): Promise<OperationGovernance> {
return this.put('/api/v1/settings/governance', request);
}
public getOperationLogs(
query: OperationLogQuery = {},
): Promise<OperationLogPage> {
return this.get('/api/v1/settings/operation-logs', query);
}
public getOperationLogFilters(): Promise<OperationLogFilters> {
return this.get('/api/v1/settings/operation-logs/filters');
}
/**
* Build the export URL so the browser can download the CSV attachment
* directly; axios would try to parse the response body as JSON.
*/
public buildOperationLogExportURL(query: OperationLogQuery = {}): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
const suffix = params.toString();
// A base of "/" means "same origin". Appending the path directly produced
// a protocol-relative "//api/..." URL, which a browser reads as the host
// "api" — the export request then never reached the backend. Resolve the
// base to the current origin first, mirroring the other URL builders here.
const apiBase =
this.instance.defaults.baseURL === '/' || !this.instance.defaults.baseURL
? window.location.origin
: this.instance.defaults.baseURL.replace(/\/$/, '');
return `${apiBase}/api/v1/settings/operation-logs/export${
suffix ? `?${suffix}` : ''
}`;
}
public setPassword(
newPassword: string,
currentPassword?: string,
+139 -8
View File
@@ -1,5 +1,4 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
assistant: {
modelHint: 'Switch assistant model for your next message',
@@ -22,6 +21,7 @@ const enUS = {
create_knowledge_base: 'Create knowledge base',
get_pipeline: 'Read Pipeline',
get_knowledge_schema: 'Read knowledge engine schema',
list_operation_logs: 'Read operation trace',
},
resources: {
models: 'Find chat models',
@@ -99,7 +99,6 @@ const enUS = {
guidedTour: {
eventDebugDescription:
'Choose a supported event, enter test data, and review the output and tool results. Platform actions are simulated; models and other tools run as configured.',
pipeline: {
trigger: {
title: 'Choose which messages to answer',
@@ -727,7 +726,6 @@ const enUS = {
internalErrorHint:
'An unexpected error occurred. Check the backend logs using the reference below.',
errorReference: 'Error reference: {{id}}',
title: 'Bots',
description:
'Create and manage bots, which are the entry points for LangBot to connect with various platforms',
@@ -1127,13 +1125,11 @@ const enUS = {
pluginSettings: 'Plugin settings',
pluginSettingsDescription: 'Parameters declared by this plugin.',
selectToDebug: 'Select a plugin above to start debugging.',
debugOutput: 'Processor output',
debugDescription:
'Input events, plugin logs and action results for this test session.',
debugNotice:
'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.',
create: 'Create plugin processor',
type: 'Plugin processor',
description:
@@ -1170,7 +1166,6 @@ const enUS = {
debugData: {
chatId: 'Chat ID',
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
title: 'Event data',
form: 'Common fields',
json: 'Full JSON',
@@ -1633,7 +1628,6 @@ const enUS = {
runnerUsageAll: 'All',
runnerUsageAgent: 'Agent / Pipeline',
runnerUsageEvent: 'Plugin processor',
searchPlaceholder: 'Search plugins...',
searchPlaceholderCount:
'Search {{count}} extensions, capabilities, or use cases...',
@@ -2719,12 +2713,150 @@ const enUS = {
title: 'Settings',
nav: {
workspace: 'Workspace',
operationTrace: 'Traceability',
models: 'Models',
api: 'API',
storage: 'Storage',
account: 'Account',
},
},
operationTrace: {
title: 'Operation traceability',
description: 'Trace admin and owner edits and views, and what changed.',
captureLevel: 'Capture level',
retention: 'Retention',
retentionSummary: 'Keep {{days}} days · up to {{rows}} records',
retentionDays: 'Retention days',
maxRows: 'Max records',
dedupeWindow: 'Dedupe window (s)',
records: 'Operation records',
changesCount: '{{count}} change(s)',
hideDetails: 'Hide details',
levelUpdated: 'Capture level updated',
levelUpdateFailed: 'Failed to update capture level',
retentionUpdated: 'Retention updated',
retentionUpdateFailed: 'Failed to update retention',
export: 'Download logs',
exportFailed: 'Failed to download operation logs',
empty: 'No operation records yet',
systemActor: 'System',
pageInfo: '{{from}}-{{to}} of {{total}}',
previousPage: 'Previous page',
nextPage: 'Next page',
filterAction: 'Action',
filterAllActions: 'All actions',
filterResource: 'Resource',
filterAllResources: 'All resources',
filterActor: 'Actor',
filterAllActors: 'All actors',
loadFailed: 'Failed to load traceability data',
refresh: 'Refresh',
save: 'Save',
redacted: 'Redacted',
tamperedBadge: 'Possibly tampered',
verifiedBadge: 'Verified',
integrityFailedCount: '{{count}} hash mismatches',
chainFailedCount: '{{count}} broken links',
scanTruncated: 'Only the latest {{count}} verified',
emptyFiltered: 'No records need verification under the current filters',
mutationsOnly: 'Changes only',
today: 'Today',
yesterday: 'Yesterday',
unknownDay: 'Unknown time',
showDetails: 'Details',
moreChanges: '+{{count}} more',
elidedValue: '{{count}} chars',
levels: {
off: {
label: 'Tracing off',
},
mutation: {
label: 'Mutations only',
},
read: {
label: 'Everything (incl. views)',
},
},
levelNames: {
off: 'Off',
mutation: 'Mutations',
read: 'Everything',
},
resourceTypes: {
bot: 'Bot',
adapter: 'Adapter',
model_provider: 'Model provider',
llm_model: 'Model',
pipeline: 'Pipeline',
user: 'User',
workspace: 'Workspace',
monitoring: 'Monitoring',
webhook: 'Webhook',
api_key: 'API key',
workspace_settings: 'Workspace settings',
member: 'Member',
member_invitation: 'Member invitation',
operation_log: 'Operation log',
assistant_conversation: 'Assistant conversation',
agent: 'Agent',
file: 'File',
plugin: 'Extension',
plugin_page: 'Extension page',
skill: 'Skill',
knowledge_base: 'Knowledge base',
mcp_server: 'MCP server',
runtime: 'Runtime',
system: 'System',
resource: 'Resource',
},
outcomes: {
ok: 'Succeeded',
denied: 'Denied',
error: 'Failed',
},
actions: {
audit_log_view: 'View operation logs',
settings_update: 'Update tracing settings',
settings_view: 'View tracing settings',
member_invite: 'Invite member',
member_role_update: 'Update member role',
member_remove: 'Remove member',
member_view: 'View members',
plugin_view: 'View extension pages',
plugin_config: 'Change extension config',
plugin_install: 'Install extension',
plugin_uninstall: 'Uninstall extension',
plugin_upgrade: 'Upgrade extension',
page_view: 'View extension page',
skill_view: 'View skill',
skill_install: 'Install skill',
skill_uninstall: 'Uninstall skill',
knowledge_base_view: 'View knowledge base',
knowledge_base_update: 'Change knowledge base',
knowledge_base_delete: 'Delete knowledge base',
mcp_view: 'View MCP server',
mcp_config: 'Change MCP server',
mcp_delete: 'Delete MCP server',
skill_update: 'Modify skill',
file_view: 'View file',
ingest: 'Ingest data',
file_delete: 'Delete knowledge base file',
ingress: 'External message ingress',
codex_view: 'View Codex authorization',
codex_authorize: 'Authorize Codex',
embed: 'Embedded session',
pipeline_extensions_update: 'Update pipeline extension bindings',
export: 'Export data',
execute: 'Execute task',
debug: 'Debug task',
publish: 'Publish resource',
create: 'Create resource',
update: 'Update resource',
delete: 'Delete resource',
view: 'View resource',
probe: 'Probe endpoint',
},
},
storageAnalysis: {
title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates',
@@ -3182,5 +3314,4 @@ const enUS = {
invalidPage: 'Invalid plugin page',
},
};
export default enUS;
+140
View File
@@ -21,6 +21,7 @@ const esES = {
create_knowledge_base: 'Crear base de conocimiento',
get_pipeline: 'Consultar Pipeline',
get_knowledge_schema: 'Consultar el esquema del motor de conocimiento',
list_operation_logs: 'Consultar la trazabilidad de operaciones',
},
resources: {
models: 'Buscar modelos de chat',
@@ -2767,12 +2768,151 @@ const esES = {
title: 'Configuración',
nav: {
workspace: 'Workspace',
operationTrace: 'Trazabilidad',
models: 'Modelos',
api: 'API',
storage: 'Almacenamiento',
account: 'Cuenta',
},
},
operationTrace: {
title: 'Trazabilidad de operaciones',
description: 'Rastrea cambios y consultas de los administradores',
captureLevel: 'Nivel de captura',
retention: 'Retención',
retentionSummary: 'Conservar {{days}} días · hasta {{rows}} registros',
retentionDays: 'Días de retención',
maxRows: 'Registros máximos',
dedupeWindow: 'Ventana de deduplicación (s)',
records: 'Registros de operación',
changesCount: '{{count}} cambio(s)',
hideDetails: 'Ocultar detalles',
levelUpdated: 'Nivel de captura actualizado',
levelUpdateFailed: 'No se pudo actualizar el nivel de captura',
retentionUpdated: 'Retención actualizada',
retentionUpdateFailed: 'No se pudo actualizar la retención',
export: 'Descargar registros',
exportFailed: 'No se pudieron descargar los registros',
empty: 'Aún no hay registros de operación',
systemActor: 'Sistema',
pageInfo: '{{from}}-{{to}} de {{total}}',
previousPage: 'Página anterior',
nextPage: 'Página siguiente',
filterAction: 'Acción',
filterAllActions: 'Todas las acciones',
filterResource: 'Recurso',
filterAllResources: 'Todos los recursos',
filterActor: 'Actor',
filterAllActors: 'Todos los actores',
loadFailed: 'No se pudieron cargar los datos de trazabilidad',
refresh: 'Actualizar',
save: 'Guardar',
redacted: 'Oculto',
tamperedBadge: 'Posible manipulación',
verifiedBadge: 'Verificado',
integrityFailedCount: '{{count}} hashes no coinciden',
chainFailedCount: '{{count}} enlaces rotos',
scanTruncated: 'Solo se verifican los últimos {{count}}',
emptyFiltered: 'Ningún registro requiere verificación',
mutationsOnly: 'Solo cambios',
today: 'Hoy',
yesterday: 'Ayer',
unknownDay: 'Fecha desconocida',
showDetails: 'Detalles',
moreChanges: '+{{count}} más',
elidedValue: '{{count}} caracteres',
levels: {
off: {
label: 'Sin trazabilidad',
},
mutation: {
label: 'Solo cambios',
},
read: {
label: 'Todo (incl. consultas)',
},
},
levelNames: {
off: 'Desactivado',
mutation: 'Solo cambios',
read: 'Todo',
},
resourceTypes: {
bot: 'Bot',
adapter: 'Adaptador',
model_provider: 'Proveedor de modelos',
llm_model: 'Modelo',
pipeline: 'Pipeline',
user: 'Usuario',
workspace: 'Espacio de trabajo',
monitoring: 'Monitorización',
webhook: 'Webhook',
api_key: 'Clave de API',
workspace_settings: 'Ajustes del espacio de trabajo',
member: 'Miembro',
member_invitation: 'Invitación de miembro',
operation_log: 'Registro de operaciones',
assistant_conversation: 'Conversación del asistente',
agent: 'Agente',
file: 'Archivo',
plugin: 'Extensión',
plugin_page: 'Página de extensión',
skill: 'Habilidad',
knowledge_base: 'Base de conocimiento',
mcp_server: 'Servidor MCP',
runtime: 'Entorno de ejecución',
system: 'Sistema',
resource: 'Recurso',
},
outcomes: {
ok: 'Correcto',
denied: 'Denegado',
error: 'Fallido',
},
actions: {
audit_log_view: 'Ver registros de operación',
settings_update: 'Actualizar ajustes de trazabilidad',
settings_view: 'Ver ajustes de trazabilidad',
member_invite: 'Invitar miembro',
member_role_update: 'Actualizar rol del miembro',
member_remove: 'Eliminar miembro',
member_view: 'Ver miembros',
plugin_view: 'Ver páginas de extensión',
plugin_config: 'Cambiar configuración de extensión',
plugin_install: 'Instalar extensión',
plugin_uninstall: 'Desinstalar extensión',
plugin_upgrade: 'Actualizar extensión',
page_view: 'Ver página de extensión',
skill_view: 'Ver habilidad',
skill_install: 'Instalar habilidad',
skill_uninstall: 'Desinstalar habilidad',
knowledge_base_view: 'Ver base de conocimiento',
knowledge_base_update: 'Cambiar base de conocimiento',
knowledge_base_delete: 'Eliminar base de conocimiento',
mcp_view: 'Ver servidor MCP',
mcp_config: 'Cambiar servidor MCP',
mcp_delete: 'Eliminar servidor MCP',
skill_update: 'Modificar habilidad',
file_view: 'Ver archivo',
ingest: 'Importar datos',
file_delete: 'Eliminar archivo de la base de conocimiento',
ingress: 'Entrada de mensajes externos',
codex_view: 'Ver autorización de Codex',
codex_authorize: 'Autorizar Codex',
embed: 'Sesión incrustada',
pipeline_extensions_update:
'Actualizar vinculaciones de extensiones del pipeline',
export: 'Exportar datos',
execute: 'Ejecutar tarea',
debug: 'Depurar tarea',
publish: 'Publicar recurso',
create: 'Crear recurso',
update: 'Actualizar recurso',
delete: 'Eliminar recurso',
view: 'Ver recurso',
probe: 'Sondear endpoint',
},
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:
+139
View File
@@ -21,6 +21,7 @@ const jaJP = {
create_knowledge_base: 'ナレッジベース作成',
get_pipeline: 'Pipeline 参照',
get_knowledge_schema: 'エンジン設定の参照',
list_operation_logs: '操作トレーサビリティの参照',
},
resources: {
models: 'チャットモデル検索',
@@ -2730,12 +2731,150 @@ const jaJP = {
title: '設定',
nav: {
workspace: 'ワークスペース',
operationTrace: '操作トレーサビリティ',
models: 'モデル',
api: 'API',
storage: 'ストレージ',
account: 'アカウント',
},
},
operationTrace: {
title: '操作トレーサビリティ',
description: '管理者とオーナーの操作を追跡します。',
captureLevel: 'キャプチャレベル',
retention: '保持ポリシー',
retentionSummary: '{{days}} 日間保持 · 最大 {{rows}} 件',
retentionDays: '保持日数',
maxRows: '最大レコード数',
dedupeWindow: '重複排除ウィンドウ(秒)',
records: '操作レコード',
changesCount: '変更 {{count}} 件',
hideDetails: '詳細を隠す',
levelUpdated: 'キャプチャレベルを更新しました',
levelUpdateFailed: 'キャプチャレベルの更新に失敗しました',
retentionUpdated: '保持ポリシーを更新しました',
retentionUpdateFailed: '保持ポリシーの更新に失敗しました',
export: 'ログをダウンロード',
exportFailed: '操作ログのダウンロードに失敗しました',
empty: '操作レコードはまだありません',
systemActor: 'システム',
pageInfo: '{{from}}-{{to}} / 全 {{total}} 件',
previousPage: '前のページ',
nextPage: '次のページ',
filterAction: '操作',
filterAllActions: 'すべての操作',
filterResource: 'リソース',
filterAllResources: 'すべてのリソース',
filterActor: '実行者',
filterAllActors: 'すべての実行者',
loadFailed: 'トレーサビリティデータの読み込みに失敗しました',
refresh: '更新',
save: '保存',
redacted: 'マスク済み',
tamperedBadge: '改ざんの可能性',
verifiedBadge: '検証済み',
integrityFailedCount: '{{count}} 件のハッシュ不一致',
chainFailedCount: '{{count}} 件のリンク断絶',
scanTruncated: '最新 {{count}} 件のみ検証',
emptyFiltered: '現在のフィルターでは検証が必要な記録はありません',
mutationsOnly: '変更のみ',
today: '今日',
yesterday: '昨日',
unknownDay: '不明な日時',
showDetails: '詳細',
moreChanges: '他 {{count}} 件',
elidedValue: '{{count}} 文字',
levels: {
off: {
label: 'トレース無効',
},
mutation: {
label: '変更のみ',
},
read: {
label: '最上位(閲覧含む)',
},
},
levelNames: {
off: '無効',
mutation: '変更のみ',
read: '最上位',
},
resourceTypes: {
bot: 'ボット',
adapter: 'アダプター',
model_provider: 'モデルプロバイダー',
llm_model: 'モデル',
pipeline: 'パイプライン',
user: 'ユーザー',
workspace: 'ワークスペース',
monitoring: 'モニタリング',
webhook: 'Webhook',
api_key: 'API キー',
workspace_settings: 'ワークスペース設定',
member: 'メンバー',
member_invitation: 'メンバー招待',
operation_log: '操作ログ',
assistant_conversation: 'アシスタント会話',
agent: 'エージェント',
file: 'ファイル',
plugin: '拡張機能',
plugin_page: '拡張ページ',
skill: 'スキル',
knowledge_base: 'ナレッジベース',
mcp_server: 'MCP サーバー',
runtime: 'ランタイム',
system: 'システム',
resource: 'リソース',
},
outcomes: {
ok: '成功',
denied: '拒否',
error: '失敗',
},
actions: {
audit_log_view: '操作ログを閲覧',
settings_update: 'トレーサビリティ設定を変更',
settings_view: 'トレーサビリティ設定を閲覧',
member_invite: 'メンバーを招待',
member_role_update: 'メンバーのロールを変更',
member_remove: 'メンバーを削除',
member_view: 'メンバーを閲覧',
plugin_view: '拡張ページを閲覧',
plugin_config: '拡張設定を変更',
plugin_install: '拡張をインストール',
plugin_uninstall: '拡張をアンインストール',
plugin_upgrade: '拡張をアップグレード',
page_view: '拡張ページを閲覧',
skill_view: 'スキルを閲覧',
skill_install: 'スキルをインストール',
skill_uninstall: 'スキルをアンインストール',
knowledge_base_view: 'ナレッジベースを閲覧',
knowledge_base_update: 'ナレッジベースを変更',
knowledge_base_delete: 'ナレッジベースを削除',
mcp_view: 'MCP サーバーを閲覧',
mcp_config: 'MCP サーバーを変更',
mcp_delete: 'MCP サーバーを削除',
skill_update: 'スキルを変更',
file_view: 'ファイルを表示',
ingest: 'データを取り込み',
file_delete: 'ナレッジベースのファイルを削除',
ingress: '外部メッセージ受信',
codex_view: 'Codex 認証を表示',
codex_authorize: 'Codex を認証',
embed: '埋め込みセッション',
pipeline_extensions_update: 'パイプライン拡張の紐付けを変更',
export: 'データをエクスポート',
execute: 'タスクを実行',
debug: 'タスクをデバッグ',
publish: 'リソースを公開',
create: 'リソースを作成',
update: 'リソースを変更',
delete: 'リソースを削除',
view: 'リソースを閲覧',
probe: 'エンドポイントを確認',
},
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',
+139
View File
@@ -21,6 +21,7 @@ const ruRU = {
create_knowledge_base: 'Создать базу знаний',
get_pipeline: 'Посмотреть Pipeline',
get_knowledge_schema: 'Посмотреть схему движка знаний',
list_operation_logs: 'Просмотр трассировки операций',
},
resources: {
models: 'Найти чат-модели',
@@ -2737,12 +2738,150 @@ const ruRU = {
title: 'Настройки',
nav: {
workspace: 'Workspace',
operationTrace: 'Трассировка',
models: 'Модели',
api: 'API',
storage: 'Хранилище',
account: 'Аккаунт',
},
},
operationTrace: {
title: 'Трассировка операций',
description: 'Отслеживание изменений и просмотров администраторов',
captureLevel: 'Уровень записи',
retention: 'Хранение',
retentionSummary: 'Хранить {{days}} дн. · до {{rows}} записей',
retentionDays: 'Дней хранения',
maxRows: 'Максимум записей',
dedupeWindow: 'Окно дедупликации (с)',
records: 'Записи операций',
changesCount: 'Изменений: {{count}}',
hideDetails: 'Скрыть детали',
levelUpdated: 'Уровень записи обновлён',
levelUpdateFailed: 'Не удалось обновить уровень записи',
retentionUpdated: 'Хранение обновлено',
retentionUpdateFailed: 'Не удалось обновить хранение',
export: 'Скачать журнал',
exportFailed: 'Не удалось скачать журнал операций',
empty: 'Записей операций пока нет',
systemActor: 'Система',
pageInfo: '{{from}}-{{to}} из {{total}}',
previousPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
filterAction: 'Действие',
filterAllActions: 'Все действия',
filterResource: 'Ресурс',
filterAllResources: 'Все ресурсы',
filterActor: 'Исполнитель',
filterAllActors: 'Все исполнители',
loadFailed: 'Не удалось загрузить данные трассировки',
refresh: 'Обновить',
save: 'Сохранить',
redacted: 'Скрыто',
tamperedBadge: 'Возможна подмена',
verifiedBadge: 'Проверено',
integrityFailedCount: 'Несовпадение хэша: {{count}}',
chainFailedCount: 'Разрыв связи: {{count}}',
scanTruncated: 'Проверены только последние {{count}}',
emptyFiltered: 'По текущим фильтрам нет записей, требующих проверки',
mutationsOnly: 'Только изменения',
today: 'Сегодня',
yesterday: 'Вчера',
unknownDay: 'Неизвестное время',
showDetails: 'Подробнее',
moreChanges: 'ещё {{count}}',
elidedValue: '{{count}} символов',
levels: {
off: {
label: 'Трассировка выключена',
},
mutation: {
label: 'Только изменения',
},
read: {
label: 'Всё (включая просмотры)',
},
},
levelNames: {
off: 'Выкл',
mutation: 'Изменения',
read: 'Всё',
},
resourceTypes: {
bot: 'Бот',
adapter: 'Адаптер',
model_provider: 'Провайдер моделей',
llm_model: 'Модель',
pipeline: 'Конвейер',
user: 'Пользователь',
workspace: 'Рабочее пространство',
monitoring: 'Мониторинг',
webhook: 'Webhook',
api_key: 'API-ключ',
workspace_settings: 'Настройки пространства',
member: 'Участник',
member_invitation: 'Приглашение участника',
operation_log: 'Журнал операций',
assistant_conversation: 'Сессия ассистента',
agent: 'Агент',
file: 'Файл',
plugin: 'Расширение',
plugin_page: 'Страница расширения',
skill: 'Навык',
knowledge_base: 'База знаний',
mcp_server: 'MCP-сервер',
runtime: 'Среда выполнения',
system: 'Система',
resource: 'Ресурс',
},
outcomes: {
ok: 'Успех',
denied: 'Отказано',
error: 'Ошибка',
},
actions: {
audit_log_view: 'Просмотр журнала операций',
settings_update: 'Изменение настроек трассировки',
settings_view: 'Просмотр настроек трассировки',
member_invite: 'Приглашение участника',
member_role_update: 'Изменение роли участника',
member_remove: 'Удаление участника',
member_view: 'Просмотр участников',
plugin_view: 'Просмотр страниц расширения',
plugin_config: 'Изменение настроек расширения',
plugin_install: 'Установка расширения',
plugin_uninstall: 'Удаление расширения',
plugin_upgrade: 'Обновление расширения',
page_view: 'Просмотр страницы расширения',
skill_view: 'Просмотр навыка',
skill_install: 'Установка навыка',
skill_uninstall: 'Удаление навыка',
knowledge_base_view: 'Просмотр базы знаний',
knowledge_base_update: 'Изменение базы знаний',
knowledge_base_delete: 'Удаление базы знаний',
mcp_view: 'Просмотр сервера MCP',
mcp_config: 'Изменение сервера MCP',
mcp_delete: 'Удаление сервера MCP',
skill_update: 'Изменение навыка',
file_view: 'Просмотр файла',
ingest: 'Импорт данных',
file_delete: 'Удаление файла базы знаний',
ingress: 'Внешний приём сообщений',
codex_view: 'Просмотр авторизации Codex',
codex_authorize: 'Авторизация Codex',
embed: 'Встроенная сессия',
pipeline_extensions_update: 'Изменение привязок расширений конвейера',
export: 'Экспорт данных',
execute: 'Выполнение задачи',
debug: 'Отладка задачи',
publish: 'Публикация ресурса',
create: 'Создание ресурса',
update: 'Изменение ресурса',
delete: 'Удаление ресурса',
view: 'Просмотр ресурса',
probe: 'Проверка эндпоинта',
},
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',
+139
View File
@@ -21,6 +21,7 @@ const thTH = {
create_knowledge_base: 'สร้างฐานความรู้',
get_pipeline: 'ดู Pipeline',
get_knowledge_schema: 'ดูสคีมาของ knowledge engine',
list_operation_logs: 'ดูการติดตามการดำเนินการ',
},
resources: {
models: 'ค้นหาโมเดลแชต',
@@ -2664,12 +2665,150 @@ const thTH = {
title: 'การตั้งค่า',
nav: {
workspace: 'Workspace',
operationTrace: 'การติดตาม',
models: 'โมเดล',
api: 'API',
storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี',
},
},
operationTrace: {
title: 'การติดตามการดำเนินการ',
description: 'ติดตามการแก้ไขและการดูของผู้ดูแลและเจ้าของ',
captureLevel: 'ระดับการบันทึก',
retention: 'นโยบายการเก็บรักษา',
retentionSummary: 'เก็บ {{days}} วัน · สูงสุด {{rows}} รายการ',
retentionDays: 'จำนวนวันเก็บรักษา',
maxRows: 'จำนวนบันทึกสูงสุด',
dedupeWindow: 'ช่วงเวลาลบรายการซ้ำ (วินาที)',
records: 'บันทึกการดำเนินการ',
changesCount: '{{count}} การเปลี่ยนแปลง',
hideDetails: 'ซ่อนรายละเอียด',
levelUpdated: 'อัปเดตระดับการบันทึกแล้ว',
levelUpdateFailed: 'อัปเดตระดับการบันทึกไม่สำเร็จ',
retentionUpdated: 'อัปเดตนโยบายการเก็บรักษาแล้ว',
retentionUpdateFailed: 'อัปเดตนโยบายการเก็บรักษาไม่สำเร็จ',
export: 'ดาวน์โหลดบันทึก',
exportFailed: 'ดาวน์โหลดบันทึกการดำเนินการไม่สำเร็จ',
empty: 'ยังไม่มีบันทึกการดำเนินการ',
systemActor: 'ระบบ',
pageInfo: '{{from}}-{{to}} จาก {{total}}',
previousPage: 'หน้าก่อนหน้า',
nextPage: 'หน้าถัดไป',
filterAction: 'การดำเนินการ',
filterAllActions: 'ทุกการดำเนินการ',
filterResource: 'ทรัพยากร',
filterAllResources: 'ทุกทรัพยากร',
filterActor: 'ผู้ดำเนินการ',
filterAllActors: 'ผู้ดำเนินการทั้งหมด',
loadFailed: 'โหลดข้อมูลการติดตามไม่สำเร็จ',
refresh: 'รีเฟรช',
save: 'บันทึก',
redacted: 'ปิดบังแล้ว',
tamperedBadge: 'อาจถูกแก้ไข',
verifiedBadge: 'ตรวจสอบผ่าน',
integrityFailedCount: 'แฮชไม่ตรงกัน {{count}} รายการ',
chainFailedCount: 'ลิงก์ขาด {{count}} รายการ',
scanTruncated: 'ตรวจสอบเฉพาะ {{count}} รายการล่าสุด',
emptyFiltered: 'ไม่มีรายการที่ต้องตรวจสอบภายใต้ตัวกรองปัจจุบัน',
mutationsOnly: 'เฉพาะการแก้ไข',
today: 'วันนี้',
yesterday: 'เมื่อวาน',
unknownDay: 'ไม่ทราบเวลา',
showDetails: 'รายละเอียด',
moreChanges: 'อีก {{count}} รายการ',
elidedValue: '{{count}} ตัวอักษร',
levels: {
off: {
label: 'ปิดการติดตาม',
},
mutation: {
label: 'เฉพาะการแก้ไข',
},
read: {
label: 'สูงสุด (รวมการดู)',
},
},
levelNames: {
off: 'ปิด',
mutation: 'เฉพาะแก้ไข',
read: 'สูงสุด',
},
resourceTypes: {
bot: 'บอท',
adapter: 'อะแดปเตอร์',
model_provider: 'ผู้ให้บริการโมเดล',
llm_model: 'โมเดล',
pipeline: 'ไปป์ไลน์',
user: 'ผู้ใช้',
workspace: 'เวิร์กสเปซ',
monitoring: 'การมอนิเตอร์',
webhook: 'Webhook',
api_key: 'คีย์ API',
workspace_settings: 'การตั้งค่าพื้นที่ทำงาน',
member: 'สมาชิก',
member_invitation: 'คำเชิญสมาชิก',
operation_log: 'บันทึกการดำเนินการ',
assistant_conversation: 'บทสนทนาผู้ช่วย',
agent: 'เอเจนต์',
file: 'ไฟล์',
plugin: 'ส่วนขยาย',
plugin_page: 'หน้าส่วนขยาย',
skill: 'ทักษะ',
knowledge_base: 'ฐานความรู้',
mcp_server: 'เซิร์ฟเวอร์ MCP',
runtime: 'รันไทม์',
system: 'ระบบ',
resource: 'ทรัพยากร',
},
outcomes: {
ok: 'สำเร็จ',
denied: 'ถูกปฏิเสธ',
error: 'ล้มเหลว',
},
actions: {
audit_log_view: 'ดูบันทึกการดำเนินการ',
settings_update: 'แก้ไขการตั้งค่าการติดตาม',
settings_view: 'ดูการตั้งค่าการติดตาม',
member_invite: 'เชิญสมาชิก',
member_role_update: 'แก้ไขบทบาทสมาชิก',
member_remove: 'ลบสมาชิก',
member_view: 'ดูสมาชิก',
plugin_view: 'ดูหน้าส่วนขยาย',
plugin_config: 'แก้ไขการตั้งค่าส่วนขยาย',
plugin_install: 'ติดตั้งส่วนขยาย',
plugin_uninstall: 'ถอนการติดตั้งส่วนขยาย',
plugin_upgrade: 'อัปเกรดส่วนขยาย',
page_view: 'ดูหน้าส่วนขยาย',
skill_view: 'ดูทักษะ',
skill_install: 'ติดตั้งทักษะ',
skill_uninstall: 'ถอนการติดตั้งทักษะ',
knowledge_base_view: 'ดูฐานความรู้',
knowledge_base_update: 'แก้ไขฐานความรู้',
knowledge_base_delete: 'ลบฐานความรู้',
mcp_view: 'ดูเซิร์ฟเวอร์ MCP',
mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP',
mcp_delete: 'ลบเซิร์ฟเวอร์ MCP',
skill_update: 'แก้ไขทักษะ',
file_view: 'ดูไฟล์',
ingest: 'นำเข้าข้อมูล',
file_delete: 'ลบไฟล์ฐานความรู้',
ingress: 'การรับข้อความภายนอก',
codex_view: 'ดูการอนุญาต Codex',
codex_authorize: 'อนุญาต Codex',
embed: 'เซสชันฝังตัว',
pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์',
export: 'ส่งออกข้อมูล',
execute: 'รันงาน',
debug: 'ดีบักงาน',
publish: 'เผยแพร่ทรัพยากร',
create: 'สร้างทรัพยากร',
update: 'แก้ไขทรัพยากร',
delete: 'ลบทรัพยากร',
view: 'ดูทรัพยากร',
probe: 'ตรวจสอบปลายทาง',
},
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
+139
View File
@@ -22,6 +22,7 @@ const viVN = {
create_knowledge_base: 'Tạo cơ sở tri thức',
get_pipeline: 'Xem Pipeline',
get_knowledge_schema: 'Xem lược đồ knowledge engine',
list_operation_logs: 'Truy vấn truy vết thao tác',
},
resources: {
models: 'Tìm mô hình trò chuyện',
@@ -2699,12 +2700,150 @@ const viVN = {
title: 'Cài đặt',
nav: {
workspace: 'Workspace',
operationTrace: 'Truy vết',
models: 'Mô hình',
api: 'API',
storage: 'Lưu trữ',
account: 'Tài khoản',
},
},
operationTrace: {
title: 'Truy vết thao tác',
description: 'Theo dõi thao tác sửa và xem của quản trị viên',
captureLevel: 'Mức ghi nhận',
retention: 'Chính sách lưu trữ',
retentionSummary: 'Lưu {{days}} ngày · tối đa {{rows}} bản ghi',
retentionDays: 'Số ngày lưu',
maxRows: 'Số bản ghi tối đa',
dedupeWindow: 'Khoảng khử trùng lặp (giây)',
records: 'Bản ghi thao tác',
changesCount: '{{count}} thay đổi',
hideDetails: 'Ẩn chi tiết',
levelUpdated: 'Đã cập nhật mức ghi nhận',
levelUpdateFailed: 'Cập nhật mức ghi nhận thất bại',
retentionUpdated: 'Đã cập nhật chính sách lưu trữ',
retentionUpdateFailed: 'Cập nhật chính sách lưu trữ thất bại',
export: 'Tải nhật ký',
exportFailed: 'Tải nhật ký thao tác thất bại',
empty: 'Chưa có bản ghi thao tác',
systemActor: 'Hệ thống',
pageInfo: '{{from}}-{{to}} trên {{total}}',
previousPage: 'Trang trước',
nextPage: 'Trang sau',
filterAction: 'Thao tác',
filterAllActions: 'Tất cả thao tác',
filterResource: 'Tài nguyên',
filterAllResources: 'Tất cả tài nguyên',
filterActor: 'Người thực hiện',
filterAllActors: 'Tất cả người thực hiện',
loadFailed: 'Tải dữ liệu truy vết thất bại',
refresh: 'Làm mới',
save: 'Lưu',
redacted: 'Đã ẩn',
tamperedBadge: 'Có thể bị sửa',
verifiedBadge: 'Đã xác minh',
integrityFailedCount: '{{count}} mã băm không khớp',
chainFailedCount: '{{count}} liên kết bị đứt',
scanTruncated: 'Chỉ xác minh {{count}} bản ghi mới nhất',
emptyFiltered: 'Không có bản ghi nào cần xác minh với bộ lọc hiện tại',
mutationsOnly: 'Chỉ thay đổi',
today: 'Hôm nay',
yesterday: 'Hôm qua',
unknownDay: 'Không rõ thời gian',
showDetails: 'Chi tiết',
moreChanges: '+{{count}} mục',
elidedValue: '{{count}} ký tự',
levels: {
off: {
label: 'Tắt truy vết',
},
mutation: {
label: 'Chỉ thao tác sửa',
},
read: {
label: 'Cao nhất (gồm xem)',
},
},
levelNames: {
off: 'Tắt',
mutation: 'Chỉ sửa',
read: 'Cao nhất',
},
resourceTypes: {
bot: 'Bot',
adapter: 'Bộ chuyển đổi',
model_provider: 'Nhà cung cấp mô hình',
llm_model: 'Mô hình',
pipeline: 'Đường ống',
user: 'Người dùng',
workspace: 'Workspace',
monitoring: 'Giám sát',
webhook: 'Webhook',
api_key: 'Khóa API',
workspace_settings: 'Cài đặt không gian làm việc',
member: 'Thành viên',
member_invitation: 'Lời mời thành viên',
operation_log: 'Nhật ký thao tác',
assistant_conversation: 'Phiên trợ lý',
agent: 'Tác nhân',
file: 'Tệp',
plugin: 'Tiện ích mở rộng',
plugin_page: 'Trang tiện ích',
skill: 'Kỹ năng',
knowledge_base: 'Cơ sở tri thức',
mcp_server: 'Máy chủ MCP',
runtime: 'Môi trường chạy',
system: 'Hệ thống',
resource: 'Tài nguyên',
},
outcomes: {
ok: 'Thành công',
denied: 'Bị từ chối',
error: 'Thất bại',
},
actions: {
audit_log_view: 'Xem nhật ký thao tác',
settings_update: 'Cập nhật cài đặt truy vết',
settings_view: 'Xem cài đặt truy vết',
member_invite: 'Mời thành viên',
member_role_update: 'Cập nhật vai trò thành viên',
member_remove: 'Xóa thành viên',
member_view: 'Xem thành viên',
plugin_view: 'Xem trang tiện ích',
plugin_config: 'Thay đổi cấu hình tiện ích',
plugin_install: 'Cài đặt tiện ích',
plugin_uninstall: 'Gỡ cài đặt tiện ích',
plugin_upgrade: 'Nâng cấp tiện ích',
page_view: 'Xem trang tiện ích',
skill_view: 'Xem kỹ năng',
skill_install: 'Cài đặt kỹ năng',
skill_uninstall: 'Gỡ cài đặt kỹ năng',
knowledge_base_view: 'Xem cơ sở tri thức',
knowledge_base_update: 'Thay đổi cơ sở tri thức',
knowledge_base_delete: 'Xóa cơ sở tri thức',
mcp_view: 'Xem máy chủ MCP',
mcp_config: 'Thay đổi máy chủ MCP',
mcp_delete: 'Xóa máy chủ MCP',
skill_update: 'Sửa kỹ năng',
file_view: 'Xem tệp',
ingest: 'Nhập dữ liệu',
file_delete: 'Xóa tệp cơ sở tri thức',
ingress: 'Nhận tin nhắn bên ngoài',
codex_view: 'Xem ủy quyền Codex',
codex_authorize: 'Ủy quyền Codex',
embed: 'Phiên nhúng',
pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline',
export: 'Xuất dữ liệu',
execute: 'Chạy tác vụ',
debug: 'Gỡ lỗi tác vụ',
publish: 'Xuất bản tài nguyên',
create: 'Tạo tài nguyên',
update: 'Cập nhật tài nguyên',
delete: 'Xóa tài nguyên',
view: 'Xem tài nguyên',
probe: 'Dò endpoint',
},
},
storageAnalysis: {
title: 'Phân tích lưu trữ',
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
+139
View File
@@ -19,6 +19,7 @@ const zhHans = {
create_knowledge_base: '创建知识库',
get_pipeline: '查看 Pipeline',
get_knowledge_schema: '查看知识引擎配置',
list_operation_logs: '查询操作溯源',
},
resources: {
models: '查询聊天模型',
@@ -2562,12 +2563,150 @@ const zhHans = {
title: '设置',
nav: {
workspace: '工作区',
operationTrace: '操作溯源',
models: '模型',
api: 'API',
storage: '存储',
account: '账户',
},
},
operationTrace: {
title: '操作溯源',
description: '追踪管理员与拥有者的修改和查看操作,记录“什么改成了什么”。',
captureLevel: '操作等级',
retention: '保留策略',
retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 条',
retentionDays: '保留天数',
maxRows: '最大记录数',
dedupeWindow: '去重窗口(秒)',
records: '操作记录',
changesCount: '{{count}} 项变更',
hideDetails: '收起详情',
levelUpdated: '操作等级已更新',
levelUpdateFailed: '操作等级更新失败',
retentionUpdated: '保留策略已更新',
retentionUpdateFailed: '保留策略更新失败',
export: '下载日志',
exportFailed: '下载操作日志失败',
empty: '暂无操作记录',
systemActor: '系统',
pageInfo: '第 {{from}}-{{to}} 条,共 {{total}} 条',
previousPage: '上一页',
nextPage: '下一页',
filterAction: '操作类型',
filterAllActions: '全部操作',
filterResource: '资源类型',
filterAllResources: '全部资源',
filterActor: '操作者',
filterAllActors: '全部操作者',
loadFailed: '加载操作溯源数据失败',
refresh: '刷新',
save: '保存',
redacted: '已脱敏',
tamperedBadge: '可能被篡改',
verifiedBadge: '校验通过',
integrityFailedCount: '{{count}} 条哈希不匹配',
chainFailedCount: '{{count}} 条链路断裂',
scanTruncated: '仅校验最近 {{count}} 条',
emptyFiltered: '当前筛选下没有需要校验的记录',
mutationsOnly: '只看修改',
today: '今天',
yesterday: '昨天',
unknownDay: '未知时间',
showDetails: '详情',
moreChanges: '还有 {{count}} 项',
elidedValue: '{{count}} 字符',
levels: {
off: {
label: '关闭溯源',
},
mutation: {
label: '仅修改操作',
},
read: {
label: '最高级(含查看)',
},
},
levelNames: {
off: '关闭',
mutation: '仅修改',
read: '最高级',
},
resourceTypes: {
bot: '机器人',
adapter: '适配器',
model_provider: '模型提供商',
llm_model: '模型',
pipeline: '管道',
user: '用户',
workspace: '工作区',
monitoring: '监控',
webhook: 'Webhook',
api_key: 'API 密钥',
workspace_settings: '工作区设置',
member: '成员',
member_invitation: '成员邀请',
operation_log: '操作日志',
assistant_conversation: '助手会话',
agent: '智能体',
file: '文件',
plugin: '扩展',
plugin_page: '扩展页面',
skill: '技能',
knowledge_base: '知识库',
mcp_server: 'MCP 服务器',
runtime: '运行时',
system: '系统',
resource: '资源',
},
outcomes: {
ok: '成功',
denied: '被拒绝',
error: '失败',
},
actions: {
audit_log_view: '查看操作日志',
settings_update: '修改溯源设置',
settings_view: '查看溯源设置',
member_invite: '邀请成员',
member_role_update: '修改成员角色',
member_remove: '移除成员',
member_view: '查看成员',
plugin_view: '查看扩展页面',
plugin_config: '修改扩展配置',
plugin_install: '安装扩展',
plugin_uninstall: '卸载扩展',
plugin_upgrade: '升级扩展',
page_view: '查看扩展页面',
skill_view: '查看技能',
skill_install: '安装技能',
skill_uninstall: '卸载技能',
knowledge_base_view: '查看知识库',
knowledge_base_update: '修改知识库',
knowledge_base_delete: '删除知识库',
mcp_view: '查看 MCP 服务器',
mcp_config: '修改 MCP 服务器',
mcp_delete: '删除 MCP 服务器',
skill_update: '修改技能',
file_view: '查看文件',
ingest: '导入数据',
file_delete: '删除知识库文件',
ingress: '外部消息接入',
codex_view: '查看 Codex 授权',
codex_authorize: '授权 Codex',
embed: '嵌入式会话',
pipeline_extensions_update: '修改管道扩展绑定',
export: '导出数据',
execute: '执行任务',
debug: '调试任务',
publish: '发布资源',
create: '创建资源',
update: '修改资源',
delete: '删除资源',
view: '查看资源',
probe: '探测接口',
},
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',
+139
View File
@@ -19,6 +19,7 @@ const zhHant = {
create_knowledge_base: '建立知識庫',
get_pipeline: '查看 Pipeline',
get_knowledge_schema: '查看知識引擎設定',
list_operation_logs: '查詢操作溯源',
},
resources: {
models: '查詢聊天模型',
@@ -2563,12 +2564,150 @@ const zhHant = {
title: '設定',
nav: {
workspace: 'Workspace',
operationTrace: '操作溯源',
models: '模型',
api: 'API',
storage: '儲存',
account: '帳戶',
},
},
operationTrace: {
title: '操作溯源',
description: '追蹤管理員與擁有者的修改與查看操作,記錄「什麼改成了什麼」。',
captureLevel: '操作等級',
retention: '保留策略',
retentionSummary: '保留 {{days}} 天 · 最多 {{rows}} 條',
retentionDays: '保留天數',
maxRows: '最大記錄數',
dedupeWindow: '去重視窗(秒)',
records: '操作記錄',
changesCount: '{{count}} 項變更',
hideDetails: '收合詳情',
levelUpdated: '操作等級已更新',
levelUpdateFailed: '操作等級更新失敗',
retentionUpdated: '保留策略已更新',
retentionUpdateFailed: '保留策略更新失敗',
export: '下載日誌',
exportFailed: '下載操作日誌失敗',
empty: '暫無操作記錄',
systemActor: '系統',
pageInfo: '第 {{from}}-{{to}} 條,共 {{total}} 條',
previousPage: '上一頁',
nextPage: '下一頁',
filterAction: '操作類型',
filterAllActions: '全部操作',
filterResource: '資源類型',
filterAllResources: '全部資源',
filterActor: '操作者',
filterAllActors: '全部操作者',
loadFailed: '載入操作溯源資料失敗',
refresh: '重新整理',
save: '儲存',
redacted: '已遮罩',
tamperedBadge: '可能被竄改',
verifiedBadge: '驗證通過',
integrityFailedCount: '{{count}} 筆雜湊不符',
chainFailedCount: '{{count}} 筆鏈結中斷',
scanTruncated: '僅校驗最近 {{count}} 筆',
emptyFiltered: '目前篩選下沒有需要校驗的記錄',
mutationsOnly: '只看修改',
today: '今天',
yesterday: '昨天',
unknownDay: '未知時間',
showDetails: '詳情',
moreChanges: '還有 {{count}} 項',
elidedValue: '{{count}} 字元',
levels: {
off: {
label: '關閉溯源',
},
mutation: {
label: '僅修改操作',
},
read: {
label: '最高級(含查看)',
},
},
levelNames: {
off: '關閉',
mutation: '僅修改',
read: '最高級',
},
resourceTypes: {
bot: '機器人',
adapter: '適配器',
model_provider: '模型提供商',
llm_model: '模型',
pipeline: '管道',
user: '使用者',
workspace: '工作區',
monitoring: '監控',
webhook: 'Webhook',
api_key: 'API 金鑰',
workspace_settings: '工作區設定',
member: '成員',
member_invitation: '成員邀請',
operation_log: '操作日誌',
assistant_conversation: '助手會話',
agent: '智慧代理',
file: '檔案',
plugin: '擴充',
plugin_page: '擴充頁面',
skill: '技能',
knowledge_base: '知識庫',
mcp_server: 'MCP 伺服器',
runtime: '執行階段',
system: '系統',
resource: '資源',
},
outcomes: {
ok: '成功',
denied: '被拒絕',
error: '失敗',
},
actions: {
audit_log_view: '查看操作日誌',
settings_update: '修改溯源設定',
settings_view: '查看溯源設定',
member_invite: '邀請成員',
member_role_update: '修改成員角色',
member_remove: '移除成員',
member_view: '查看成員',
plugin_view: '查看擴充頁面',
plugin_config: '修改擴充設定',
plugin_install: '安裝擴充',
plugin_uninstall: '解除安裝擴充',
plugin_upgrade: '升級擴充',
page_view: '查看擴充頁面',
skill_view: '查看技能',
skill_install: '安裝技能',
skill_uninstall: '解除安裝技能',
knowledge_base_view: '查看知識庫',
knowledge_base_update: '修改知識庫',
knowledge_base_delete: '刪除知識庫',
mcp_view: '查看 MCP 伺服器',
mcp_config: '修改 MCP 伺服器',
mcp_delete: '刪除 MCP 伺服器',
skill_update: '修改技能',
file_view: '查看檔案',
ingest: '匯入資料',
file_delete: '刪除知識庫檔案',
ingress: '外部訊息接入',
codex_view: '查看 Codex 授權',
codex_authorize: '授權 Codex',
embed: '嵌入式工作階段',
pipeline_extensions_update: '修改管道擴展綁定',
export: '匯出資料',
execute: '執行任務',
debug: '偵錯任務',
publish: '發佈資源',
create: '建立資源',
update: '修改資源',
delete: '刪除資源',
view: '查看資源',
probe: '探測介面',
},
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',