diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 709425cbd..47737a1bb 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -29,6 +29,7 @@ import hashlib import hmac import json import logging +import threading import time import typing @@ -470,6 +471,58 @@ _ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = ( (('/publish',), 'publish', 'publish'), ) +#: Refines the resource family for the generic verb rules. The route rules above +#: only cover the extension/tenant surfaces; everything else (bots, providers, +#: pipelines, users, monitoring...) falls back to a nameless ``resource``. This +#: table names the family from the registered route identity, so a read of +#: ``/api/v1/pipelines`` is reported as a Pipeline, not as an opaque Resource. +#: It is only consulted when the matched rule still carries the generic +#: ``resource`` type, so specific rules keep their own family. More specific +#: fragments must precede their prefixes because the first match wins. +_RESOURCE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str], ...]] = ( + (('/platform/bots',), 'bot'), + (('/platform/',), 'adapter'), + (('/provider/providers',), 'model_provider'), + (('/provider/models',), 'llm_model'), + (('/provider/',), 'model_provider'), + (('/pipelines',), 'pipeline'), + (('/user/',), 'user'), + (('/workspaces/current',), 'workspace'), + (('/workspaces',), 'workspace'), + (('/monitoring',), 'monitoring'), + (('/webhooks',), 'webhook'), + (('/apikeys',), 'api_key'), + # The sandbox, survey and generic system probes share the ``system`` family. + (('/box/',), 'system'), + (('/survey',), 'system'), + (('/system/',), 'system'), +) + + +def _resource_type_for(route: str) -> str | None: + """Return the resource family for a route, or ``None`` when unknown.""" + + lowered = (route or '').lower() + for fragments, resource_type in _RESOURCE_RULES: + if all(fragment in lowered for fragment in fragments): + return resource_type + return None + + +def _with_resource_type(rule: ActionRule, route: str) -> ActionRule: + """Refine a generic rule's resource family from the route identity. + + Only the generic fallback (``resource``) is refined; a rule that already + names a family (plugin, skill, member...) is returned unchanged. + """ + + if rule.resource_type != 'resource': + return rule + resource_type = _resource_type_for(route) + if resource_type is None: + return rule + return dataclasses.replace(rule, resource_type=resource_type) + def classify(method: str, route: str) -> ActionRule: """Map one HTTP request to its normalized action rule. @@ -484,17 +537,17 @@ def classify(method: str, route: str) -> ActionRule: for fragments, read_action, write_action in _ROUTE_RULES: if all(fragment in lowered_route for fragment in fragments): - return ACTION_RULES_BY_ACTION[read_action if is_read else write_action] + return _with_resource_type(ACTION_RULES_BY_ACTION[read_action if is_read else write_action], route) if is_read: - return ACTION_RULES_BY_ACTION['view'] + return _with_resource_type(ACTION_RULES_BY_ACTION['view'], route) if upper_method == 'POST': - return ACTION_RULES_BY_ACTION['create'] + return _with_resource_type(ACTION_RULES_BY_ACTION['create'], route) if upper_method in {'PUT', 'PATCH'}: - return ACTION_RULES_BY_ACTION['update'] + return _with_resource_type(ACTION_RULES_BY_ACTION['update'], route) if upper_method == 'DELETE': - return ACTION_RULES_BY_ACTION['delete'] - return ACTION_RULES_BY_ACTION['probe'] + return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route) + return _with_resource_type(ACTION_RULES_BY_ACTION['probe'], route) def level_cap_for_role(role: str | None) -> int: @@ -858,6 +911,11 @@ class WorkspaceSettingsService: self._policy_cache_ttl = 5.0 # Inserts since the last row-budget check, keyed by Workspace UUID. self._insert_counters: dict[str, int] = {} + # Serializes the "read newest hash, then insert" pair. The route wrapper + # traces concurrent requests (the panel opens a dozen parallel GETs), and + # without this lock two rows would read the same predecessor and one of + # them would look like a broken chain link even though nobody edited it. + self._write_lock = threading.Lock() # -- level resolution ------------------------------------------------- @@ -1204,21 +1262,26 @@ class WorkspaceSettingsService: return False # Link each record to its predecessor so silent edits or removals - # become detectable when the chain is re-verified on read. - record_fields['prev_hash'] = await self._latest_record_hash(workspace_uuid) - record_fields['record_hash'] = compute_record_hash(record_fields) + # become detectable when the chain is re-verified on read. Reading + # the newest hash and inserting must be atomic: concurrent traces + # (the panel opens a dozen parallel GETs) would otherwise both link + # to the same predecessor and manufacture a false "chain broken" + # report even though nobody edited anything. + with self._write_lock: + record_fields['prev_hash'] = await self._latest_record_hash(workspace_uuid) + record_fields['record_hash'] = compute_record_hash(record_fields) - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_operation_log.WorkspaceOperationLog).values( - **record_fields, - request_id=str(request_id)[:128] if request_id else None, - detail=json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str) - if detail - else None, - user_agent=str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None, - duration_ms=max(int(duration_ms), 0), + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_operation_log.WorkspaceOperationLog).values( + **record_fields, + request_id=str(request_id)[:128] if request_id else None, + detail=json.dumps(redact_payload(dict(detail or {})), ensure_ascii=False, default=str) + if detail + else None, + user_agent=str(user_agent)[:_MAX_USER_AGENT_CHARS] if user_agent else None, + duration_ms=max(int(duration_ms), 0), + ) ) - ) await self._maybe_enforce_row_budget(workspace_uuid) return True except Exception as exc: # pragma: no cover - auditing is best effort diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index b91451d7b..358b2f7f5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -2772,6 +2772,16 @@ const enUS = { 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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 4ed6064a6..f4ce9b6f1 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -2827,6 +2827,16 @@ const esES = { 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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index c09fa032f..afa56043b 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -2790,6 +2790,16 @@ const jaJP = { read: '最上位', }, resourceTypes: { + bot: 'ボット', + adapter: 'アダプター', + model_provider: 'モデルプロバイダー', + llm_model: 'モデル', + pipeline: 'パイプライン', + user: 'ユーザー', + workspace: 'ワークスペース', + monitoring: 'モニタリング', + webhook: 'Webhook', + api_key: 'API キー', workspace_settings: 'ワークスペース設定', member: 'メンバー', member_invitation: 'メンバー招待', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 5d48382e6..a965543c1 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -2797,6 +2797,16 @@ const ruRU = { read: 'Всё', }, resourceTypes: { + bot: 'Бот', + adapter: 'Адаптер', + model_provider: 'Провайдер моделей', + llm_model: 'Модель', + pipeline: 'Конвейер', + user: 'Пользователь', + workspace: 'Рабочее пространство', + monitoring: 'Мониторинг', + webhook: 'Webhook', + api_key: 'API-ключ', workspace_settings: 'Настройки пространства', member: 'Участник', member_invitation: 'Приглашение участника', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index b3c174890..41fd4dfec 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -2724,6 +2724,16 @@ const thTH = { read: 'สูงสุด', }, resourceTypes: { + bot: 'บอท', + adapter: 'อะแดปเตอร์', + model_provider: 'ผู้ให้บริการโมเดล', + llm_model: 'โมเดล', + pipeline: 'ไปป์ไลน์', + user: 'ผู้ใช้', + workspace: 'เวิร์กสเปซ', + monitoring: 'การมอนิเตอร์', + webhook: 'Webhook', + api_key: 'คีย์ API', workspace_settings: 'การตั้งค่าพื้นที่ทำงาน', member: 'สมาชิก', member_invitation: 'คำเชิญสมาชิก', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 9e1a59284..baa97b697 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -2759,6 +2759,16 @@ const viVN = { 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', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 487433694..6c1c86e4b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -2622,6 +2622,16 @@ const zhHans = { read: '最高级', }, resourceTypes: { + bot: '机器人', + adapter: '适配器', + model_provider: '模型提供商', + llm_model: '模型', + pipeline: '管道', + user: '用户', + workspace: '工作区', + monitoring: '监控', + webhook: 'Webhook', + api_key: 'API 密钥', workspace_settings: '工作区设置', member: '成员', member_invitation: '成员邀请', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 823884f19..91bb6e466 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -2623,6 +2623,16 @@ const zhHant = { read: '最高級', }, resourceTypes: { + bot: '機器人', + adapter: '適配器', + model_provider: '模型提供商', + llm_model: '模型', + pipeline: '管道', + user: '使用者', + workspace: '工作區', + monitoring: '監控', + webhook: 'Webhook', + api_key: 'API 金鑰', workspace_settings: '工作區設定', member: '成員', member_invitation: '成員邀請',