feat(deploy): add portable external-service stack

This commit is contained in:
2026-07-11 22:12:21 +02:00
parent 715de6649b
commit 767df63a33
7 changed files with 265 additions and 1 deletions
+42
View File
@@ -0,0 +1,42 @@
# ThothII
ThothII is a human-reviewed NL-to-SQL workflow with a React frontend and a Fastify/Pi/`tht`
core. The portable deployment runs exactly two application services; data services remain
external in this profile.
## Docker Compose: external services
Requirements: Docker Engine with Compose v2 and reachable DWH, vector, and embeddings
services.
1. Copy `deploy/env.example` to `deploy/.env` and fill in runtime credentials. The `.env`
file is gitignored and is read only when the container starts; secrets are never copied
into either image.
2. Add or edit YAML workspace descriptors under `deploy/workspaces/`. These files are mounted
read-only. Use relative `roots`; they resolve beneath `/data/workspaces/<workspace-name>`.
3. Start the external-service profile:
```sh
docker compose --profile external up --build --wait
```
4. Open <http://localhost:8080>. Set `THOTH_HTTP_PORT` before starting to use another host
port.
Application state, including settings, sessions, artifacts, and indexes, lives in the named
`thoth_data` volume mounted at `/data`. `docker compose down` keeps that volume. Only an
explicit destructive command such as `docker compose down --volumes` removes it.
The frontend depends on the core health check and proxies `/health` and `/api/*` to it. The
application health endpoint intentionally checks process readiness only; external dependency
diagnostics are exposed by `tht doctor` and do not prevent the UI from starting.
Run the end-to-end packaging check with:
```sh
./scripts/docker-smoke.sh
```
The smoke script validates Compose, builds and waits for both services, checks health through
the frontend, verifies SSE response headers, restarts the core, and confirms `/data` survives.
Its cleanup preserves the named volume.
+3
View File
@@ -77,6 +77,9 @@ export function sessionRoutes(
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
});
// Send the handshake immediately. Without this, Node waits for the first event body and
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
reply.raw.flushHeaders();
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
req.raw.on("close", off);
+25 -1
View File
@@ -2,9 +2,33 @@ import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("GET /health ritorna ok", async () => {
test("GET /health reports process readiness without external services", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/json");
expect(res.json()).toEqual({ status: "ok" });
});
test("SSE response headers are flushed before the first event", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
await app.listen({ port: 0, host: "127.0.0.1" });
const port = (app.server.address() as { port: number }).port;
const controller = new AbortController();
try {
const response = await Promise.race([
fetch(`http://127.0.0.1:${port}/sessions/header-probe/events`, {
signal: controller.signal,
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("SSE headers were not flushed")), 250),
),
]);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-cache");
} finally {
controller.abort();
await app.close();
}
});
+47
View File
@@ -0,0 +1,47 @@
name: thothii
services:
core:
profiles: [external]
build:
context: .
dockerfile: docker/core.Dockerfile
env_file:
- path: deploy/.env
required: false
environment:
THT_DATA_ROOT: /data
SETTINGS_FILE: /data/settings/settings.json
volumes:
- thoth_data:/data
- ./deploy/workspaces:/app/harness/workspaces:ro
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
restart: unless-stopped
frontend:
profiles: [external]
build:
context: .
dockerfile: docker/frontend.Dockerfile
environment:
BACKEND_BASE_URL: /api
ports:
- "${THOTH_HTTP_PORT:-8080}:8080"
depends_on:
core:
condition: service_healthy
healthcheck:
test: [CMD, wget, --quiet, --spider, http://127.0.0.1:8080/health]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
restart: unless-stopped
volumes:
thoth_data:
+28
View File
@@ -0,0 +1,28 @@
# Copy this file to deploy/.env. Never commit deploy/.env or real credentials.
# Compose passes these values to the core container at runtime; images contain no secrets.
# Optional application defaults
PI_PROVIDER=
PI_MODEL=
PI_THINKING=
MAX_PI_PROCESSES=4
AUTH_MODE=none
# External DWH (example workspace uses the HTTP adapters)
THT_DB_NAME=
THT_DWH_REST_URL=
THT_DWH_API_KEY=
# External vector service. Use a distinct write key where the service supports one.
THT_VEC_REST_URL=
THT_VEC_API_KEY=
THT_VEC_WRITE_API_KEY=
# External embeddings service
THT_OLLAMA_URL=
# Evidence source visible inside the persistent data volume
THT_DOCS_ROOT=/data/workspaces/example/evidence-source
# Optional CA file mounted separately by an operator, for example via a Compose override.
THT_SSL_CA=
+69
View File
@@ -0,0 +1,69 @@
language: en
dwh:
type: thoth_rest
database:
database: ${THT_DB_NAME}
schema: datawarehouse
endpoint:
base_url: ${THT_DWH_REST_URL}
api_key: ${THT_DWH_API_KEY}
ssl_ca: ${THT_SSL_CA}
# Relative logical roots are resolved beneath /data/workspaces/example.
roots:
artifacts: artifacts
indexes: indexes
sessions: sessions
examples:
max_per_column: 10
lsh:
signature_size: 64
n_gram: 3
threshold: 0.5
max_values_per_column: 1000
eligibility:
max_declared_len: 128
max_avg_length: 40
max_sampled_len: 200
ignore_columns: [etl_last_update]
evidence:
source_root: ${THT_DOCS_ROOT}
evidence_dir: evidence
embeddings:
base_url: ${THT_OLLAMA_URL}
model: nomic-embed-text-v2-moe
dim: 768
batch_size: 32
vectors:
type: thoth_vector_http
reader:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_API_KEY}
ssl_ca: ${THT_SSL_CA}
writer:
base_url: ${THT_VEC_REST_URL}
api_key: ${THT_VEC_WRITE_API_KEY}
ssl_ca: ${THT_SSL_CA}
vector:
max_chunk_chars: 4000
search:
rrf_k: 60
top_schema_tables: 12
schema_chunk_pool: 150
execution:
allow: [cte_test, explain, preview, aggregate, export]
max_preview_rows: 10
max_export_rows: 100000
statement_timeout_ms: 30000
warn_execution_ms: 5000
max_aggregate_cells: 20
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
compose="docker compose --profile external"
marker="smoke-$(date +%s)-$$"
headers=""
# Avoid colliding with a developer's existing service. Production/developer Compose still
# defaults to 8080; a published port of 0 asks Docker for a free ephemeral smoke port.
export THOTH_HTTP_PORT=${THOTH_HTTP_PORT:-0}
cleanup() {
if [ -n "$headers" ]; then rm -f "$headers"; fi
# Deliberately omit --volumes: an ordinary smoke run must preserve user data.
$compose down --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT HUP INT TERM
$compose config --quiet
$compose up --build --wait core frontend
published=$($compose port frontend 8080)
http_port=${published##*:}
curl --fail --silent --show-error "http://127.0.0.1:$http_port/health" >/dev/null
# The nginx proxy must preserve streaming semantics for the backend SSE endpoint.
headers=$(mktemp)
curl --fail --silent --show-error --max-time 2 --dump-header "$headers" \
"http://127.0.0.1:$http_port/api/sessions/docker-smoke/events" \
>/dev/null 2>&1 || status=$?
status=${status:-0}
if [ "$status" -ne 0 ] && [ "$status" -ne 28 ]; then
echo "SSE probe failed with curl status $status" >&2
exit "$status"
fi
grep -qi '^content-type: text/event-stream' "$headers"
grep -qi '^cache-control: no-cache' "$headers"
rm -f "$headers"
headers=""
# Write through the named volume, restart the application, and prove persistence.
$compose exec -T core sh -c 'printf "%s\n" "$1" > /data/.compose-smoke-marker' sh "$marker"
$compose restart core
$compose up --wait core frontend
persisted=$($compose exec -T core sh -c 'cat /data/.compose-smoke-marker')
[ "$persisted" = "$marker" ]
$compose exec -T core rm -f /data/.compose-smoke-marker
curl --fail --silent --show-error "http://127.0.0.1:$http_port/health" >/dev/null
echo "Compose health, SSE proxy, and restart persistence checks passed."