4 Commits
0.6.0 ... 0.6.2

Author SHA1 Message Date
root
98aad67dde fix(frontend): restore Molecular CoT log parser 2026-07-24 18:55:40 +03:00
root
6a0650b7a0 fix: service-to-service auth with SERVICE_JWT and robust Content-Disposition parsing 2026-07-24 17:49:39 +03:00
ba30f34537 logs 2026-07-24 10:16:29 +03:00
c30cca78f3 smoke alembic 2026-07-23 18:53:45 +03:00
15 changed files with 3528 additions and 19 deletions

View File

@@ -1,10 +1,15 @@
# #region Alembic.Migration.AddSessionActivityTable [C:2] [TYPE Migration] [SEMANTICS alembic,migration,session,activity,auth]
# @BRIEF Create session_activity table for idle/absolute session timeout enforcement.
# @PRE Previous migration (f7a8b9c0d1e2) has been applied; auth.users table exists.
# @POST session_activity table created with FK to users.id.
# @PRE Previous migration (f7a8b9c0d1e2) has been applied.
# @POST session_activity table is created when users exists; otherwise ORM create_all()
# creates it after the fresh Alembic upgrade.
# @SIDE_EFFECT DDL execution — creates table, index, foreign key constraint.
# @RELATION DEPENDS_ON -> [Models.Auth.SessionActivity]
# @RELATION DEPENDS_ON -> [Models.Auth.User]
# @RATIONALE users is ORM-owned and is created by Base.metadata.create_all() after
# Alembic during a fresh install, so the FK migration must be safely skipped first.
# @REJECTED Unconditionally creating the FK table was rejected — fresh installs fail
# before application startup because users does not yet exist.
"""add session_activity table
Revision ID: 8e9f0a1b2c3d
@@ -42,7 +47,7 @@ def _table_exists(table_name: str) -> bool:
# @SIDE_EFFECT Executes CREATE TABLE DDL.
def upgrade() -> None:
"""Create session_activity table for idle/absolute timeout enforcement."""
if _table_exists("session_activity"):
if _table_exists("session_activity") or not _table_exists("users"):
return
op.create_table(
"session_activity",

View File

@@ -26,6 +26,8 @@ depends_on: str | Sequence[str] | None = None
def _add_col_if_missing(table: str, column: sa.Column) -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(table):
return
existing = {c["name"] for c in inspector.get_columns(table)}
if column.name in existing:
return
@@ -38,6 +40,8 @@ def _add_col_if_missing(table: str, column: sa.Column) -> None:
# @BRIEF Add nullable performance knobs to translation_jobs and llm_providers.
# @RATIONALE NULL defaults preserve legacy algorithm behaviour (serial LLM, auto hard caps);
# capabilities are stored in DB, not inferred from brand/host at runtime.
# @RATIONALE llm_providers is an ORM-owned table created by Base.metadata.create_all()
# after Alembic on a fresh install; absent tables must therefore be skipped here.
# @REJECTED Non-nullable columns with server defaults — would silently flip legacy jobs to new behaviour.
def upgrade() -> None:
# ── translation_jobs (job policy / performance) ────────────────────────
@@ -164,6 +168,8 @@ def downgrade() -> None:
if name in job_cols:
op.drop_column("translation_jobs", name)
if not inspector.has_table("llm_providers"):
return
prov_cols = {c["name"] for c in inspector.get_columns("llm_providers")}
for name in (
"max_llm_concurrency",

View File

@@ -21,7 +21,7 @@ from src.schemas.agent import (
)
from ...core.database import get_db
from ...dependencies import get_current_user
from ...dependencies import get_current_user, get_agent_service_user
def _derive_risk(messages) -> str | None:
@@ -97,7 +97,7 @@ def _text_has_error(text: str) -> bool:
return any(m in t for m in markers)
router = APIRouter(prefix="/api/assistant", tags=["Agent"])
agent_router = APIRouter(prefix="/api/agent", tags=["Agent-Internal"], dependencies=[Depends(get_current_user)])
agent_router = APIRouter(prefix="/api/agent", tags=["Agent-Internal"])
# #region AgentChat.Api.ListConversations [C:3] [TYPE Function] [SEMANTICS agent-chat,api,list]
@@ -191,7 +191,7 @@ async def list_conversations(
async def save_conversation(
body: SaveConversationRequest,
db: Session = Depends(get_db),
user=Depends(get_current_user),
user=Depends(get_agent_service_user),
):
"""Create or update a conversation. Called by Gradio agent after streaming."""
conv = db.query(AgentConversation).filter(
@@ -332,7 +332,7 @@ async def check_active_session():
# from FastAPI REST instead of requiring duplicate env vars.
from ...core.config_manager import ConfigManager
from ...core.database import get_db
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, get_agent_service_user
from ...services.llm_provider import LLMProviderService

View File

@@ -167,7 +167,7 @@ class SupersetClientBase:
# @PURPOSE Determines the filename for an exported dashboard.
def _resolve_export_filename(self, response: httpx.Response, dashboard_id: int) -> str:
with belief_scope("_resolve_export_filename"):
filename = get_filename_from_headers(dict(response.headers))
filename = get_filename_from_headers(response.headers)
if not filename:
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"

View File

@@ -515,14 +515,28 @@ def sanitize_filename(filename: str) -> str:
# #endregion Core.Fileio.SanitizeFilename
# #region Core.Fileio.GetFilenameFromHeaders [TYPE Function]
# @ingroup Core
# @BRIEF Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# @PRE headers должен быть словарем заголовков.
# @BRIEF Извлекает имя файла из HTTP заголовка 'Content-Disposition' (case-insensitive).
# @PRE headers — httpx.Headers или dict[str, str].
# @POST Возвращает имя файла или None, если заголовок отсутствует.
def get_filename_from_headers(headers: dict) -> str | None:
# @RATIONALE httpx.Headers → dict() преобразование теряет case-insensitivity (HTTP/2
# нормализует заголовки в lowercase). Используем ручной case-insensitive поиск.
# Поддержка filename*= (RFC 5987) для не-ASCII имён файлов.
def get_filename_from_headers(headers: object) -> str | None:
with belief_scope("Get filename from headers"):
content_disposition = headers.get("Content-Disposition", "")
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
return match.group(1).strip()
# Case-insensitive lookup for Content-Disposition
cd = ""
if hasattr(headers, "get"):
# httpx.Headers is case-insensitive, but also handle dict
cd = headers.get("Content-Disposition") or headers.get("content-disposition") or ""
if not cd:
return None
# RFC 5987 filename*= (e.g. filename*=UTF-8''%D0%94%D0%B0%D1%88.zip)
if m := re.search(r"filename\*\s*=\s*(?:UTF-8|ISO-8859-1)''([^;]+)", cd, re.I):
from urllib.parse import unquote
return unquote(m.group(1).strip())
# Standard filename= (quoted or bare, stops at ; or EOL)
if m := re.search(r'filename\s*=\s*"?([^";]+)"?', cd, re.I):
return m.group(1).strip()
return None
# #endregion Core.Fileio.GetFilenameFromHeaders
# #region Core.Fileio.ConsolidateArchiveFolders [TYPE Function]

View File

@@ -17,6 +17,7 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
import os
from pathlib import Path
from fastapi import Depends, Header, HTTPException, Request, status
@@ -679,6 +680,46 @@ def get_current_user(
# #endregion Dependencies.AppDependencies.GetCurrentUser
# #region Dependencies.AppDependencies.ServiceUser [C:1] [TYPE Class]
# @ingroup Dependencies
# @BRIEF Lightweight user object for service-to-service auth (agent→backend).
class _ServiceUser:
"""Synthetic user for service-to-service calls. Not persisted to DB."""
id: str = "agent-service"
username: str = "agent-service"
email: str | None = None
is_active: bool = True
is_ad_user: bool = False
# #endregion Dependencies.AppDependencies.ServiceUser
# #region Dependencies.AppDependencies.GetAgentServiceUser [C:4] [TYPE Function]
# @ingroup Dependencies
# @BRIEF Dependency for agent routes — accepts either a valid JWT (standard flow)
# or a SERVICE_JWT shared secret for direct service-to-service auth.
# @PRE SERVICE_JWT env var must be set for service-to-service bypass.
# @POST Returns a User or _ServiceUser if credentials are valid; raises 401 otherwise.
# @RATIONALE The agent container sends SERVICE_JWT as a Bearer token but cannot
# mint a JWT because it has no user identity. This dependency checks the
# shared secret first (bypasses JWT decode + DB lookup), then falls back
# to standard get_current_user for human-authenticated requests.
# @SIDE_EFFECT Reads os.environ["SERVICE_JWT"].
# @RELATION CALLS -> [Dependencies.AppDependencies.GetCurrentUser]
def get_agent_service_user(
token: str | None = Depends(oauth2_scheme_optional),
x_user_jwt: str | None = Header(None, alias="X-User-JWT"),
db=Depends(get_auth_db),
):
service_jwt = os.environ.get("SERVICE_JWT", "")
effective_token = (x_user_jwt or token or "")
if service_jwt and effective_token == service_jwt:
return _ServiceUser()
return get_current_user(token=token, x_user_jwt=x_user_jwt, db=db)
# #endregion Dependencies.AppDependencies.GetAgentServiceUser
# #region Dependencies.AppDependencies.TrackSessionActivity [C:3] [TYPE Function]
# @ingroup Dependencies
# @BRIEF Update or create SessionActivity row for the current JWT.

View File

@@ -25,7 +25,7 @@ if _src not in sys.path:
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.agent_conversations import router, agent_router
from src.core.database import get_db
from src.dependencies import get_current_user, get_config_manager
from src.dependencies import get_current_user, get_agent_service_user, get_config_manager
from src.schemas.auth import User, RoleSchema
app = FastAPI()
@@ -42,6 +42,7 @@ def _make_client(overrides: dict | None = None) -> TestClient:
app.dependency_overrides[get_db] = lambda: MagicMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_agent_service_user] = lambda: mock_user
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
if overrides:
for dep, fn in overrides.items():

View File

@@ -189,6 +189,16 @@ def test_resolve_export_filename_from_header():
resp.headers = {"Content-Disposition": 'attachment; filename="my_dash.zip"'}
fname = obj._resolve_export_filename(resp, 99)
assert fname == "my_dash.zip"
# #region Test.SupersetClient.TestResolveExportFilenameLowercaseHeader [C:2] [TYPE Function]
# @BRIEF _resolve_export_filename handles lowercase content-disposition (HTTP/2).
def test_resolve_export_filename_lowercase_header():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"content-disposition": 'attachment; filename="lower.zip"'}
fname = obj._resolve_export_filename(resp, 50)
assert fname == "lower.zip"
# #endregion Test.SupersetClient.TestResolveExportFilenameLowercaseHeader
# #endregion Test.SupersetClient.TestResolveExportFilenameFromHeader
# #region Test.SupersetClient.TestResolveExportFilenameFallback [C:2] [TYPE Function]

View File

@@ -100,11 +100,37 @@ class TestGetFilenameFromHeaders:
assert get_filename_from_headers(headers) == "my report.csv"
def test_utf8_filename_with_regular_format(self):
"""The regex only matches filename=\"...\", not filename*=UTF-8''..."""
"""filename*= (RFC 5987) is parsed and returned when present."""
from src.core.utils.fileio import get_filename_from_headers
headers = {"Content-Disposition": 'attachment; filename="report.pdf"; filename*=UTF-8\'\'%D0%BE%D1%82%D1%87%D0%B5%D1%82.pdf'}
result = get_filename_from_headers(headers)
assert result == "report.pdf"
assert result == "отчет.pdf"
def test_lowercase_content_disposition(self):
"""Case-insensitive lookup — HTTP/2 normalizes headers to lowercase."""
from src.core.utils.fileio import get_filename_from_headers
headers = {"content-disposition": 'attachment; filename="report.pdf"'}
assert get_filename_from_headers(headers) == "report.pdf"
def test_filename_stops_at_semicolon(self):
"""filename= should stop at ; not capture rest of line."""
from src.core.utils.fileio import get_filename_from_headers
headers = {"Content-Disposition": "attachment; filename=export.zip; size=1234"}
assert get_filename_from_headers(headers) == "export.zip"
def test_rfc5987_only(self):
"""Only filename*= (RFC 5987) without standard filename=."""
from src.core.utils.fileio import get_filename_from_headers
headers = {"Content-Disposition": "attachment; filename*=UTF-8''%D0%94%D0%B0%D1%88.zip"}
result = get_filename_from_headers(headers)
assert result == "Даш.zip"
def test_httpx_headers_case_insensitive(self):
"""httpx.Headers object with lowercase key."""
import httpx
from src.core.utils.fileio import get_filename_from_headers
headers = httpx.Headers({"content-disposition": 'attachment; filename="export.zip"'})
assert get_filename_from_headers(headers) == "export.zip"
class TestCalculateCrc32:

