Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/langbot/pkg/persistence/tenant_uow.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ class _ScopedSessionGuardState:
'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,
Expand Down
10 changes: 9 additions & 1 deletion tests/integration/persistence/test_migrations_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -132,8 +133,14 @@ def register_installation_binding(
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': [],
Expand Down Expand Up @@ -1034,6 +1041,7 @@ async def build_capacity_provider(
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
Expand Down
14 changes: 14 additions & 0 deletions tests/unit_tests/persistence/test_tenant_uow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand All @@ -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'):
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/home/monitoring/components/TokenMonitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions web/src/app/home/monitoring/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
53 changes: 53 additions & 0 deletions web/tests/unit/token-monitoring-error.test.mjs
Original file line number Diff line number Diff line change
@@ -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\)\)/);
});
Loading