chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time bootstrap for the DocsGPT e2e Postgres template DB.
|
||||
#
|
||||
# Creates two databases on the native DBngin Postgres instance:
|
||||
# * docsgpt_e2e_template — schema-only (alembic head), marked as a PG template
|
||||
# * docsgpt_e2e — the live DB the first `up.sh` run connects to
|
||||
#
|
||||
# Idempotent. Safe to re-run after schema changes to refresh the template.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
|
||||
PSQL="${PG_BIN}/psql"
|
||||
PG_ISREADY="${PG_BIN}/pg_isready"
|
||||
|
||||
PG_HOST="127.0.0.1"
|
||||
PG_PORT="5432"
|
||||
PG_SUPERUSER="postgres"
|
||||
|
||||
TEMPLATE_DB="docsgpt_e2e_template"
|
||||
E2E_DB="docsgpt_e2e"
|
||||
OWNER_ROLE="docsgpt"
|
||||
OWNER_PASSWORD="docsgpt"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
log() {
|
||||
printf '[bake_template] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
if [[ ! -x "${PSQL}" ]]; then
|
||||
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Checking Postgres is up at ${PG_HOST}:${PG_PORT}..."
|
||||
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
|
||||
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
|
||||
log "Start DBngin's Postgres 16.2 instance and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Dropping and recreating ${TEMPLATE_DB} as superuser ${PG_SUPERUSER}..."
|
||||
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
|
||||
-v ON_ERROR_STOP=1 -X -q <<SQL
|
||||
-- Clear the template flag first so DROP DATABASE is allowed.
|
||||
UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${TEMPLATE_DB}';
|
||||
UPDATE pg_database SET datallowconn = TRUE WHERE datname = '${TEMPLATE_DB}';
|
||||
|
||||
-- Evict any lingering backends on the template DB.
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${TEMPLATE_DB}'
|
||||
AND pid <> pg_backend_pid();
|
||||
|
||||
DROP DATABASE IF EXISTS ${TEMPLATE_DB};
|
||||
CREATE DATABASE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
|
||||
SQL
|
||||
|
||||
log "Applying Alembic schema to ${TEMPLATE_DB}..."
|
||||
|
||||
if [[ -x "${REPO_ROOT}/.venv/bin/python" ]]; then
|
||||
PYTHON="${REPO_ROOT}/.venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
POSTGRES_URI="postgresql://${OWNER_ROLE}:${OWNER_PASSWORD}@${PG_HOST}:${PG_PORT}/${TEMPLATE_DB}" \
|
||||
"${PYTHON}" scripts/db/init_postgres.py
|
||||
)
|
||||
|
||||
log "Marking ${TEMPLATE_DB} as a Postgres template (datistemplate=TRUE, datallowconn=FALSE)..."
|
||||
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
|
||||
-v ON_ERROR_STOP=1 -X -q <<SQL
|
||||
UPDATE pg_database SET datistemplate = TRUE WHERE datname = '${TEMPLATE_DB}';
|
||||
UPDATE pg_database SET datallowconn = FALSE WHERE datname = '${TEMPLATE_DB}';
|
||||
SQL
|
||||
|
||||
log "Cloning ${E2E_DB} from ${TEMPLATE_DB}..."
|
||||
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
|
||||
-v ON_ERROR_STOP=1 -X -q <<SQL
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${E2E_DB}'
|
||||
AND pid <> pg_backend_pid();
|
||||
|
||||
DROP DATABASE IF EXISTS ${E2E_DB};
|
||||
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
|
||||
SQL
|
||||
|
||||
log "Done. Template ${TEMPLATE_DB} is baked; ${E2E_DB} is ready for the first e2e run."
|
||||
exit 0
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/e2e/down.sh
|
||||
#
|
||||
# Tear down the DocsGPT end-to-end test stack started by up.sh.
|
||||
# Reads pidfiles from /tmp/docsgpt-e2e/*.pid, sends SIGTERM, waits up to 3s,
|
||||
# escalates to SIGKILL if still alive, removes each pidfile.
|
||||
#
|
||||
# Constraints:
|
||||
# - Idempotent: exits 0 even when no pidfiles exist.
|
||||
# - NEVER uses pkill/killall (CLAUDE.md: don't risk killing native
|
||||
# Postgres/Redis/Mongo processes).
|
||||
# - NEVER touches shared DB/Redis services.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
PIDDIR="/tmp/docsgpt-e2e"
|
||||
|
||||
log() {
|
||||
echo "[down.sh] $*" >&2
|
||||
}
|
||||
|
||||
# Stop a single service given its pidfile. Best-effort; never fatal.
|
||||
stop_one() {
|
||||
local pidfile="$1"
|
||||
local svc
|
||||
svc="$(basename "$pidfile" .pid)"
|
||||
|
||||
if [[ ! -s "$pidfile" ]]; then
|
||||
log "$svc: pidfile empty or missing — removing"
|
||||
rm -f "$pidfile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "$pidfile" 2>/dev/null || true)"
|
||||
|
||||
# Guard against garbage in the pidfile (non-numeric or empty).
|
||||
if ! [[ "$pid" =~ ^[0-9]+$ ]]; then
|
||||
log "$svc: pidfile contents not numeric ('$pid') — removing"
|
||||
rm -f "$pidfile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
log "$svc: pid $pid not running — removing pidfile"
|
||||
rm -f "$pidfile"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "$svc: sending SIGTERM to pid $pid"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
# Poll up to 3 seconds for graceful exit.
|
||||
local waited=0
|
||||
while (( waited < 3 )); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
waited=$(( waited + 1 ))
|
||||
done
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
log "$svc: pid $pid still alive after 3s — SIGKILL"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
else
|
||||
log "$svc: pid $pid exited gracefully"
|
||||
fi
|
||||
|
||||
rm -f "$pidfile"
|
||||
}
|
||||
|
||||
if [[ ! -d "$PIDDIR" ]]; then
|
||||
log "no pid directory at $PIDDIR — nothing to stop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
pidfiles=( "$PIDDIR"/*.pid )
|
||||
shopt -u nullglob
|
||||
|
||||
if (( ${#pidfiles[@]} == 0 )); then
|
||||
log "no pidfiles in $PIDDIR — nothing to stop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for pidfile in "${pidfiles[@]}"; do
|
||||
stop_one "$pidfile"
|
||||
done
|
||||
|
||||
log "teardown complete"
|
||||
exit 0
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/e2e/env.sh
|
||||
#
|
||||
# Environment variables for the DocsGPT end-to-end test stack.
|
||||
# This file is intentionally passive: it exports variables and nothing else.
|
||||
# It is `source`d by scripts/e2e/up.sh (and potentially by developers who want
|
||||
# to run Flask/Celery manually against the e2e stack).
|
||||
#
|
||||
# Mirrors `Appendix A — .env.e2e reference` in e2e-plan.md. If you add/remove
|
||||
# a variable here, update the plan doc as well.
|
||||
#
|
||||
# DO NOT run commands (mkdir, touch, etc.) from this file — keep it pure.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Postgres
|
||||
# -----------------------------------------------------------------------------
|
||||
export POSTGRES_URI="postgresql://docsgpt:docsgpt@127.0.0.1:5432/docsgpt_e2e"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis (dev uses DBs 0/1/2; e2e uses 11/12/13 to stay isolated)
|
||||
# -----------------------------------------------------------------------------
|
||||
export CELERY_BROKER_URL="redis://127.0.0.1:6379/11"
|
||||
export CELERY_RESULT_BACKEND="redis://127.0.0.1:6379/12"
|
||||
export CACHE_REDIS_URL="redis://127.0.0.1:6379/13"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mongo — unused in the e2e stack (app fully cut over on this branch)
|
||||
# -----------------------------------------------------------------------------
|
||||
export MONGO_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vector + storage
|
||||
# -----------------------------------------------------------------------------
|
||||
export VECTOR_STORE="faiss"
|
||||
export EMBEDDINGS_NAME="huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
export STORAGE_TYPE="local"
|
||||
export URL_STRATEGY="backend"
|
||||
export UPLOAD_FOLDER=".e2e-tmp/inputs"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flask
|
||||
# -----------------------------------------------------------------------------
|
||||
export API_URL="http://127.0.0.1:7099"
|
||||
export FLASK_DEBUG_MODE="false"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Auth (specs can override AUTH_TYPE per-launch via process env)
|
||||
# -----------------------------------------------------------------------------
|
||||
export AUTH_TYPE="${AUTH_TYPE:-session_jwt}"
|
||||
export JWT_SECRET_KEY="e2e-fixed-secret-never-use-in-prod"
|
||||
export ENCRYPTION_SECRET_KEY="e2e-fixed-encryption-key-never-use-in-prod"
|
||||
|
||||
# OIDC mode (AUTH_TYPE=oidc) — points at the mock IdP that oidc.spec.ts
|
||||
# spawns on demand (scripts/e2e/mock_oidc_idp.py, port 7999). Discovery is
|
||||
# lazy, so Flask boots fine before the IdP is up. Every OIDC_* var is pinned
|
||||
# here because the app's load_dotenv() walks up and reads the repo .env —
|
||||
# whatever a developer keeps there must not leak into the e2e stack.
|
||||
if [[ "${AUTH_TYPE}" == "oidc" ]]; then
|
||||
export OIDC_ISSUER="${OIDC_ISSUER:-http://127.0.0.1:7999}"
|
||||
export OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-docsgpt-e2e}"
|
||||
export OIDC_FRONTEND_URL="${OIDC_FRONTEND_URL:-http://127.0.0.1:5179}"
|
||||
export OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-}"
|
||||
export OIDC_SCOPES="${OIDC_SCOPES:-openid profile email}"
|
||||
export OIDC_USER_ID_CLAIM="${OIDC_USER_ID_CLAIM:-sub}"
|
||||
export OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI:-}"
|
||||
export OIDC_SESSION_LIFETIME_SECONDS="${OIDC_SESSION_LIFETIME_SECONDS:-28800}"
|
||||
export OIDC_PROVIDER_NAME="${OIDC_PROVIDER_NAME:-}"
|
||||
export OIDC_ALLOWED_GROUPS="${OIDC_ALLOWED_GROUPS:-}"
|
||||
export OIDC_GROUPS_CLAIM="${OIDC_GROUPS_CLAIM:-groups}"
|
||||
export SCIM_ENABLED="${SCIM_ENABLED:-false}"
|
||||
export SCIM_TOKEN="${SCIM_TOKEN:-}"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM → mock stub on 127.0.0.1:7899
|
||||
# -----------------------------------------------------------------------------
|
||||
export LLM_PROVIDER="openai"
|
||||
export LLM_NAME="gpt-4o-mini"
|
||||
export API_KEY="e2e-fake-key"
|
||||
export OPENAI_API_KEY="e2e-fake-key"
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:7899/v1"
|
||||
export EMBEDDINGS_BASE_URL="http://127.0.0.1:7899/v1"
|
||||
export EMBEDDINGS_KEY="e2e-fake-key"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Determinism — disable features that introduce non-determinism in tests
|
||||
# -----------------------------------------------------------------------------
|
||||
export ENABLE_CONVERSATION_COMPRESSION="false"
|
||||
export ENABLE_TOOL_PREFETCH="false"
|
||||
export PARSE_PDF_AS_IMAGE="false"
|
||||
export DOCLING_OCR_ENABLED="false"
|
||||
@@ -0,0 +1,509 @@
|
||||
"""OpenAI-compatible stub server for the DocsGPT e2e test suite.
|
||||
|
||||
Speaks the minimum subset of the OpenAI HTTP API that DocsGPT's ``openai``
|
||||
Python client needs:
|
||||
|
||||
* ``POST /v1/chat/completions`` (streaming + non-streaming, tool calls via fixture)
|
||||
* ``POST /v1/embeddings`` (deterministic hash-seeded vectors)
|
||||
* ``GET /healthz`` (liveness probe for ``scripts/e2e/up.sh``)
|
||||
|
||||
The server is **deterministic**: the same request always returns the same
|
||||
response. Requests are fingerprinted by SHA-256 of a canonical JSON encoding
|
||||
of ``(model, messages, tool_choice)``. If a fixture file matching that hash
|
||||
exists under ``mock_llm_fixtures/<hash>.json`` it wins; otherwise a generic
|
||||
"I don't know" fallback is returned and the hash + request is logged to stderr
|
||||
so a developer can promote it into a fixture later.
|
||||
|
||||
Run standalone (does NOT import anything from ``application/``). Python 3.11+.
|
||||
Flask is the only non-stdlib dependency and is already in
|
||||
``application/requirements.txt``.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/e2e/mock_llm.py
|
||||
|
||||
Defaults to ``127.0.0.1:7899`` to match the ``OPENAI_BASE_URL`` referenced in
|
||||
``e2e-plan.md`` Appendix A.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Response, jsonify, request, stream_with_context
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOST = os.environ.get("MOCK_LLM_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("MOCK_LLM_PORT", "7899"))
|
||||
FIXTURES_DIR = Path(__file__).parent / "mock_llm_fixtures"
|
||||
EMBEDDING_DIM = 768
|
||||
GENERIC_FALLBACK_TEXT = (
|
||||
"I don't have enough information to answer that from the provided sources."
|
||||
)
|
||||
STREAM_CHUNK_COUNT = 5
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS — permissive; stub trusts its port
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.after_request
|
||||
def _add_cors_headers(response: Response) -> Response:
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/v1/chat/completions", methods=["OPTIONS"])
|
||||
@app.route("/v1/embeddings", methods=["OPTIONS"])
|
||||
def _cors_preflight() -> Response:
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _canonical_messages(messages: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""Return a minimal, stable representation of the messages array.
|
||||
|
||||
We keep only fields that are semantically meaningful for fingerprinting a
|
||||
request. Extra keys from the OpenAI client (e.g. ``name``, ``tool_call_id``)
|
||||
are preserved because they *do* change the intended response.
|
||||
"""
|
||||
|
||||
if not messages:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
# Content may be a string OR a list of content-part dicts (vision / tool).
|
||||
# Serialize both forms deterministically.
|
||||
entry: dict[str, Any] = {
|
||||
"role": msg.get("role"),
|
||||
"content": msg.get("content"),
|
||||
}
|
||||
for key in ("name", "tool_call_id", "tool_calls"):
|
||||
if key in msg:
|
||||
entry[key] = msg[key]
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _compute_request_digest(payload: dict[str, Any]) -> str:
|
||||
"""SHA-256 fingerprint of ``(model, messages, tool_choice)``.
|
||||
|
||||
Kept narrow on purpose — temperature / top_p / seed / max_tokens should
|
||||
NOT influence which canned answer we return; those are knobs the app may
|
||||
flap on across runs.
|
||||
"""
|
||||
|
||||
canonical = {
|
||||
"model": payload.get("model"),
|
||||
"messages": _canonical_messages(payload.get("messages")),
|
||||
"tool_choice": payload.get("tool_choice"),
|
||||
}
|
||||
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _load_fixture(digest: str) -> dict[str, Any] | None:
|
||||
"""Return the parsed fixture dict for ``digest``, or ``None`` if missing/bad."""
|
||||
|
||||
path = FIXTURES_DIR / f"{digest}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
sys.stderr.write(f"[mock-llm] failed to load fixture {path}: {exc}\n")
|
||||
sys.stderr.flush()
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
"""Rough 4-chars-per-token estimate (OpenAI's own ballpark)."""
|
||||
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
|
||||
def _messages_text(messages: list[dict[str, Any]] | None) -> str:
|
||||
"""Concatenate message contents for prompt-token estimation."""
|
||||
|
||||
if not messages:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _split_into_chunks(text: str, count: int) -> list[str]:
|
||||
"""Split ``text`` into roughly ``count`` pieces by character length.
|
||||
|
||||
Guarantees at least one chunk even for the empty string (so streaming
|
||||
clients still see a delta before ``[DONE]``).
|
||||
"""
|
||||
|
||||
if count <= 0:
|
||||
return [text]
|
||||
if not text:
|
||||
return [""]
|
||||
n = len(text)
|
||||
size = max(1, (n + count - 1) // count)
|
||||
chunks = [text[i : i + size] for i in range(0, n, size)]
|
||||
if not chunks:
|
||||
chunks = [""]
|
||||
return chunks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat completions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_chat_response(
|
||||
payload: dict[str, Any], digest: str
|
||||
) -> tuple[str, list[dict[str, Any]] | None, str, dict[str, int]]:
|
||||
"""Return ``(content, tool_calls, finish_reason, usage)`` for ``payload``.
|
||||
|
||||
Looks up a fixture by digest first; falls back to the generic response if
|
||||
no fixture is present, and logs the miss so the dev can convert it.
|
||||
"""
|
||||
|
||||
fixture = _load_fixture(digest)
|
||||
if fixture is None:
|
||||
sys.stderr.write(f"[mock-llm] unknown fixture hash {digest}\n")
|
||||
try:
|
||||
sys.stderr.write(
|
||||
"[mock-llm] request: "
|
||||
+ json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||
+ "\n"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
sys.stderr.write("[mock-llm] request: <unserializable>\n")
|
||||
sys.stderr.flush()
|
||||
content = GENERIC_FALLBACK_TEXT
|
||||
tool_calls: list[dict[str, Any]] | None = None
|
||||
finish_reason = "stop"
|
||||
prompt_tokens = _estimate_tokens(_messages_text(payload.get("messages")))
|
||||
completion_tokens = _estimate_tokens(content)
|
||||
usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
return content, tool_calls, finish_reason, usage
|
||||
|
||||
response = fixture.get("response") or {}
|
||||
content = response.get("content") or ""
|
||||
tool_calls = response.get("tool_calls")
|
||||
finish_reason = response.get("finish_reason") or "stop"
|
||||
fixture_usage = response.get("usage") or {}
|
||||
prompt_tokens = int(
|
||||
fixture_usage.get(
|
||||
"prompt_tokens",
|
||||
_estimate_tokens(_messages_text(payload.get("messages"))),
|
||||
)
|
||||
)
|
||||
completion_tokens = int(
|
||||
fixture_usage.get("completion_tokens", _estimate_tokens(content))
|
||||
)
|
||||
usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
return content, tool_calls, finish_reason, usage
|
||||
|
||||
|
||||
def _chat_completion_envelope(
|
||||
*,
|
||||
digest: str,
|
||||
model: str,
|
||||
content: str,
|
||||
tool_calls: list[dict[str, Any]] | None,
|
||||
finish_reason: str,
|
||||
usage: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {"role": "assistant", "content": content}
|
||||
if tool_calls:
|
||||
message["tool_calls"] = tool_calls
|
||||
return {
|
||||
"id": f"chatcmpl-e2e-{digest[:12]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
"usage": usage,
|
||||
}
|
||||
|
||||
|
||||
def _sse(payload: dict[str, Any]) -> str:
|
||||
return "data: " + json.dumps(payload, ensure_ascii=False) + "\n\n"
|
||||
|
||||
|
||||
def _stream_chat_response(
|
||||
*,
|
||||
digest: str,
|
||||
model: str,
|
||||
content: str,
|
||||
tool_calls: list[dict[str, Any]] | None,
|
||||
finish_reason: str,
|
||||
chunk_delay_ms: int = 0,
|
||||
):
|
||||
"""Generator yielding SSE frames that match the OpenAI streaming protocol.
|
||||
|
||||
``chunk_delay_ms`` (controlled by ``X-Mock-LLM-Stream-Chunk-Delay-Ms``
|
||||
header) sleeps that many milliseconds between successive SSE frames.
|
||||
Used by durability E2E tests to simulate slow streams that survive a
|
||||
mid-flight ``kill -9`` against the consumer.
|
||||
"""
|
||||
|
||||
created = int(time.time())
|
||||
completion_id = f"chatcmpl-e2e-{digest[:12]}"
|
||||
|
||||
def _base_chunk(delta: dict[str, Any], final: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": finish_reason if final else None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _maybe_sleep() -> None:
|
||||
if chunk_delay_ms > 0:
|
||||
time.sleep(chunk_delay_ms / 1000.0)
|
||||
|
||||
# Opening role delta — matches OpenAI's real behavior.
|
||||
yield _sse(_base_chunk({"role": "assistant", "content": ""}))
|
||||
|
||||
if tool_calls:
|
||||
# Emit tool calls in one delta; content streaming is skipped when
|
||||
# tool_calls are present, matching what RAG code paths expect.
|
||||
_maybe_sleep()
|
||||
yield _sse(_base_chunk({"tool_calls": tool_calls}))
|
||||
yield _sse(_base_chunk({}, final=True))
|
||||
else:
|
||||
chunks = _split_into_chunks(content, STREAM_CHUNK_COUNT)
|
||||
last_index = len(chunks) - 1
|
||||
for i, piece in enumerate(chunks):
|
||||
_maybe_sleep()
|
||||
yield _sse(_base_chunk({"content": piece}, final=(i == last_index)))
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def _read_int_header(name: str, default: int = 0, ceiling: int = 600_000) -> int:
|
||||
"""Parse an integer header with a sane upper bound (10 minutes)."""
|
||||
raw = request.headers.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if value < 0:
|
||||
return default
|
||||
return min(value, ceiling)
|
||||
|
||||
|
||||
def _read_int_env(name: str, default: int = 0, ceiling: int = 600_000) -> int:
|
||||
"""Same as ``_read_int_header`` but for env vars — the durability E2E
|
||||
script sets ``MOCK_LLM_FORCE_*_DELAY_MS`` so it can drive slow streams
|
||||
through DocsGPT's OpenAI client without injecting per-request
|
||||
headers."""
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if value < 0:
|
||||
return default
|
||||
return min(value, ceiling)
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
def chat_completions() -> Response:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
model = payload.get("model") or "gpt-4o-mini"
|
||||
stream = bool(payload.get("stream"))
|
||||
|
||||
digest = _compute_request_digest(payload)
|
||||
content, tool_calls, finish_reason, usage = _resolve_chat_response(payload, digest)
|
||||
|
||||
# Durability E2E hooks: per-request OR per-process delays so tests can
|
||||
# simulate slow providers without touching fixtures or recompiling the
|
||||
# stub. Headers win over env so a single fixture run can opt in/out.
|
||||
upfront_delay_ms = _read_int_header("X-Mock-LLM-Total-Delay-Ms") or _read_int_env(
|
||||
"MOCK_LLM_FORCE_TOTAL_DELAY_MS"
|
||||
)
|
||||
chunk_delay_ms = _read_int_header(
|
||||
"X-Mock-LLM-Stream-Chunk-Delay-Ms"
|
||||
) or _read_int_env("MOCK_LLM_FORCE_STREAM_CHUNK_DELAY_MS")
|
||||
if upfront_delay_ms > 0:
|
||||
time.sleep(upfront_delay_ms / 1000.0)
|
||||
|
||||
if stream:
|
||||
generator = _stream_chat_response(
|
||||
digest=digest,
|
||||
model=model,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
chunk_delay_ms=chunk_delay_ms,
|
||||
)
|
||||
response = Response(
|
||||
stream_with_context(generator),
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
# Flask strips Content-Length on streamed responses; make sure we don't
|
||||
# accidentally set one. Nothing to do here — just documenting.
|
||||
return response
|
||||
|
||||
envelope = _chat_completion_envelope(
|
||||
digest=digest,
|
||||
model=model,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
usage=usage,
|
||||
)
|
||||
return jsonify(envelope)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embeddings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _deterministic_embedding(text: str) -> list[float]:
|
||||
"""Hash-seeded 768-dim float vector in [-1, 1).
|
||||
|
||||
Never all-zero: seeded RNG on a non-trivial hash of ``text`` plus a small
|
||||
non-zero offset so degenerate vector-store checks pass even if
|
||||
``text`` itself is empty.
|
||||
"""
|
||||
|
||||
seed = int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:16], 16) & 0xFFFFFFFF
|
||||
rng = random.Random(seed)
|
||||
vec = [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIM)]
|
||||
# Guarantee non-degeneracy: nudge the first component away from 0 if the
|
||||
# seeded draw happens to produce a very small value.
|
||||
if abs(vec[0]) < 1e-6:
|
||||
vec[0] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
@app.post("/v1/embeddings")
|
||||
@app.post("/v1/v1/embeddings")
|
||||
def embeddings() -> Response:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
model = payload.get("model") or "text-embedding-3-small"
|
||||
raw_input = payload.get("input", "")
|
||||
|
||||
if isinstance(raw_input, str):
|
||||
inputs: list[str] = [raw_input]
|
||||
elif isinstance(raw_input, list):
|
||||
inputs = [str(item) if not isinstance(item, str) else item for item in raw_input]
|
||||
else:
|
||||
inputs = [str(raw_input)]
|
||||
|
||||
data = [
|
||||
{
|
||||
"object": "embedding",
|
||||
"index": i,
|
||||
"embedding": _deterministic_embedding(text),
|
||||
}
|
||||
for i, text in enumerate(inputs)
|
||||
]
|
||||
total_tokens = sum(_estimate_tokens(text) for text in inputs)
|
||||
return jsonify(
|
||||
{
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"model": model,
|
||||
"usage": {
|
||||
"prompt_tokens": total_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> Response:
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
sys.stderr.write(
|
||||
f"[mock-llm] listening on http://{HOST}:{PORT} "
|
||||
f"(fixtures: {FIXTURES_DIR})\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
# threaded=True so that concurrent streaming + embeddings requests from
|
||||
# the Flask backend + Celery worker don't serialize behind each other.
|
||||
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Tester fixture: 'please run whoami on my remote device' -> run_command tool call (whoami). Used to verify full mode auto-approves a safe non-denylisted command.",
|
||||
"response": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_remote_tester_whoami_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"arguments": "{\"command\":\"whoami\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# Mock LLM fixtures
|
||||
|
||||
This directory holds **canned OpenAI Chat Completions responses** keyed by a
|
||||
SHA-256 fingerprint of the request. The stub server at
|
||||
`scripts/e2e/mock_llm.py` looks up `<hash>.json` here for every
|
||||
`POST /v1/chat/completions` request; if no fixture matches, the server returns
|
||||
a generic deterministic fallback and logs the missing hash to stderr so you
|
||||
can promote it into a fixture later.
|
||||
|
||||
Embeddings do not use fixtures — they are generated on the fly from a
|
||||
hash-seeded RNG. Only chat completions are fixtured.
|
||||
|
||||
## Filename format
|
||||
|
||||
```
|
||||
<sha256-hex>.json
|
||||
```
|
||||
|
||||
The hash is a lowercase hex SHA-256 digest (64 characters, no prefix, no
|
||||
extension beyond `.json`). Example:
|
||||
|
||||
```
|
||||
3f5a7b9c...d12ef0.json
|
||||
```
|
||||
|
||||
## How the hash is computed
|
||||
|
||||
The fingerprint covers only the fields that should control which canned answer
|
||||
is returned:
|
||||
|
||||
```python
|
||||
canonical = {
|
||||
"model": payload.get("model"),
|
||||
"messages": [minimal({role, content, name?, tool_call_id?, tool_calls?}) ...],
|
||||
"tool_choice": payload.get("tool_choice"),
|
||||
}
|
||||
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `temperature`, `top_p`, `seed`, `max_tokens`, `stream`, and any other
|
||||
sampling/transport knobs **do not influence the hash**. Streaming and
|
||||
non-streaming variants of the same request resolve to the same fixture.
|
||||
- Message `content` is kept verbatim — if the app passes a list of content
|
||||
parts (vision / multi-modal), the list is hashed as-is.
|
||||
- `tool_calls` on assistant messages and `tool_call_id` / `name` on tool
|
||||
messages are included because they change what the model is *replying to*.
|
||||
|
||||
The canonical source of the hashing logic is `_compute_request_digest` in
|
||||
`scripts/e2e/mock_llm.py`. If you change that function, regenerate all
|
||||
fixtures.
|
||||
|
||||
## Computing a fixture hash from the command line
|
||||
|
||||
The easiest path: run the e2e suite once with your new flow, grep stderr for
|
||||
`[mock-llm] unknown fixture hash <hash>` along with the request dump on the
|
||||
following line, and save the canned answer under `<hash>.json`. The up.sh log
|
||||
tail preserves both lines.
|
||||
|
||||
If you need to compute a hash by hand from a request payload:
|
||||
|
||||
```python
|
||||
# scripts/e2e/compute_hash.py (not committed; run ad hoc)
|
||||
import hashlib, json, sys
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
canonical = {
|
||||
"model": payload.get("model"),
|
||||
"messages": [
|
||||
{k: v for k, v in msg.items() if k in {"role", "content", "name", "tool_call_id", "tool_calls"}}
|
||||
for msg in payload.get("messages", [])
|
||||
],
|
||||
"tool_choice": payload.get("tool_choice"),
|
||||
}
|
||||
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
print(hashlib.sha256(blob.encode("utf-8")).hexdigest())
|
||||
```
|
||||
|
||||
```bash
|
||||
cat request.json | python scripts/e2e/compute_hash.py
|
||||
```
|
||||
|
||||
## Fixture JSON schema
|
||||
|
||||
```json
|
||||
{
|
||||
"request_digest": "<hash>",
|
||||
"description": "Human description of when this is used",
|
||||
"response": {
|
||||
"content": "The canned assistant text, may include markdown.",
|
||||
"tool_calls": null,
|
||||
"finish_reason": "stop",
|
||||
"usage": {"prompt_tokens": 12, "completion_tokens": 34}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field reference
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `request_digest` | string | no (documentation only) | Must match the filename. The loader does not re-verify this, it's here for human review. |
|
||||
| `description` | string | no | Short note on what flow this covers — makes grepping fixtures easier. |
|
||||
| `response.content` | string | yes (if no `tool_calls`) | The assistant's reply body. Plain text or markdown. Empty string is legal when `tool_calls` is set. |
|
||||
| `response.tool_calls` | array \| null | no | OpenAI tool-call shape: `[{"id": "call_x", "type": "function", "function": {"name": "...", "arguments": "{...}"}}]`. Arguments must be a JSON **string**, not an object. |
|
||||
| `response.finish_reason` | string | no (defaults to `"stop"`) | Use `"tool_calls"` when returning tool calls, `"length"` to simulate truncation. |
|
||||
| `response.usage.prompt_tokens` | number | no | Used verbatim in the non-streaming envelope. Default: estimated from request messages. |
|
||||
| `response.usage.completion_tokens` | number | no | Default: estimated from `content`. |
|
||||
|
||||
`response.usage.total_tokens` is always recomputed as the sum — do not set it.
|
||||
|
||||
### Streaming behavior
|
||||
|
||||
The stub handles streaming vs non-streaming transparently for both content
|
||||
and tool-call fixtures:
|
||||
|
||||
- **Content fixtures** are split into ~5 SSE deltas by character length. Only
|
||||
the last delta carries `finish_reason`.
|
||||
- **Tool-call fixtures** are emitted as a single delta containing the full
|
||||
`tool_calls` array, followed by a final empty delta carrying `finish_reason`.
|
||||
|
||||
No fixture change is needed to toggle between streaming and non-streaming;
|
||||
the app's `stream=true` flag alone controls it.
|
||||
|
||||
## Tool-call example
|
||||
|
||||
```json
|
||||
{
|
||||
"request_digest": "abc123...",
|
||||
"description": "Agent calls the weather tool for 'weather in London?'",
|
||||
"response": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_e2e_weather_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"London\"}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow for adding a fixture
|
||||
|
||||
1. Run the failing e2e spec. Watch `scripts/e2e/up.sh`'s log tail (or
|
||||
`/tmp/docsgpt-e2e/mock_llm.log` depending on how orchestration pipes it).
|
||||
2. Find the `[mock-llm] unknown fixture hash <hash>` warning and the request
|
||||
dump on the following line.
|
||||
3. Create `mock_llm_fixtures/<hash>.json` with the schema above.
|
||||
4. Re-run the spec — the warning should disappear and the spec should pass.
|
||||
5. Commit the fixture. Fixtures are checked into the repo so every developer
|
||||
and every CI run gets the same canned answers.
|
||||
|
||||
## Determinism guarantees
|
||||
|
||||
- Same request → same hash → same fixture → same response, always.
|
||||
- No `time.time()`, no random seeds, no environment dependence in the hash.
|
||||
- Embeddings are hash-seeded but never all-zero (the stub nudges the first
|
||||
component away from 0 if the seeded draw is near zero), so the vector store
|
||||
ingest path never rejects them.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Tester fixture: 'please run mkdir /tmp/rd_v3_test_dir on my remote device' -> run_command tool call (mkdir /tmp/rd_v3_test_dir). Used to verify a write command in ask mode still pauses on the approval bar.",
|
||||
"response": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_remote_tester_mkdir_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"arguments": "{\"command\":\"mkdir /tmp/rd_v3_test_dir\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Tester fixture: 'please run rm -rf / on my remote device' -> run_command tool call (rm -rf /). Used to verify full mode still pauses on the denylist-forced-prompt circuit-breaker.",
|
||||
"response": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_remote_tester_rm_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"arguments": "{\"command\":\"rm -rf /\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_comment": "Tester fixture: 'please run ls -la /tmp on my remote device' -> run_command tool call (ls -la /tmp).",
|
||||
"response": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_remote_tester_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"arguments": "{\"command\":\"ls -la /tmp\",\"working_directory\":\"/tmp\",\"timeout_ms\":5000}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Mock OpenID Connect provider for the DocsGPT e2e suite and local dev.
|
||||
|
||||
Speaks the minimum OIDC surface the backend's ``AUTH_TYPE=oidc`` flow needs:
|
||||
|
||||
* ``GET /.well-known/openid-configuration`` (discovery)
|
||||
* ``GET /authorize`` (auto-approves and redirects back with a code)
|
||||
* ``POST /token`` (single-use code + PKCE S256 check, RS256 ``id_token``,
|
||||
single-use rotating ``refresh_token``; also ``grant_type=refresh_token``)
|
||||
* ``GET /jwks`` (public key for ID-token verification)
|
||||
* ``GET /userinfo`` (Bearer access token from ``/token``)
|
||||
* ``GET /end-session`` (honors ``post_logout_redirect_uri``)
|
||||
* ``POST /trigger-backchannel-logout`` (test hook: signs and delivers a
|
||||
back-channel logout token to a given URL)
|
||||
* ``GET /healthz`` (liveness probe)
|
||||
|
||||
There is no login form: every ``/authorize`` request is approved as the user
|
||||
configured via ``MOCK_OIDC_SUB`` / ``MOCK_OIDC_EMAIL`` (overridable per
|
||||
request with ``?sub=``/``?email=`` for multi-user tests). Group membership
|
||||
comes from ``MOCK_OIDC_GROUPS`` (comma-separated).
|
||||
|
||||
Run standalone (does NOT import anything from ``application/``). Dependencies
|
||||
(flask, python-jose, cryptography, requests) are all in
|
||||
``application/requirements.txt``.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/e2e/mock_oidc_idp.py
|
||||
|
||||
Defaults to ``127.0.0.1:7999`` to match ``scripts/e2e/env.sh``'s
|
||||
``OIDC_ISSUER`` default for ``AUTH_TYPE=oidc`` runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from flask import Flask, Response, jsonify, redirect, request
|
||||
from jose import jwk
|
||||
from jose import jwt as jose_jwt
|
||||
|
||||
HOST = os.environ.get("MOCK_OIDC_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("MOCK_OIDC_PORT", "7999"))
|
||||
ISSUER = f"http://{HOST}:{PORT}"
|
||||
DEFAULT_SUB = os.environ.get("MOCK_OIDC_SUB", "mock-oidc-user")
|
||||
DEFAULT_EMAIL = os.environ.get("MOCK_OIDC_EMAIL", "mock-oidc-user@example.com")
|
||||
DEFAULT_NAME = os.environ.get("MOCK_OIDC_NAME", "Mock OIDC User")
|
||||
DEFAULT_GROUPS = [
|
||||
group.strip()
|
||||
for group in os.environ.get("MOCK_OIDC_GROUPS", "docsgpt-users").split(",")
|
||||
if group.strip()
|
||||
]
|
||||
DEFAULT_CLIENT_ID = os.environ.get("MOCK_OIDC_CLIENT_ID", "docsgpt-e2e")
|
||||
ID_TOKEN_TTL_SECONDS = 300
|
||||
LOGOUT_TOKEN_TTL_SECONDS = 120
|
||||
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
|
||||
# Random per process: each restart generates a fresh RSA key, and a fresh
|
||||
# kid lets relying parties detect the change via their kid-miss refetch
|
||||
# path instead of failing signature checks against a stale cached JWKS.
|
||||
KID = f"mock-oidc-key-{secrets.token_hex(4)}"
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
PRIVATE_PEM = _private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("ascii")
|
||||
PUBLIC_JWK = {
|
||||
**jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(),
|
||||
"kid": KID,
|
||||
"use": "sig",
|
||||
}
|
||||
|
||||
# code -> {client_id, redirect_uri, code_challenge, nonce, sub, email, name, groups}
|
||||
_codes: dict[str, dict] = {}
|
||||
# access_token -> {client_id, sub, email, name, groups}
|
||||
_access_tokens: dict[str, dict] = {}
|
||||
# refresh_token -> {client_id, sub, email, name, groups} (single-use, rotated)
|
||||
_refresh_tokens: dict[str, dict] = {}
|
||||
_last_client_id: str | None = None
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
sys.stderr.write(f"[mock-oidc] {message}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _pkce_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _user_record(record: dict) -> dict:
|
||||
"""Identity fields carried from /authorize through tokens and userinfo."""
|
||||
return {
|
||||
"client_id": record["client_id"],
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": list(record.get("groups") or DEFAULT_GROUPS),
|
||||
}
|
||||
|
||||
|
||||
def _issue_tokens(record: dict, nonce: str | None) -> dict:
|
||||
"""Mint an id_token (+ tracked access/refresh tokens) for ``record``."""
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": record["client_id"],
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": list(record.get("groups") or DEFAULT_GROUPS),
|
||||
"iat": now,
|
||||
"exp": now + ID_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
if nonce:
|
||||
claims["nonce"] = nonce
|
||||
id_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
|
||||
access_token = secrets.token_urlsafe(24)
|
||||
refresh_token = secrets.token_urlsafe(24)
|
||||
_access_tokens[access_token] = _user_record(record)
|
||||
_refresh_tokens[refresh_token] = _user_record(record)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ID_TOKEN_TTL_SECONDS,
|
||||
"id_token": id_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/openid-configuration")
|
||||
def discovery() -> Response:
|
||||
return jsonify(
|
||||
{
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/authorize",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
"userinfo_endpoint": f"{ISSUER}/userinfo",
|
||||
"end_session_endpoint": f"{ISSUER}/end-session",
|
||||
"backchannel_logout_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"scopes_supported": ["openid", "profile", "email"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/jwks")
|
||||
def jwks() -> Response:
|
||||
return jsonify({"keys": [PUBLIC_JWK]})
|
||||
|
||||
|
||||
@app.get("/authorize")
|
||||
def authorize() -> Response:
|
||||
args = request.args
|
||||
missing = [
|
||||
name
|
||||
for name in ("client_id", "redirect_uri", "code_challenge", "state")
|
||||
if not args.get(name)
|
||||
]
|
||||
if missing:
|
||||
return Response(f"missing params: {', '.join(missing)}", status=400)
|
||||
if args.get("response_type") != "code":
|
||||
return Response("unsupported response_type", status=400)
|
||||
if args.get("code_challenge_method") != "S256":
|
||||
return Response("unsupported code_challenge_method", status=400)
|
||||
|
||||
code = secrets.token_urlsafe(24)
|
||||
_codes[code] = {
|
||||
"client_id": args["client_id"],
|
||||
"redirect_uri": args["redirect_uri"],
|
||||
"code_challenge": args["code_challenge"],
|
||||
"nonce": args.get("nonce"),
|
||||
"sub": args.get("sub") or DEFAULT_SUB,
|
||||
"email": args.get("email") or DEFAULT_EMAIL,
|
||||
"name": DEFAULT_NAME,
|
||||
"groups": list(DEFAULT_GROUPS),
|
||||
}
|
||||
_log(f"authorize: auto-approved sub={_codes[code]['sub']}")
|
||||
separator = "&" if "?" in args["redirect_uri"] else "?"
|
||||
query = urlencode({"code": code, "state": args["state"]})
|
||||
return redirect(f"{args['redirect_uri']}{separator}{query}", code=302)
|
||||
|
||||
|
||||
@app.post("/token")
|
||||
def token() -> Response:
|
||||
global _last_client_id
|
||||
form = request.form
|
||||
grant_type = form.get("grant_type")
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
record = _refresh_tokens.pop(form.get("refresh_token", ""), None) # single-use
|
||||
if record is None:
|
||||
_log("token: unknown or reused refresh_token")
|
||||
return jsonify({"error": "invalid_grant"}), 400
|
||||
_last_client_id = record["client_id"]
|
||||
_log(f"token: refreshed tokens for sub={record['sub']}")
|
||||
return jsonify(_issue_tokens(record, nonce=None))
|
||||
|
||||
if grant_type != "authorization_code":
|
||||
return jsonify({"error": "unsupported_grant_type"}), 400
|
||||
record = _codes.pop(form.get("code", ""), None) # single-use
|
||||
if record is None:
|
||||
_log("token: unknown or replayed code")
|
||||
return jsonify({"error": "invalid_grant"}), 400
|
||||
if form.get("redirect_uri") != record["redirect_uri"]:
|
||||
return jsonify({"error": "invalid_grant", "error_description": "redirect_uri mismatch"}), 400
|
||||
if form.get("client_id") != record["client_id"]:
|
||||
return jsonify({"error": "invalid_client"}), 400
|
||||
verifier = form.get("code_verifier", "")
|
||||
if not verifier or _pkce_challenge(verifier) != record["code_challenge"]:
|
||||
_log("token: PKCE verification failed")
|
||||
return jsonify({"error": "invalid_grant", "error_description": "PKCE failed"}), 400
|
||||
|
||||
_last_client_id = record["client_id"]
|
||||
_log(f"token: issued id_token for sub={record['sub']}")
|
||||
return jsonify(_issue_tokens(record, nonce=record["nonce"]))
|
||||
|
||||
|
||||
@app.get("/userinfo")
|
||||
def userinfo() -> Response:
|
||||
header = request.headers.get("Authorization", "")
|
||||
access_token = header[len("Bearer "):] if header.startswith("Bearer ") else ""
|
||||
record = _access_tokens.get(access_token)
|
||||
if record is None:
|
||||
_log("userinfo: unknown access token")
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
return jsonify(
|
||||
{
|
||||
"sub": record["sub"],
|
||||
"email": record["email"],
|
||||
"name": record["name"],
|
||||
"groups": record["groups"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/trigger-backchannel-logout")
|
||||
def trigger_backchannel_logout() -> Response:
|
||||
"""Test hook: sign a back-channel logout token and POST it to ``url``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
url = body.get("url")
|
||||
sub = body.get("sub")
|
||||
sid = body.get("sid")
|
||||
if not url or not (sub or sid):
|
||||
return jsonify({"error": "url and sub (or sid) required"}), 400
|
||||
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": body.get("client_id") or _last_client_id or DEFAULT_CLIENT_ID,
|
||||
"iat": now,
|
||||
"exp": now + LOGOUT_TOKEN_TTL_SECONDS,
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
"events": {BACKCHANNEL_LOGOUT_EVENT: {}},
|
||||
}
|
||||
if sub:
|
||||
claims["sub"] = sub
|
||||
if sid:
|
||||
claims["sid"] = sid
|
||||
logout_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID})
|
||||
try:
|
||||
downstream = requests.post(url, data={"logout_token": logout_token}, timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
_log(f"trigger-backchannel-logout: delivery to {url} failed: {exc}")
|
||||
return jsonify({"error": "delivery_failed", "detail": str(exc)}), 502
|
||||
_log(f"trigger-backchannel-logout: {url} responded {downstream.status_code}")
|
||||
return jsonify({"status": downstream.status_code})
|
||||
|
||||
|
||||
@app.get("/end-session")
|
||||
def end_session() -> Response:
|
||||
target = request.args.get("post_logout_redirect_uri")
|
||||
_log(f"end-session: redirect={target or '<none>'}")
|
||||
if target:
|
||||
return redirect(target, code=302)
|
||||
return Response("Signed out of mock IdP.", status=200, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> Response:
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_log(f"listening on {ISSUER} (sub={DEFAULT_SUB}, groups={','.join(DEFAULT_GROUPS)})")
|
||||
app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset the DocsGPT e2e DB by cloning it from the baked template.
|
||||
#
|
||||
# Called by scripts/e2e/up.sh at the start of each e2e run.
|
||||
# Fast path: a single psql session does terminate + drop + clone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
|
||||
PSQL="${PG_BIN}/psql"
|
||||
PG_ISREADY="${PG_BIN}/pg_isready"
|
||||
|
||||
PG_HOST="127.0.0.1"
|
||||
PG_PORT="5432"
|
||||
PG_SUPERUSER="postgres"
|
||||
|
||||
TEMPLATE_DB="docsgpt_e2e_template"
|
||||
E2E_DB="docsgpt_e2e"
|
||||
OWNER_ROLE="docsgpt"
|
||||
|
||||
log() {
|
||||
printf '[reset_db] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
if [[ ! -x "${PSQL}" ]]; then
|
||||
log "psql not found at ${PSQL} — is DBngin Postgres 16.2 installed?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! "${PG_ISREADY}" -h "${PG_HOST}" -p "${PG_PORT}" -q; then
|
||||
log "Postgres is not accepting connections at ${PG_HOST}:${PG_PORT}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the template exists before attempting to clone from it.
|
||||
template_exists="$(
|
||||
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
|
||||
-tAX -c "SELECT 1 FROM pg_database WHERE datname = '${TEMPLATE_DB}';"
|
||||
)"
|
||||
|
||||
if [[ "${template_exists}" != "1" ]]; then
|
||||
log "Template DB '${TEMPLATE_DB}' does not exist."
|
||||
log "Run scripts/e2e/bake_template.sh once before the first e2e run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${PSQL}" -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_SUPERUSER}" -d postgres \
|
||||
-v ON_ERROR_STOP=1 -X -q <<SQL
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${E2E_DB}'
|
||||
AND pid <> pg_backend_pid();
|
||||
|
||||
DROP DATABASE IF EXISTS ${E2E_DB};
|
||||
CREATE DATABASE ${E2E_DB} TEMPLATE ${TEMPLATE_DB} OWNER ${OWNER_ROLE};
|
||||
SQL
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,12 @@
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname='public'
|
||||
AND tablename <> 'alembic_version'
|
||||
LOOP
|
||||
EXECUTE format('TRUNCATE TABLE %I RESTART IDENTITY CASCADE', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
Executable
+356
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/e2e/up.sh
|
||||
#
|
||||
# Boot the DocsGPT end-to-end test stack on this machine, natively.
|
||||
# See e2e-plan.md (Phase 0 / P0-A) for the contract.
|
||||
#
|
||||
# Happy path:
|
||||
# 1. Preflight shared services (Postgres, Redis). Fail loud if down.
|
||||
# 2. Reset state: Postgres template clone, Redis FLUSHDB 11/12/13, wipe .e2e-tmp.
|
||||
# 3. Export env.
|
||||
# 4. Start mock LLM (7899) → Flask (7099) → Celery → Vite (5179), each in
|
||||
# background, each with its own pidfile + log + readiness probe.
|
||||
# 5. Exit 0, leaving services running. Playwright (or the user) invokes
|
||||
# down.sh separately when done.
|
||||
#
|
||||
# On error before handoff: tear everything down, non-zero exit.
|
||||
# We explicitly DO NOT tear down on the happy-path exit — that would defeat
|
||||
# the purpose of "up".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Paths
|
||||
# -----------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
PG_BIN="/Users/Shared/DBngin/postgresql/16.2/bin"
|
||||
DBNGIN_REDIS_BIN="/Users/Shared/DBngin/redis/7.0.0/bin"
|
||||
|
||||
# Resolve redis-cli — PATH first, then DBngin's bundled copy.
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
REDIS_CLI="$(command -v redis-cli)"
|
||||
elif [[ -x "$DBNGIN_REDIS_BIN/redis-cli" ]]; then
|
||||
REDIS_CLI="$DBNGIN_REDIS_BIN/redis-cli"
|
||||
else
|
||||
REDIS_CLI=""
|
||||
fi
|
||||
PIDDIR="/tmp/docsgpt-e2e"
|
||||
E2E_TMP="$REPO_ROOT/.e2e-tmp"
|
||||
LOGDIR="$E2E_TMP/logs"
|
||||
BOOT_LOG="$LOGDIR/up.log"
|
||||
SVC_LOGDIR="$PIDDIR" # per-service logs live with the pidfiles per the brief
|
||||
|
||||
MOCK_LLM_PORT=7899
|
||||
FLASK_PORT=7099
|
||||
VITE_PORT=5179
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bookkeeping — track which services we successfully started so we can tear
|
||||
# them down if something later fails.
|
||||
# -----------------------------------------------------------------------------
|
||||
HANDOFF_OK=0
|
||||
STARTED_SERVICES=()
|
||||
|
||||
log() {
|
||||
local msg="[up.sh] $*"
|
||||
# Goes to stderr so stdout stays clean; also mirrored to the boot log.
|
||||
echo "$msg" >&2
|
||||
if [[ -n "${BOOT_LOG:-}" ]] && [[ -d "$(dirname "$BOOT_LOG")" ]]; then
|
||||
echo "$msg" >> "$BOOT_LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
die() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Trap: if we exit before handoff (failure or Ctrl-C), clean up. The happy
|
||||
# path sets HANDOFF_OK=1 just before `exit 0`, so the trap becomes a no-op.
|
||||
cleanup_on_failure() {
|
||||
local rc=$?
|
||||
if [[ "$HANDOFF_OK" -eq 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
log "aborting — tearing down any services that started (rc=$rc)"
|
||||
if [[ -x "$SCRIPT_DIR/down.sh" ]]; then
|
||||
"$SCRIPT_DIR/down.sh" || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_on_failure EXIT INT TERM
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Wait for a shell predicate to succeed. Args: <label> <timeout-seconds> <cmd...>
|
||||
wait_for() {
|
||||
local label="$1"
|
||||
local timeout="$2"
|
||||
shift 2
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout )); do
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
log " -> $label ready after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$(( elapsed + 1 ))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a substring to appear in a log file.
|
||||
wait_for_log() {
|
||||
local label="$1"
|
||||
local timeout="$2"
|
||||
local logfile="$3"
|
||||
local needle="$4"
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout )); do
|
||||
if [[ -f "$logfile" ]] && grep -qF -- "$needle" "$logfile"; then
|
||||
log " -> $label saw '$needle' after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$(( elapsed + 1 ))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Dump last 50 lines of a log file to stderr (for boot-failure diagnostics).
|
||||
dump_tail() {
|
||||
local label="$1"
|
||||
local logfile="$2"
|
||||
echo "---- last 50 lines of $label ($logfile) ----" >&2
|
||||
if [[ -f "$logfile" ]]; then
|
||||
tail -n 50 "$logfile" >&2 || true
|
||||
else
|
||||
echo "(log file does not exist)" >&2
|
||||
fi
|
||||
echo "---- end $label ----" >&2
|
||||
}
|
||||
|
||||
# Boot-failure handler: dump the log, then let the trap tear everything down.
|
||||
boot_fail() {
|
||||
local svc="$1"
|
||||
local logfile="$2"
|
||||
local reason="$3"
|
||||
log "boot failure: $svc — $reason"
|
||||
dump_tail "$svc" "$logfile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. Preflight
|
||||
# -----------------------------------------------------------------------------
|
||||
log "preflight: checking shared native services"
|
||||
|
||||
if [[ ! -x "$PG_BIN/pg_isready" ]]; then
|
||||
die "pg_isready not found at $PG_BIN/pg_isready — is DBngin Postgres 16.2 installed?"
|
||||
fi
|
||||
|
||||
if ! "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 -U docsgpt -d postgres >/dev/null 2>&1; then
|
||||
die "Postgres not reachable at 127.0.0.1:5432 as user 'docsgpt'. Start DBngin Postgres 16.2. (CLAUDE.md: do not kill/start this process from scripts.)"
|
||||
fi
|
||||
log " -> postgres OK"
|
||||
|
||||
if [[ -z "$REDIS_CLI" ]]; then
|
||||
die "redis-cli not found on PATH nor at $DBNGIN_REDIS_BIN/redis-cli — install redis or adjust DBNGIN_REDIS_BIN"
|
||||
fi
|
||||
|
||||
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 PING 2>/dev/null | grep -q '^PONG$'; then
|
||||
die "Redis not reachable at 127.0.0.1:6379. Start the native redis-server. (CLAUDE.md: do not kill/start this process from scripts.)"
|
||||
fi
|
||||
log " -> redis OK"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. Reset state
|
||||
# -----------------------------------------------------------------------------
|
||||
log "resetting state"
|
||||
|
||||
# Wipe & recreate .e2e-tmp first so BOOT_LOG has a home.
|
||||
rm -rf "$E2E_TMP"
|
||||
mkdir -p "$E2E_TMP/inputs" "$E2E_TMP/indexes" "$LOGDIR"
|
||||
: > "$BOOT_LOG"
|
||||
log " -> .e2e-tmp wiped; logs at $LOGDIR"
|
||||
|
||||
mkdir -p "$PIDDIR"
|
||||
# Leave existing per-service logs alone until we overwrite them at launch time;
|
||||
# that way a prior failure log isn't immediately erased if someone re-runs up.
|
||||
|
||||
# Postgres reset — delegated to reset_db.sh (owned by track P0-B).
|
||||
RESET_DB_SCRIPT="$SCRIPT_DIR/reset_db.sh"
|
||||
if [[ ! -x "$RESET_DB_SCRIPT" ]]; then
|
||||
die "reset_db.sh missing or not executable at $RESET_DB_SCRIPT — has track P0-B landed?"
|
||||
fi
|
||||
log " -> invoking reset_db.sh"
|
||||
if ! "$RESET_DB_SCRIPT" >> "$BOOT_LOG" 2>&1; then
|
||||
die "reset_db.sh failed — see $BOOT_LOG"
|
||||
fi
|
||||
|
||||
# Redis reset — three dedicated DB indices.
|
||||
for db in 11 12 13; do
|
||||
if ! "$REDIS_CLI" -h 127.0.0.1 -p 6379 -n "$db" FLUSHDB >/dev/null 2>&1; then
|
||||
die "redis-cli FLUSHDB failed on db $db"
|
||||
fi
|
||||
done
|
||||
log " -> redis dbs 11/12/13 flushed"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. Load env
|
||||
# -----------------------------------------------------------------------------
|
||||
log "sourcing env.sh"
|
||||
# shellcheck source=./env.sh
|
||||
source "$SCRIPT_DIR/env.sh"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. Start services
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Pick Flask / python binaries from the repo venv when present.
|
||||
if [[ -x "$REPO_ROOT/.venv/bin/flask" ]]; then
|
||||
FLASK_BIN="$REPO_ROOT/.venv/bin/flask"
|
||||
else
|
||||
FLASK_BIN="$(command -v flask || true)"
|
||||
fi
|
||||
if [[ -z "$FLASK_BIN" ]]; then
|
||||
die "flask binary not found (.venv/bin/flask missing and no 'flask' on PATH)"
|
||||
fi
|
||||
|
||||
if [[ -x "$REPO_ROOT/.venv/bin/python" ]]; then
|
||||
PY_BIN="$REPO_ROOT/.venv/bin/python"
|
||||
else
|
||||
PY_BIN="$(command -v python3 || command -v python || true)"
|
||||
fi
|
||||
if [[ -z "$PY_BIN" ]]; then
|
||||
die "python binary not found (.venv/bin/python missing and no 'python3' on PATH)"
|
||||
fi
|
||||
|
||||
log "using flask=$FLASK_BIN python=$PY_BIN"
|
||||
|
||||
# ---- 4a. Mock LLM ------------------------------------------------------------
|
||||
MOCK_LLM_LOG="$SVC_LOGDIR/mock-llm.log"
|
||||
MOCK_LLM_PID="$PIDDIR/mock-llm.pid"
|
||||
log "starting mock LLM on 127.0.0.1:$MOCK_LLM_PORT"
|
||||
(
|
||||
cd "$REPO_ROOT"
|
||||
# Port can be read from env by the script; we also export it for clarity.
|
||||
MOCK_LLM_PORT="$MOCK_LLM_PORT" PYTHONUNBUFFERED=1 nohup "$PY_BIN" scripts/e2e/mock_llm.py \
|
||||
>"$MOCK_LLM_LOG" 2>&1 &
|
||||
echo $! > "$MOCK_LLM_PID"
|
||||
)
|
||||
STARTED_SERVICES+=("mock-llm")
|
||||
|
||||
if ! wait_for "mock-llm /healthz" 10 \
|
||||
curl -sf "http://127.0.0.1:${MOCK_LLM_PORT}/healthz"; then
|
||||
boot_fail "mock-llm" "$MOCK_LLM_LOG" "healthz did not respond within 10s"
|
||||
fi
|
||||
|
||||
# ---- 4b. Flask ---------------------------------------------------------------
|
||||
FLASK_LOG="$SVC_LOGDIR/flask.log"
|
||||
FLASK_PID="$PIDDIR/flask.pid"
|
||||
log "starting Flask on 127.0.0.1:$FLASK_PORT"
|
||||
(
|
||||
cd "$E2E_TMP"
|
||||
PYTHONUNBUFFERED=1 nohup "$FLASK_BIN" --app ../application/app.py run \
|
||||
--host 127.0.0.1 --port "$FLASK_PORT" \
|
||||
>"$FLASK_LOG" 2>&1 &
|
||||
echo $! > "$FLASK_PID"
|
||||
)
|
||||
STARTED_SERVICES+=("flask")
|
||||
|
||||
if ! wait_for "flask /api/config" 30 \
|
||||
curl -sf "http://127.0.0.1:${FLASK_PORT}/api/config"; then
|
||||
boot_fail "flask" "$FLASK_LOG" "/api/config did not respond within 30s"
|
||||
fi
|
||||
|
||||
# ---- 4c. Celery --------------------------------------------------------------
|
||||
CELERY_LOG="$SVC_LOGDIR/celery.log"
|
||||
CELERY_PID="$PIDDIR/celery.pid"
|
||||
log "starting Celery worker (solo pool)"
|
||||
(
|
||||
cd "$E2E_TMP"
|
||||
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
nohup "$PY_BIN" -m celery -A application.app.celery worker \
|
||||
-l INFO --pool=solo -Q docsgpt,parsing \
|
||||
--without-gossip --without-mingle --without-heartbeat \
|
||||
>"$CELERY_LOG" 2>&1 &
|
||||
echo $! > "$CELERY_PID"
|
||||
)
|
||||
STARTED_SERVICES+=("celery")
|
||||
|
||||
# Celery's "ready" banner contains both "celery@<host>" and "ready.". Wait for
|
||||
# both in sequence so we know the worker actually finished bootstrapping.
|
||||
if ! wait_for_log "celery 'celery@'" 30 "$CELERY_LOG" "celery@"; then
|
||||
boot_fail "celery" "$CELERY_LOG" "never emitted 'celery@' banner within 30s"
|
||||
fi
|
||||
|
||||
# Ready check via `celery inspect ping`. We can't grep the log for 'ready'
|
||||
# because application/core/logging_config.py calls dictConfig with the default
|
||||
# disable_existing_loggers=True, which silences celery.worker's ready banner.
|
||||
# `inspect ping` queries the worker over the broker — it's the canonical
|
||||
# responsiveness check and doesn't depend on log output.
|
||||
CELERY_INSPECT_TIMEOUT=45
|
||||
elapsed=0
|
||||
ping_ok=0
|
||||
while (( elapsed < CELERY_INSPECT_TIMEOUT )); do
|
||||
if ( cd "$E2E_TMP" && \
|
||||
PYTHONPATH="$REPO_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
"$PY_BIN" -m celery -A application.app.celery inspect ping \
|
||||
--timeout 2 >/dev/null 2>&1 ); then
|
||||
ping_ok=1
|
||||
log " -> celery inspect ping OK after ${elapsed}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$(( elapsed + 1 ))
|
||||
done
|
||||
if (( ping_ok == 0 )); then
|
||||
boot_fail "celery" "$CELERY_LOG" "worker did not respond to 'inspect ping' within ${CELERY_INSPECT_TIMEOUT}s"
|
||||
fi
|
||||
|
||||
# ---- 4d. Vite dev server -----------------------------------------------------
|
||||
VITE_LOG="$SVC_LOGDIR/vite.log"
|
||||
VITE_PID="$PIDDIR/vite.pid"
|
||||
log "starting Vite dev server on 127.0.0.1:$VITE_PORT"
|
||||
(
|
||||
cd "$REPO_ROOT/frontend"
|
||||
VITE_API_HOST="http://127.0.0.1:${FLASK_PORT}" nohup npm run dev -- \
|
||||
--host 127.0.0.1 --port "$VITE_PORT" --strictPort \
|
||||
>"$VITE_LOG" 2>&1 &
|
||||
echo $! > "$VITE_PID"
|
||||
)
|
||||
STARTED_SERVICES+=("vite")
|
||||
|
||||
# Prefer nc; fall back to lsof. Either succeeding means the port is LISTEN.
|
||||
vite_ready() {
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z 127.0.0.1 "$VITE_PORT" >/dev/null 2>&1 && return 0
|
||||
fi
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
[[ -n "$(lsof -nP -iTCP:"$VITE_PORT" -sTCP:LISTEN -t 2>/dev/null)" ]] && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! wait_for "vite LISTEN on $VITE_PORT" 20 vite_ready; then
|
||||
boot_fail "vite" "$VITE_LOG" "port $VITE_PORT never entered LISTEN within 20s"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. Handoff
|
||||
# -----------------------------------------------------------------------------
|
||||
log "all services up:"
|
||||
log " mock-llm pid=$(cat "$MOCK_LLM_PID") log=$MOCK_LLM_LOG"
|
||||
log " flask pid=$(cat "$FLASK_PID") log=$FLASK_LOG"
|
||||
log " celery pid=$(cat "$CELERY_PID") log=$CELERY_LOG"
|
||||
log " vite pid=$(cat "$VITE_PID") log=$VITE_LOG"
|
||||
log "handoff complete — exiting 0, services remain running. Run scripts/e2e/down.sh to stop."
|
||||
|
||||
HANDOFF_OK=1
|
||||
exit 0
|
||||
Reference in New Issue
Block a user