Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98aad67dde | ||
|
|
6a0650b7a0 | ||
| ba30f34537 | |||
| c30cca78f3 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
108
build.sh
@@ -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
3130
container.log
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
100
frontend/src/lib/logs/parseCot.ts
Normal file
100
frontend/src/lib/logs/parseCot.ts
Normal 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
|
||||
Reference in New Issue
Block a user