View File

@@ -13,10 +13,13 @@
# @REJECTED Legacy stamp + raw SQL rejected — entrypoint now runs `alembic upgrade head`
# for legacy databases, ensuring all missing tables/columns are created.
import importlib.util
import os
from pathlib import Path
import pytest
import sys
from unittest.mock import Mock
import pytest
# Ensure backend/src is importable for model metadata
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
@@ -100,6 +103,66 @@ def test_legacy_database_upgrade() -> None:
# #endregion Test.AlembicMigrations.TestLegacyDatabaseUpgrade
# #region Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
# @BRIEF Verify the performance-knobs migration skips ORM-owned tables absent during a fresh upgrade.
# @POST No reflection or DDL operation runs when llm_providers does not exist yet.
def test_performance_knobs_skips_absent_optional_table(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fresh Alembic upgrades must not require llm_providers before create_all()."""
migration_path = (
Path(__file__).parent.parent
/ "alembic"
/ "versions"
/ "f7a8b9c0d1e2_add_translate_performance_knobs.py"
)
spec = importlib.util.spec_from_file_location("performance_knobs_migration", migration_path)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
inspector = Mock()
inspector.has_table.return_value = False
bind = object()
add_column = Mock()
monkeypatch.setattr(migration.op, "get_bind", lambda: bind)
monkeypatch.setattr(migration.sa, "inspect", lambda received: inspector)
monkeypatch.setattr(migration.op, "add_column", add_column)
migration._add_col_if_missing("llm_providers", migration.sa.Column("throughput_class", migration.sa.String()))
inspector.get_columns.assert_not_called()
add_column.assert_not_called()
# #endregion Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable
# #region Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
# @BRIEF Verify the session-activity migration skips its FK table before ORM creates users.
# @POST No CREATE TABLE operation runs when users is absent during a fresh Alembic upgrade.
def test_session_activity_skips_absent_users(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fresh Alembic upgrades must not create FK tables before their ORM parent exists."""
migration_path = (
Path(__file__).parent.parent
/ "alembic"
/ "versions"
/ "8e9f0a1b2c3d_add_session_activity_table.py"
)
spec = importlib.util.spec_from_file_location("session_activity_migration", migration_path)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
inspector = Mock()
inspector.get_table_names.return_value = []
create_table = Mock()
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
monkeypatch.setattr(migration, "inspect", lambda bind: inspector)
monkeypatch.setattr(migration.op, "create_table", create_table)
migration.upgrade()
create_table.assert_not_called()
# #endregion Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers
# #region Test.AlembicMigrations.RunAlembicUpgrade [C:1] [TYPE Function]
def _run_alembic_upgrade(revision: str = "head") -> None:
"""Run a named Alembic upgrade programmatically against DATABASE_URL."""

108
build.sh
View File

@@ -22,6 +22,10 @@
# bundle:frontend <tag> Build + export frontend .tar.xz only
# bundle:agent <tag> Build + export agent .tar.xz only
#
# Commands (release smoke):
# smoke:bundle <backend-image> <postgres-image>
# Validate clean PostgreSQL migration + backend boot/restart.
#
# Commands (full bundles — backend + frontend + agent + postgres):
# bundle <tag> Slim enterprise bundle (no embeddings agent). Default.
# bundle:embeddings <tag> Enterprise bundle WITH semantic embedding routing (larger agent)
@@ -215,6 +219,93 @@ export_image() {
echo "[bundle] ✅ ${archive} saved"
}
# ======================================================================
# RELEASE SMOKE TESTS
# ======================================================================
run_bundle_smoke() {
local backend_image="$1"
local postgres_image="$2"
local timeout_seconds="${BUNDLE_SMOKE_TIMEOUT_SECONDS:-180}"
local suffix="$(date +%s)-${RANDOM}"
local network="ss-tools-release-smoke-${suffix}"
local db_container="ss-tools-release-smoke-db-${suffix}"
local backend_container="ss-tools-release-smoke-backend-${suffix}"
local database_url="postgresql+psycopg2://postgres:postgres@${db_container}:5432/release_smoke"
(
set -euo pipefail
cleanup_bundle_smoke() {
docker rm -f "${backend_container}" "${db_container}" >/dev/null 2>&1 || true
docker network rm "${network}" >/dev/null 2>&1 || true
}
trap cleanup_bundle_smoke EXIT
wait_for_postgres() {
local elapsed=0
while (( elapsed < timeout_seconds )); do
if docker exec "${db_container}" pg_isready -U postgres -d release_smoke >/dev/null 2>&1; then
return 0
fi
sleep 2
((elapsed += 2))
done
echo "[smoke] ❌ PostgreSQL did not become ready within ${timeout_seconds}s" >&2
docker logs "${db_container}" >&2 || true
return 1
}
wait_for_backend_health() {
local phase="$1"
local elapsed=0
while (( elapsed < timeout_seconds )); do
if docker exec "${backend_container}" curl -fsS http://127.0.0.1:8000/ >/dev/null 2>&1; then
echo "[smoke] ✅ Backend health check passed (${phase})"
return 0
fi
if [[ "$(docker inspect --format '{{.State.Running}}' "${backend_container}" 2>/dev/null || true)" != "true" ]]; then
echo "[smoke] ❌ Backend exited during ${phase}" >&2
docker logs "${backend_container}" >&2 || true
return 1
fi
sleep 2
((elapsed += 2))
done
echo "[smoke] ❌ Backend health check timed out during ${phase}" >&2
docker logs "${backend_container}" >&2 || true
return 1
}
echo "[smoke] Starting clean PostgreSQL release gate..."
docker network create "${network}" >/dev/null
docker run -d --name "${db_container}" --network "${network}" \
--security-opt seccomp=unconfined \
-e POSTGRES_DB=release_smoke \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
"${postgres_image}" >/dev/null
wait_for_postgres
echo "[smoke] Verifying fresh migration, ORM schema initialization, and backend health..."
docker run -d --name "${backend_container}" --network "${network}" \
-e DATABASE_URL="${database_url}" \
-e TASKS_DATABASE_URL="${database_url}" \
-e AUTH_DATABASE_URL="${database_url}" \
-e AUTH_SECRET_KEY=release-smoke-auth-secret \
-e ENCRYPTION_KEY=change-me-generate-a-fernet-key= \
-e SERVICE_JWT=release-smoke-service-token \
-e INITIAL_ADMIN_CREATE=false \
"${backend_image}" >/dev/null
wait_for_backend_health "initial start"
echo "[smoke] Verifying idempotent migration and restart..."
docker restart "${backend_container}" >/dev/null
wait_for_backend_health "restart"
echo "[smoke] ✅ Clean PostgreSQL release gate passed"
)
}
# ======================================================================
# COMPOSE COMMANDS
# ======================================================================
@@ -344,6 +435,8 @@ services:
image: ${postgres_tag}
pull_policy: never
restart: unless-stopped
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: \${POSTGRES_DB:-ss_tools}
POSTGRES_USER: \${POSTGRES_USER:-postgres}
@@ -630,6 +723,9 @@ bundle_release() {
docker pull "${postgres_source}"
docker tag "${postgres_source}" "${postgres_tag}"
# Release gate: fresh PostgreSQL migration + backend boot + restart must pass.
run_bundle_smoke "${backend_tag}" "${postgres_tag}"
# Export .tar.xz archives
echo "[bundle] Exporting .tar.xz archives..."
export_image "${backend_tag}" "${DIST_ROOT}/superset-tools-backend.${tag}.tar.xz"
@@ -813,6 +909,9 @@ bundle_embeddings() {
docker pull "${postgres_source}"
docker tag "${postgres_source}" "${postgres_tag}"
# Release gate: fresh PostgreSQL migration + backend boot + restart must pass.
run_bundle_smoke "${backend_tag}" "${postgres_tag}"
# Export .tar.xz archives
echo "[bundle:embeddings] Exporting .tar.xz archives..."
export_image "${backend_tag}" "${DIST_ROOT}/superset-tools-backend.${tag}${suffix}.tar.xz"
@@ -879,10 +978,15 @@ Commands for single-image bundle (build + .tar.xz export):
bundle:frontend <tag> Build + export frontend .tar.xz only
bundle:agent <tag> Build + export agent .tar.xz only
Release verification:
smoke:bundle <backend-image> <postgres-image>
Run clean PostgreSQL migration + backend boot/restart gate.
Commands for full bundles (backend + frontend + agent + postgres):
bundle <tag> Default enterprise bundle (4 .tar.xz archives).
Includes: backend + frontend + agent (slim) + postgres (16-alpine).
PostgreSQL работает в докер-контейнере — внешний БД не требуется.
Before export: clean PostgreSQL migration + backend boot/restart smoke gate.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle v1.0.0
@@ -890,6 +994,7 @@ Commands for full bundles (backend + frontend + agent + postgres):
Enterprise bundle WITH semantic embedding routing.
Agent built WITH sentence-transformers+torch (larger image).
Includes: backend + frontend + agent (embeddings) + postgres.
Before export: clean PostgreSQL migration + backend boot/restart smoke gate.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle:embeddings v1.0.0
@@ -930,7 +1035,7 @@ main() {
shift 2>/dev/null || true
case "$CMD" in
up|down|restart|logs|status|help|-h|--help|bundle|bundle:embeddings|bundle:light|\
up|down|restart|logs|status|help|-h|--help|bundle|bundle:embeddings|bundle:light|smoke:bundle|\
build:backend|build:frontend|build:agent|\
bundle:backend|bundle:frontend|bundle:agent)
# Valid commands — proceed
@@ -951,6 +1056,7 @@ main() {
bundle) bundle_release "$@" ;;
bundle:embeddings) bundle_embeddings "$@" ;;
bundle:light) bundle_light "$@" ;;
smoke:bundle) run_bundle_smoke "$@" ;;
build:backend) build_backend "$@" ;;
build:frontend) build_frontend "$@" ;;
build:agent) build_agent "$@" ;;

