diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 0ec114a8f..0d21560ab 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = { 'now': sqlalchemy.sql.functions.now, 'sum': sqlalchemy.sql.functions.sum, } -_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'}) +_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_STATEMENT_TYPES = ( sqlalchemy.sql.dml.UpdateBase, diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 4eec1668c..5b0502e1f 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -115,6 +115,7 @@ class _CapacityPluginRuntimeHandler: def __init__(self) -> None: self.bindings: dict[str, typing.Any] = {} self.reconciled: tuple[typing.Any, ...] = () + self.reconcile_timeout: float | None = None def register_installation_binding( self, @@ -132,8 +133,14 @@ class _CapacityPluginRuntimeHandler: def unregister_installation_binding(self, binding) -> None: self.bindings.pop(binding.installation_uuid, None) - async def reconcile_plugin_installations(self, desired_states) -> dict: + async def reconcile_plugin_installations( + self, + desired_states, + *, + timeout: float | None = None, + ) -> dict: self.reconciled = tuple(desired_states) + self.reconcile_timeout = timeout return { 'applied': [], 'removed': [], @@ -1034,6 +1041,7 @@ class TestPostgreSQLTenantRuntime: assert not mcp_loader._hosted_mcp_tasks assert len(plugin_handler.reconciled) == workspace_count assert len(plugin_handler.bindings) == workspace_count + assert plugin_handler.reconcile_timeout == 300.0 assert all(count == workspace_count for count in statement_counts.values()), statement_counts if max_elapsed is not None: assert elapsed <= max_elapsed diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py index 34e907095..8fd5e79f8 100644 --- a/tests/unit_tests/persistence/test_tenant_uow.py +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -964,6 +964,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql( sa.func.date_trunc('hour', sa.column('timestamp')), sa.func.length(sa.literal('value')), sa.func.nullif(sa.literal('value'), sa.literal('')), + sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')), ), sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))), sa.select(sa.cast(sa.column('embedding'), Vector(384))), @@ -977,6 +978,19 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem _validate_scoped_statement_call((statement,), {}) +async def test_scoped_session_executes_sqlite_strftime() -> None: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with TenantUnitOfWork(engine, 'workspace-a') as uow: + result = await uow.session.execute( + sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00'))) + ) + + assert result.scalar_one() == '2026-08-28 03:00' + finally: + await engine.dispose() + + async def test_scoped_sql_rejects_public_execution_options() -> None: statement = sa.select(sa.literal(1)) with pytest.raises(ScopedSessionTransactionError, match='execution options'): diff --git a/web/src/app/home/monitoring/components/TokenMonitoring.tsx b/web/src/app/home/monitoring/components/TokenMonitoring.tsx index 129f5f713..83b552ef3 100644 --- a/web/src/app/home/monitoring/components/TokenMonitoring.tsx +++ b/web/src/app/home/monitoring/components/TokenMonitoring.tsx @@ -20,6 +20,7 @@ import { TrendingUp, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { getErrorMessage } from '../utils'; interface TokenSummary { total_calls: number; @@ -152,7 +153,7 @@ export default function TokenMonitoring({ }); setStats(result); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(getErrorMessage(e)); } finally { setLoading(false); } diff --git a/web/src/app/home/monitoring/utils.ts b/web/src/app/home/monitoring/utils.ts new file mode 100644 index 000000000..2aea4aa98 --- /dev/null +++ b/web/src/app/home/monitoring/utils.ts @@ -0,0 +1,12 @@ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if ( + typeof error === 'object' && + error !== null && + 'msg' in error && + typeof error.msg === 'string' + ) { + return error.msg; + } + return String(error); +} diff --git a/web/tests/unit/token-monitoring-error.test.mjs b/web/tests/unit/token-monitoring-error.test.mjs new file mode 100644 index 000000000..c19344f7c --- /dev/null +++ b/web/tests/unit/token-monitoring-error.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const utilsPath = path.resolve( + currentDirectory, + '../../src/app/home/monitoring/utils.ts', +); +const componentPath = path.resolve( + currentDirectory, + '../../src/app/home/monitoring/components/TokenMonitoring.tsx', +); + +function loadMonitoringUtils() { + const source = fs.readFileSync(utilsPath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + () => { + throw new Error('Monitoring utils must not have runtime imports'); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports; +} + +const { getErrorMessage } = loadMonitoringUtils(); + +test('token monitoring extracts messages from structured API errors', () => { + assert.equal( + getErrorMessage({ + code: 500, + msg: 'SQLite aggregation failed', + data: null, + }), + 'SQLite aggregation failed', + ); + assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed'); + assert.equal(getErrorMessage('Request failed'), 'Request failed'); +}); + +test('token monitoring uses the structured API error helper', () => { + const source = fs.readFileSync(componentPath, 'utf8'); + assert.match(source, /import \{ getErrorMessage \} from '\.\.\/utils';/); + assert.match(source, /setError\(getErrorMessage\(e\)\)/); +});