mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 08:15:43 +00:00
Pg 4 (#2390)
* feat: postgres tests * feat: mongo cutoff * feat: mongo cutoff * feat: adjust docs and compose files * fix: mini code mongo removals * fix: tests and k8s mongo stuff * feat: test fixes * fix: ruff * fix: vale * Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: mini suggestions * vale lint fix 2 * fix: codeql columns thing * fix: test mongo * fix: tests coverage * feat: better tests 4 * feat: more tests * feat: decent coverage * fix: ruff fixes * fix: remove mongo mock * feat: enhance workflow engine and API routes; add document retrieval and source handling * feat: e2e tests * fix: mcp, mongo and more * fix: mini codeql warning * fix: agent chunk view * fix: mini issues * fix: more pg fixes * feat: postgres prep on start * feat: qa tests * fix: mini improvements * fix: tests --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Siddhant Rai <siddhant.rai.5686@gmail.com>
This commit is contained in:
312 files changed
+763725
-25159
No files matched your search
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
+70
@@ -0,0 +1,70 @@
|
||||
#!/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="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"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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,452 @@
|
||||
"""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,
|
||||
):
|
||||
"""Generator yielding SSE frames that match the OpenAI streaming protocol."""
|
||||
|
||||
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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# 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.
|
||||
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):
|
||||
yield _sse(_base_chunk({"content": piece}, final=(i == last_index)))
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if stream:
|
||||
generator = _stream_chat_response(
|
||||
digest=digest,
|
||||
model=model,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
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()
|
||||
Whitespace-only changes.
@@ -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.
|
||||
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 \
|
||||
--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