3130
container.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,9 @@ services:
db:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
# Compatibility workaround for legacy Docker/libseccomp hosts running PostgreSQL 16.
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: ${POSTGRES_DB:-ss_tools}
POSTGRES_USER: ${POSTGRES_USER:-postgres}

View File

@@ -2,6 +2,9 @@ services:
db:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
# Compatibility workaround for legacy Docker/libseccomp hosts running PostgreSQL 16.
security_opt:
- seccomp=unconfined
environment:
POSTGRES_DB: ss_tools
POSTGRES_USER: postgres
@@ -38,6 +41,7 @@ services:
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
FEATURES__DATASET_REVIEW: ${FEATURES__DATASET_REVIEW:-true}
FEATURES__HEALTH_MONITOR: ${FEATURES__HEALTH_MONITOR:-true}
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
LLM_CA_CERT_URLS: ${LLM_CA_CERT_URLS:-}
ports:
- "${BACKEND_HOST_PORT:-8001}:8000"

View File

@@ -0,0 +1,100 @@
// #region Logs.ParseCot [C:3] [TYPE Module] [SEMANTICS logs,cot,parse,task]
// @defgroup Logs Parse Molecular CoT records and normalize task log entries for display.
// #region Logs.ParseCot.Message [C:2] [TYPE Function] [SEMANTICS logs,cot,parse]
// @ingroup Logs
// @BRIEF Parse a valid Molecular CoT JSON log record, returning null for ordinary log text.
export type CotMarker = "REASON" | "REFLECT" | "EXPLORE";
export interface CotMessage {
marker: CotMarker;
intent: string;
src?: string;
trace_id?: string;
span_id?: string;
task_id?: string;
level?: string;
payload?: Record<string, unknown>;
error?: string;
raw: Record<string, unknown>;
}
function stringField(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function objectField(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export function parseCotMessage(message: unknown): CotMessage | null {
if (typeof message !== "string" || !message.trim().startsWith("{")) {
return null;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(message) as Record<string, unknown>;
} catch {
return null;
}
const marker = stringField(parsed.marker);
const intent = stringField(parsed.intent);
if (
(marker !== "REASON" && marker !== "REFLECT" && marker !== "EXPLORE") ||
!intent
) {
return null;
}
const payload = objectField(parsed.payload);
return {
marker,
intent,
src: stringField(parsed.src),
trace_id: stringField(parsed.trace_id),
span_id: stringField(parsed.span_id),
task_id: stringField(parsed.task_id) ?? stringField(payload?.task_id),
level: stringField(parsed.level),
payload,
error: stringField(parsed.error),
raw: parsed,
};
}
// #endregion Logs.ParseCot.Message
// #region Logs.ParseCot.TaskLogToViewEntry [C:3] [TYPE Function] [SEMANTICS logs,cot,task,normalize]
// @ingroup Logs
// @BRIEF Normalize an API task log record while preserving parsed CoT metadata when present.
export function taskLogToViewEntry(
entry: Record<string, unknown>,
index: number,
) {
const rawMessage = typeof entry.message === "string" ? entry.message : "";
const cot = parseCotMessage(rawMessage);
const timestamp = stringField(entry.timestamp) ?? stringField(entry.ts) ?? "";
const level = stringField(entry.level) ?? cot?.level ?? "INFO";
const metadata = objectField(entry.metadata);
return {
id: stringField(entry.id) ?? `${timestamp}-${index}-${level}`,
ts: timestamp,
level,
domain: "task",
task_id: stringField(entry.task_id) ?? cot?.task_id,
source: stringField(entry.source),
marker: cot?.marker,
intent: cot?.intent,
trace_id: cot?.trace_id,
span_id: cot?.span_id,
payload: cot?.payload ?? metadata,
error: cot?.error,
rawMessage,
};
}
// #endregion Logs.ParseCot.TaskLogToViewEntry
// #endregion Logs.ParseCot