대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
396
scripts/check-phase3-artifacts.py
Normal file
396
scripts/check-phase3-artifacts.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate Phase 3 pilot evidence artifacts without modifying source data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
FORBIDDEN_HEADER_TERMS = {
|
||||
"address",
|
||||
"api_key",
|
||||
"birthdate",
|
||||
"cookie",
|
||||
"date_of_birth",
|
||||
"dob",
|
||||
"email",
|
||||
"full_name",
|
||||
"guardian_name",
|
||||
"identity_map",
|
||||
"national_id",
|
||||
"participant_name",
|
||||
"phone",
|
||||
"raw_audio",
|
||||
"raw_source_case",
|
||||
"raw_voice",
|
||||
"secret",
|
||||
"ssn",
|
||||
"student_id",
|
||||
"student_name",
|
||||
"token",
|
||||
}
|
||||
|
||||
EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE)
|
||||
PHONE_RE = re.compile(r"(?:\+?\d[\d .()-]{7,}\d)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CsvSpec:
|
||||
path: str
|
||||
headers: tuple[str, ...]
|
||||
|
||||
|
||||
CSV_SPECS = (
|
||||
CsvSpec(
|
||||
"00-intake/pilot_roster.csv",
|
||||
(
|
||||
"participant_id",
|
||||
"cohort_id",
|
||||
"consent_version",
|
||||
"consent_signed_at",
|
||||
"withdrawal_state",
|
||||
"enrolled_at",
|
||||
),
|
||||
),
|
||||
CsvSpec(
|
||||
"00-intake/consent_receipts.csv",
|
||||
("participant_id", "consent_version", "signed_at", "signer_role", "receipt_id"),
|
||||
),
|
||||
CsvSpec(
|
||||
"01-sessions/session_completion.csv",
|
||||
(
|
||||
"participant_id",
|
||||
"session_id",
|
||||
"persona_id",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"completion_state",
|
||||
"turns_count",
|
||||
"supervisor_reviewed_at",
|
||||
),
|
||||
),
|
||||
CsvSpec(
|
||||
"02-measures/prepost_measures.csv",
|
||||
("participant_id", "measure_name", "timepoint", "score", "collected_at"),
|
||||
),
|
||||
CsvSpec(
|
||||
"02-measures/sus_responses.csv",
|
||||
("participant_id", "item", "response", "collected_at"),
|
||||
),
|
||||
CsvSpec(
|
||||
"04-privacy/withdrawal_log.csv",
|
||||
("participant_id", "requested_at", "effective_at", "scope", "status", "attestation_path"),
|
||||
),
|
||||
)
|
||||
|
||||
REQUIRED_MARKDOWN = ("04-privacy/privacy_audit.md",)
|
||||
|
||||
KPI_METRICS = {
|
||||
"embedding_consistency",
|
||||
"hallucination_rate",
|
||||
"icc",
|
||||
"inter_rater_kappa",
|
||||
"pilot_completion",
|
||||
"self_efficacy_prepost",
|
||||
"session_completion",
|
||||
"sus",
|
||||
"top1",
|
||||
}
|
||||
|
||||
MANIFEST_KEYS = {
|
||||
"agreement",
|
||||
"anonymization",
|
||||
"approvals",
|
||||
"consent_scope",
|
||||
"created_at",
|
||||
"dataset_name",
|
||||
"export_id",
|
||||
"export_status",
|
||||
"files",
|
||||
"pii_scan",
|
||||
"purpose",
|
||||
}
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
self.info: list[str] = []
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
self.warnings.append(message)
|
||||
|
||||
def note(self, message: str) -> None:
|
||||
self.info.append(message)
|
||||
|
||||
def to_dict(self, evidence_root: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"evidence_root": str(evidence_root),
|
||||
"status": "pass" if not self.errors else "fail",
|
||||
"errors": self.errors,
|
||||
"warnings": self.warnings,
|
||||
"info": self.info,
|
||||
}
|
||||
|
||||
|
||||
def read_csv_header(path: Path, report: Report) -> list[str] | None:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.reader(handle)
|
||||
header = next(reader, None)
|
||||
if not header:
|
||||
report.error(f"{path}: empty CSV or missing header")
|
||||
return None
|
||||
return [cell.strip() for cell in header]
|
||||
except UnicodeDecodeError:
|
||||
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
|
||||
except OSError as exc:
|
||||
report.error(f"{path}: cannot read CSV: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def scan_csv_values(path: Path, report: Report, max_rows: int) -> None:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
for row_number, row in enumerate(reader, start=2):
|
||||
if row_number > max_rows + 1:
|
||||
report.note(f"{path}: scanned first {max_rows} data rows for identifier patterns")
|
||||
return
|
||||
for column, value in row.items():
|
||||
if not value:
|
||||
continue
|
||||
if EMAIL_RE.search(value):
|
||||
report.error(f"{path}:{row_number}: possible email in column '{column}'")
|
||||
elif column and column.lower() not in {"turns_count", "score", "response", "item"}:
|
||||
if PHONE_RE.fullmatch(value.strip()):
|
||||
report.warn(f"{path}:{row_number}: possible phone-like value in column '{column}'")
|
||||
except UnicodeDecodeError:
|
||||
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
|
||||
except OSError as exc:
|
||||
report.error(f"{path}: cannot scan CSV: {exc}")
|
||||
|
||||
|
||||
def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int) -> None:
|
||||
path = root / spec.path
|
||||
if not path.exists():
|
||||
report.error(f"missing required file: {spec.path}")
|
||||
return
|
||||
header = read_csv_header(path, report)
|
||||
if header is None:
|
||||
return
|
||||
|
||||
missing = [name for name in spec.headers if name not in header]
|
||||
if missing:
|
||||
report.error(f"{spec.path}: missing required headers: {', '.join(missing)}")
|
||||
|
||||
forbidden = sorted(
|
||||
column for column in header for term in FORBIDDEN_HEADER_TERMS if term in column.lower()
|
||||
)
|
||||
if forbidden:
|
||||
report.error(f"{spec.path}: forbidden identifier/secret-like headers: {', '.join(forbidden)}")
|
||||
|
||||
scan_csv_values(path, report, max_rows=max_scan_rows)
|
||||
|
||||
|
||||
def read_json(path: Path, report: Report) -> dict[str, Any] | None:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except json.JSONDecodeError as exc:
|
||||
report.error(f"{path}: invalid JSON: {exc}")
|
||||
return None
|
||||
except UnicodeDecodeError:
|
||||
report.error(f"{path}: not valid UTF-8")
|
||||
return None
|
||||
except OSError as exc:
|
||||
report.error(f"{path}: cannot read JSON: {exc}")
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
report.error(f"{path}: top-level JSON must be an object")
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def validate_kpi_report(root: Path, report: Report) -> None:
|
||||
rel_path = "02-measures/kpi_report.json"
|
||||
path = root / rel_path
|
||||
if not path.exists():
|
||||
report.error(f"missing required file: {rel_path}")
|
||||
return
|
||||
data = read_json(path, report)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
metrics = data.get("metrics")
|
||||
if not isinstance(metrics, dict):
|
||||
report.error(f"{rel_path}: missing object key 'metrics'")
|
||||
return
|
||||
missing = sorted(KPI_METRICS - set(metrics))
|
||||
if missing:
|
||||
report.error(f"{rel_path}: missing metric keys: {', '.join(missing)}")
|
||||
|
||||
for metric_name, metric in metrics.items():
|
||||
if not isinstance(metric, dict):
|
||||
report.error(f"{rel_path}: metric '{metric_name}' must be an object")
|
||||
continue
|
||||
for key in ("value", "threshold", "pass", "method", "source_files"):
|
||||
if key not in metric:
|
||||
report.warn(f"{rel_path}: metric '{metric_name}' missing '{key}'")
|
||||
|
||||
|
||||
def validate_manifest(root: Path, report: Report) -> None:
|
||||
rel_path = "03-export/export_manifest.json"
|
||||
path = root / rel_path
|
||||
if not path.exists():
|
||||
report.error(f"missing required file: {rel_path}")
|
||||
return
|
||||
data = read_json(path, report)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
missing = sorted(MANIFEST_KEYS - set(data))
|
||||
if missing:
|
||||
report.error(f"{rel_path}: missing keys: {', '.join(missing)}")
|
||||
|
||||
status = data.get("export_status")
|
||||
if status == "approved_for_recursive_learning_seed":
|
||||
approvals = data.get("approvals")
|
||||
if not isinstance(approvals, dict):
|
||||
report.error(f"{rel_path}: approved export requires approvals object")
|
||||
else:
|
||||
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
|
||||
if not approvals.get(key):
|
||||
report.error(f"{rel_path}: approved export missing approval '{key}'")
|
||||
|
||||
files = data.get("files")
|
||||
if isinstance(files, list):
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
report.error(f"{rel_path}: files[] entries must be objects")
|
||||
continue
|
||||
for key in ("path", "rows", "sha256", "schema"):
|
||||
if key not in item:
|
||||
report.warn(f"{rel_path}: file entry missing '{key}'")
|
||||
elif files is not None:
|
||||
report.error(f"{rel_path}: 'files' must be a list")
|
||||
|
||||
|
||||
def validate_markdown(root: Path, report: Report) -> None:
|
||||
for rel_path in REQUIRED_MARKDOWN:
|
||||
path = root / rel_path
|
||||
if not path.exists():
|
||||
report.error(f"missing required file: {rel_path}")
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
report.error(f"{rel_path}: not valid UTF-8")
|
||||
continue
|
||||
except OSError as exc:
|
||||
report.error(f"{rel_path}: cannot read Markdown: {exc}")
|
||||
continue
|
||||
if not content.strip():
|
||||
report.error(f"{rel_path}: file is empty")
|
||||
if "Legal/privacy reviewer:" not in content:
|
||||
report.warn(f"{rel_path}: expected legal/privacy reviewer field")
|
||||
|
||||
|
||||
def validate(root: Path, max_scan_rows: int) -> Report:
|
||||
report = Report()
|
||||
if not root.exists():
|
||||
report.error(f"evidence root does not exist: {root}")
|
||||
return report
|
||||
if not root.is_dir():
|
||||
report.error(f"evidence root is not a directory: {root}")
|
||||
return report
|
||||
|
||||
for spec in CSV_SPECS:
|
||||
validate_csv(root, spec, report, max_scan_rows=max_scan_rows)
|
||||
validate_kpi_report(root, report)
|
||||
validate_manifest(root, report)
|
||||
validate_markdown(root, report)
|
||||
return report
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only Phase 3 evidence checker. Writes only when --output is provided."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Run validation checks. This is also the default behavior.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Alias for read-only validation; kept for operator clarity.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evidence-root",
|
||||
type=Path,
|
||||
default=Path("evidence/phase3"),
|
||||
help="Directory containing Phase 3 evidence files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
help="Optional JSON report path. No files are written unless this is set.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the validation report as JSON instead of text.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-scan-rows",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Maximum data rows per CSV to scan for obvious identifier patterns.",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def print_text_report(data: dict[str, Any]) -> None:
|
||||
print(f"Phase 3 artifact check: {data['status']}")
|
||||
print(f"Evidence root: {data['evidence_root']}")
|
||||
for label in ("errors", "warnings", "info"):
|
||||
items = data[label]
|
||||
if not items:
|
||||
continue
|
||||
print(f"\n{label.upper()}:")
|
||||
for item in items:
|
||||
print(f"- {item}")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
report = validate(args.evidence_root, max_scan_rows=max(0, args.max_scan_rows))
|
||||
data = report.to_dict(args.evidence_root)
|
||||
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=True))
|
||||
else:
|
||||
print_text_report(data)
|
||||
|
||||
return 0 if not report.errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
626
scripts/check-postgres-rls-audit.py
Normal file
626
scripts/check-postgres-rls-audit.py
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Local opt-in Postgres RLS/audit smoke checks for Vignette.
|
||||
|
||||
The script creates a small set of uniquely tagged fixture rows, verifies the
|
||||
live RLS/audit behavior that unit tests mock, then removes only those rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DSN_ENV_VARS = ("VIGNETTE_RLS_AUDIT_DSN", "POSTGRES_RLS_AUDIT_DSN")
|
||||
LOCAL_HOSTS = {"", "localhost", "127.0.0.1", "::1", "db", "postgres"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmokeState:
|
||||
run_id: str
|
||||
cohort_a: str
|
||||
cohort_b: str
|
||||
learner_a: str | None = None
|
||||
learner_b: str | None = None
|
||||
teacher_a: str | None = None
|
||||
admin: str | None = None
|
||||
session_a: str | None = None
|
||||
session_b: str | None = None
|
||||
created_user_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Run a read/write smoke test against a local/dev Postgres database "
|
||||
"to prove session RLS, visible_to filtering, cohort scoping, and "
|
||||
"audit insert behavior."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dsn",
|
||||
default=next((os.environ.get(name) for name in DSN_ENV_VARS if os.environ.get(name)), None),
|
||||
help=(
|
||||
"Postgres DSN. May also be supplied via VIGNETTE_RLS_AUDIT_DSN "
|
||||
"or POSTGRES_RLS_AUDIT_DSN. DATABASE_URL is intentionally not read."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write-fixtures",
|
||||
action="store_true",
|
||||
help="Required opt-in: insert uniquely tagged fixture rows and clean them up afterward.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-fixtures",
|
||||
action="store_true",
|
||||
help="Leave the created fixture rows in place for manual inspection.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run-id",
|
||||
default=None,
|
||||
help="Optional run id. Defaults to a random rls-audit-smoke-* id.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-non-local",
|
||||
action="store_true",
|
||||
help="Allow a DSN host outside localhost/db/postgres. Intended only for disposable dev DBs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-skips",
|
||||
action="store_true",
|
||||
help="Return exit code 0 when checks are skipped. By default skipped proof is nonzero.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
||||
return parser
|
||||
|
||||
|
||||
def dsn_host(dsn: str) -> str:
|
||||
parsed = urlparse(dsn)
|
||||
if parsed.hostname:
|
||||
return parsed.hostname
|
||||
if parsed.scheme and parsed.path and not parsed.netloc:
|
||||
return ""
|
||||
return parsed.hostname or ""
|
||||
|
||||
|
||||
def is_local_dsn(dsn: str) -> bool:
|
||||
host = dsn_host(dsn)
|
||||
if host in LOCAL_HOSTS:
|
||||
return True
|
||||
try:
|
||||
return socket.gethostbyname(host).startswith("127.")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def print_prerequisites(parser: argparse.ArgumentParser) -> None:
|
||||
parser.print_help(sys.stderr)
|
||||
print(
|
||||
"\nPrerequisites:\n"
|
||||
" - Run against a local/dev Postgres DB initialized with infra/db/init/*.sql.\n"
|
||||
" - Use a non-owner app role with NOBYPASSRLS, for example vignette_app.\n"
|
||||
" - Supply a DSN explicitly via --dsn, VIGNETTE_RLS_AUDIT_DSN, or POSTGRES_RLS_AUDIT_DSN.\n"
|
||||
" - Pass --write-fixtures to permit temporary fixture inserts.\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
async def set_context(
|
||||
conn: Any,
|
||||
*,
|
||||
role: str | None = None,
|
||||
uid: str | None = None,
|
||||
cohort: str | None = None,
|
||||
ai_view: str | None = None,
|
||||
) -> None:
|
||||
await conn.execute("SELECT set_config('app.ai_context', $1, true)", "1" if ai_view else "")
|
||||
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view or "")
|
||||
await conn.execute("SELECT set_config('app.current_role', $1, true)", role or "")
|
||||
await conn.execute("SELECT set_config('app.current_uid', $1, true)", uid or "")
|
||||
await conn.execute("SELECT set_config('app.current_cohort', $1, true)", cohort or "")
|
||||
await conn.execute(
|
||||
"SELECT set_config('app.current_sens_max', $1, true)",
|
||||
{"client": "1", "counselor": "0", "evaluator": "2"}.get(ai_view or "", ""),
|
||||
)
|
||||
|
||||
|
||||
async def fetchval_as(
|
||||
conn: Any,
|
||||
query: str,
|
||||
*args: Any,
|
||||
role: str | None = None,
|
||||
uid: str | None = None,
|
||||
cohort: str | None = None,
|
||||
ai_view: str | None = None,
|
||||
) -> Any:
|
||||
async with conn.transaction():
|
||||
await set_context(conn, role=role, uid=uid, cohort=cohort, ai_view=ai_view)
|
||||
return await conn.fetchval(query, *args)
|
||||
|
||||
|
||||
async def execute_as(
|
||||
conn: Any,
|
||||
query: str,
|
||||
*args: Any,
|
||||
role: str | None = None,
|
||||
uid: str | None = None,
|
||||
cohort: str | None = None,
|
||||
ai_view: str | None = None,
|
||||
) -> str:
|
||||
async with conn.transaction():
|
||||
await set_context(conn, role=role, uid=uid, cohort=cohort, ai_view=ai_view)
|
||||
return await conn.execute(query, *args)
|
||||
|
||||
|
||||
async def preflight(conn: Any) -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
role_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.rolname, r.rolsuper, r.rolbypassrls
|
||||
FROM pg_roles r
|
||||
WHERE r.rolname = current_user
|
||||
"""
|
||||
)
|
||||
if role_row and (role_row["rolsuper"] or role_row["rolbypassrls"]):
|
||||
results.append(
|
||||
CheckResult(
|
||||
"preflight.rls_role",
|
||||
"fail",
|
||||
f"current_user={role_row['rolname']} can bypass RLS; use a NOBYPASSRLS app role",
|
||||
)
|
||||
)
|
||||
|
||||
owner_tables = await conn.fetch(
|
||||
"""
|
||||
SELECT n.nspname || '.' || c.relname AS table_name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname IN ('app','audit')
|
||||
AND c.relname IN ('sessions','turns','app_user','audit_log')
|
||||
AND pg_get_userbyid(c.relowner) = current_user
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
if owner_tables:
|
||||
table_list = ", ".join(row["table_name"] for row in owner_tables)
|
||||
results.append(
|
||||
CheckResult(
|
||||
"preflight.table_owner",
|
||||
"fail",
|
||||
f"current_user owns RLS target tables ({table_list}) and can bypass policies",
|
||||
)
|
||||
)
|
||||
|
||||
required = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
to_regclass('app.app_user') IS NOT NULL AS has_user,
|
||||
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
|
||||
to_regclass('app.turns') IS NOT NULL AS has_turns,
|
||||
to_regclass('audit.audit_log') IS NOT NULL AS has_audit,
|
||||
to_regprocedure('app.current_uid()') IS NOT NULL AS has_current_uid,
|
||||
to_regprocedure('app.current_role_name()') IS NOT NULL AS has_current_role,
|
||||
to_regprocedure('app.is_ai_context()') IS NOT NULL AS has_ai_context
|
||||
"""
|
||||
)
|
||||
missing = [key.removeprefix("has_") for key, value in dict(required or {}).items() if not value]
|
||||
if missing:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"preflight.schema",
|
||||
"skip",
|
||||
"cannot prove RLS/audit behavior; missing schema objects: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
)
|
||||
|
||||
policies = await conn.fetch(
|
||||
"""
|
||||
SELECT schemaname, tablename, policyname
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'app'
|
||||
AND (tablename, policyname) IN (
|
||||
('sessions','p_sessions_select'),
|
||||
('turns','p_turns_select')
|
||||
)
|
||||
"""
|
||||
)
|
||||
found = {(row["tablename"], row["policyname"]) for row in policies}
|
||||
expected = {("sessions", "p_sessions_select"), ("turns", "p_turns_select")}
|
||||
missing_policies = sorted(f"{table}.{policy}" for table, policy in expected - found)
|
||||
if missing_policies:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"preflight.policies",
|
||||
"skip",
|
||||
"cannot prove RLS behavior; missing policies: " + ", ".join(missing_policies),
|
||||
)
|
||||
)
|
||||
|
||||
if not results:
|
||||
results.append(CheckResult("preflight", "pass", "schema, policies, and DB role are smoke-testable"))
|
||||
return results
|
||||
|
||||
|
||||
async def create_fixtures(conn: Any, run_id: str) -> SmokeState:
|
||||
state = SmokeState(
|
||||
run_id=run_id,
|
||||
cohort_a=f"{run_id}-cohort-a",
|
||||
cohort_b=f"{run_id}-cohort-b",
|
||||
)
|
||||
prefix = f"rls-audit-smoke:{run_id}"
|
||||
users = [
|
||||
("learner_a", "learner", state.cohort_a),
|
||||
("learner_b", "learner", state.cohort_b),
|
||||
("teacher_a", "instructor", state.cohort_a),
|
||||
("admin", "admin", None),
|
||||
]
|
||||
for key, role, cohort in users:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.app_user (external_id, email, display_name, role, cohort, consent_at)
|
||||
VALUES ($1, NULL, $2, $3, $4, now())
|
||||
RETURNING user_id
|
||||
""",
|
||||
f"{prefix}:{key}",
|
||||
f"RLS smoke {key} {run_id}",
|
||||
role,
|
||||
cohort,
|
||||
)
|
||||
user_id = str(row["user_id"])
|
||||
setattr(state, key, user_id)
|
||||
state.created_user_ids.append(user_id)
|
||||
|
||||
async with conn.transaction():
|
||||
await set_context(conn, role="admin", uid=state.admin)
|
||||
state.session_a = str(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO app.sessions (
|
||||
learner_id, runtime_case_id, persona_code, persona_display_name,
|
||||
persona_difficulty, session_no, theory_mode, stage_path
|
||||
)
|
||||
VALUES ($1::uuid, gen_random_uuid(), $2, $3, 'easy', 1, 'humanistic', '[]'::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
state.learner_a,
|
||||
"RLS_SMOKE_A",
|
||||
f"RLS Smoke A {run_id}",
|
||||
)
|
||||
)
|
||||
state.session_b = str(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO app.sessions (
|
||||
learner_id, runtime_case_id, persona_code, persona_display_name,
|
||||
persona_difficulty, session_no, theory_mode, stage_path
|
||||
)
|
||||
VALUES ($1::uuid, gen_random_uuid(), $2, $3, 'easy', 1, 'humanistic', '[]'::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
state.learner_b,
|
||||
"RLS_SMOKE_B",
|
||||
f"RLS Smoke B {run_id}",
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.turns (session_id, seq, speaker, text, text_masked, actor_kind, visible_to)
|
||||
VALUES
|
||||
($1::uuid, 1, 'counselor', $3, $3, 'human_learner', ARRAY['client','counselor','evaluator']),
|
||||
($1::uuid, 2, 'client', $4, $4, 'client_ai', ARRAY['client','counselor','evaluator']),
|
||||
($1::uuid, 3, 'counselor', $5, $5, 'evaluator_ai', ARRAY['evaluator']),
|
||||
($2::uuid, 1, 'counselor', $6, $6, 'human_learner', ARRAY['client','counselor','evaluator'])
|
||||
""",
|
||||
state.session_a,
|
||||
state.session_b,
|
||||
f"{run_id} learner A visible counselor turn",
|
||||
f"{run_id} learner A visible client turn",
|
||||
f"{run_id} evaluator-only hidden turn",
|
||||
f"{run_id} learner B visible counselor turn",
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
async def cleanup_fixtures(conn: Any, state: SmokeState) -> None:
|
||||
prefix = f"rls-audit-smoke:{state.run_id}:%"
|
||||
# Sessions/turns/audit are RLS-protected, so deletes must run inside the same
|
||||
# admin context the fixtures were created under; otherwise the NOBYPASSRLS app
|
||||
# role silently deletes 0 rows and the app_user delete fails on a FK reference.
|
||||
async with conn.transaction():
|
||||
await set_context(conn, role="admin", uid=state.admin)
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM audit.audit_log
|
||||
WHERE detail->>'smoke_run_id' = $1 OR target_id LIKE $2
|
||||
""",
|
||||
state.run_id,
|
||||
prefix,
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.turns WHERE session_id = ANY($1::uuid[])",
|
||||
[sid for sid in (state.session_a, state.session_b) if sid],
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.sessions WHERE learner_id = ANY($1::uuid[])",
|
||||
state.created_user_ids,
|
||||
)
|
||||
# app_user is not RLS-protected; delete it last once sessions no longer ref it.
|
||||
await conn.execute(
|
||||
"DELETE FROM app.app_user WHERE external_id LIKE $1",
|
||||
prefix,
|
||||
)
|
||||
|
||||
|
||||
async def check_learner_isolation(conn: Any, state: SmokeState) -> CheckResult:
|
||||
own = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_a,
|
||||
role="learner",
|
||||
uid=state.learner_a,
|
||||
)
|
||||
other = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_b,
|
||||
role="learner",
|
||||
uid=state.learner_a,
|
||||
)
|
||||
other_turns = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid",
|
||||
state.session_b,
|
||||
role="learner",
|
||||
uid=state.learner_a,
|
||||
)
|
||||
if own == 1 and other == 0 and other_turns == 0:
|
||||
return CheckResult(
|
||||
"learner.session_turn_isolation",
|
||||
"pass",
|
||||
"learner A can read own session and cannot read learner B session or turns",
|
||||
)
|
||||
return CheckResult(
|
||||
"learner.session_turn_isolation",
|
||||
"fail",
|
||||
f"expected own=1 other=0 other_turns=0; got own={own} other={other} other_turns={other_turns}",
|
||||
)
|
||||
|
||||
|
||||
async def check_teacher_cohort(conn: Any, state: SmokeState) -> CheckResult:
|
||||
visible_a = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_a,
|
||||
role="instructor",
|
||||
uid=state.teacher_a,
|
||||
cohort=state.cohort_a,
|
||||
)
|
||||
visible_b = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_b,
|
||||
role="instructor",
|
||||
uid=state.teacher_a,
|
||||
cohort=state.cohort_a,
|
||||
)
|
||||
if visible_a == 1 and visible_b == 0:
|
||||
return CheckResult(
|
||||
"teacher.cohort_scoped_sessions",
|
||||
"pass",
|
||||
"instructor scoped to cohort A sees learner A session and not cohort B session",
|
||||
)
|
||||
return CheckResult(
|
||||
"teacher.cohort_scoped_sessions",
|
||||
"fail",
|
||||
f"expected cohort A session=1 cohort B session=0; got {visible_a} and {visible_b}",
|
||||
)
|
||||
|
||||
|
||||
async def check_visible_to(conn: Any, state: SmokeState) -> CheckResult:
|
||||
learner_count = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
|
||||
state.session_a,
|
||||
role="learner",
|
||||
uid=state.learner_a,
|
||||
)
|
||||
client_ai_count = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
|
||||
state.session_a,
|
||||
ai_view="client",
|
||||
)
|
||||
counselor_ai_count = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
|
||||
state.session_a,
|
||||
ai_view="counselor",
|
||||
)
|
||||
evaluator_ai_count = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
|
||||
state.session_a,
|
||||
ai_view="evaluator",
|
||||
)
|
||||
if (learner_count, client_ai_count, counselor_ai_count, evaluator_ai_count) == (0, 0, 0, 1):
|
||||
return CheckResult(
|
||||
"visible_to.evaluator_only_hidden",
|
||||
"pass",
|
||||
"evaluator-only turn is hidden from learner/client/counselor AI and visible to evaluator AI",
|
||||
)
|
||||
return CheckResult(
|
||||
"visible_to.evaluator_only_hidden",
|
||||
"fail",
|
||||
"expected learner/client/counselor/evaluator counts 0/0/0/1; got "
|
||||
f"{learner_count}/{client_ai_count}/{counselor_ai_count}/{evaluator_ai_count}",
|
||||
)
|
||||
|
||||
|
||||
async def check_audit_insert(conn: Any, state: SmokeState) -> CheckResult:
|
||||
target_teacher = f"rls-audit-smoke:{state.run_id}:teacher-read:{state.session_a}"
|
||||
target_admin = f"rls-audit-smoke:{state.run_id}:admin-read:{state.session_a}"
|
||||
teacher_can_read = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_a,
|
||||
role="instructor",
|
||||
uid=state.teacher_a,
|
||||
cohort=state.cohort_a,
|
||||
)
|
||||
admin_can_read = await fetchval_as(
|
||||
conn,
|
||||
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
|
||||
state.session_a,
|
||||
role="admin",
|
||||
uid=state.admin,
|
||||
)
|
||||
if teacher_can_read != 1 or admin_can_read != 1:
|
||||
return CheckResult(
|
||||
"audit.teacher_admin_read_insert",
|
||||
"fail",
|
||||
f"read path not visible before audit insert; instructor={teacher_can_read} admin={admin_can_read}",
|
||||
)
|
||||
await execute_as(
|
||||
conn,
|
||||
"""
|
||||
INSERT INTO audit.audit_log (actor_uid, action, target_kind, target_id, detail)
|
||||
VALUES ($1::uuid, 'read_session', 'session', $2, $3::jsonb)
|
||||
""",
|
||||
state.teacher_a,
|
||||
target_teacher,
|
||||
json.dumps({"smoke_run_id": state.run_id, "role": "instructor"}),
|
||||
role="instructor",
|
||||
uid=state.teacher_a,
|
||||
cohort=state.cohort_a,
|
||||
)
|
||||
await execute_as(
|
||||
conn,
|
||||
"""
|
||||
INSERT INTO audit.audit_log (actor_uid, action, target_kind, target_id, detail)
|
||||
VALUES ($1::uuid, 'read_session', 'session', $2, $3::jsonb)
|
||||
""",
|
||||
state.admin,
|
||||
target_admin,
|
||||
json.dumps({"smoke_run_id": state.run_id, "role": "admin"}),
|
||||
role="admin",
|
||||
uid=state.admin,
|
||||
)
|
||||
count = await conn.fetchval(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM audit.audit_log
|
||||
WHERE detail->>'smoke_run_id' = $1
|
||||
AND action = 'read_session'
|
||||
AND target_id IN ($2, $3)
|
||||
""",
|
||||
state.run_id,
|
||||
target_teacher,
|
||||
target_admin,
|
||||
)
|
||||
if count == 2:
|
||||
return CheckResult(
|
||||
"audit.teacher_admin_read_insert",
|
||||
"pass",
|
||||
"instructor and admin read_session audit rows inserted and verified",
|
||||
)
|
||||
return CheckResult(
|
||||
"audit.teacher_admin_read_insert",
|
||||
"fail",
|
||||
f"expected 2 read_session audit rows for run id; got {count}",
|
||||
)
|
||||
|
||||
|
||||
async def run_smoke(args: argparse.Namespace) -> tuple[list[CheckResult], SmokeState | None]:
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as exc:
|
||||
return [CheckResult("preflight.asyncpg", "fail", f"asyncpg is not installed: {exc}")], None
|
||||
|
||||
conn = await asyncpg.connect(args.dsn)
|
||||
state: SmokeState | None = None
|
||||
try:
|
||||
results = await preflight(conn)
|
||||
if any(result.status != "pass" for result in results):
|
||||
return results, None
|
||||
run_id = args.run_id or f"rls-audit-smoke-{uuid.uuid4().hex[:12]}"
|
||||
state = await create_fixtures(conn, run_id)
|
||||
results.extend(
|
||||
[
|
||||
await check_learner_isolation(conn, state),
|
||||
await check_teacher_cohort(conn, state),
|
||||
await check_visible_to(conn, state),
|
||||
await check_audit_insert(conn, state),
|
||||
]
|
||||
)
|
||||
return results, state
|
||||
finally:
|
||||
if state is not None and not args.keep_fixtures:
|
||||
await cleanup_fixtures(conn, state)
|
||||
await conn.close()
|
||||
|
||||
|
||||
def emit(results: list[CheckResult], state: SmokeState | None, *, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": state.run_id if state else None,
|
||||
"status": (
|
||||
"fail"
|
||||
if any(result.status == "fail" for result in results)
|
||||
else "skip"
|
||||
if any(result.status == "skip" for result in results)
|
||||
else "pass"
|
||||
),
|
||||
"checks": [result.__dict__ for result in results],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
if state:
|
||||
print(f"run_id: {state.run_id}")
|
||||
for result in results:
|
||||
print(f"{result.status.upper():4} {result.name}: {result.detail}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if not args.dsn or not args.write_fixtures:
|
||||
print_prerequisites(parser)
|
||||
return 2
|
||||
if not args.allow_non_local and not is_local_dsn(args.dsn):
|
||||
print(
|
||||
f"Refusing non-local DSN host '{dsn_host(args.dsn)}'. "
|
||||
"Use --allow-non-local only for disposable dev databases.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
results, state = asyncio.run(run_smoke(args))
|
||||
emit(results, state, as_json=args.json)
|
||||
has_fail = any(result.status == "fail" for result in results)
|
||||
has_skip = any(result.status == "skip" for result in results)
|
||||
if has_fail or (has_skip and not args.allow_skips):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
25
scripts/dev-down.ps1
Normal file
25
scripts/dev-down.ps1
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Vignette 로컬 개발 스택(게이트웨이 9099 + API 8000 + 웹 5173)을 정리한다.
|
||||
.DESCRIPTION
|
||||
커맨드라인 기준으로 정확히 종료한다(고아 워커/리로더 포함). scripts/dev-up.ps1 의 짝.
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
|
||||
#>
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Stop-Stale([string]$pattern, [string]$label) {
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } |
|
||||
ForEach-Object {
|
||||
Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId)
|
||||
Stop-Process -Id $_.ProcessId -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "로컬 dev 스택 종료..."
|
||||
Stop-Stale 'engine_gateway\.gateway' 'gateway'
|
||||
Stop-Stale 'app\.main:app' 'api'
|
||||
Stop-Stale 'vite' 'web'
|
||||
Start-Sleep -Seconds 1
|
||||
Write-Host "완료."
|
||||
135
scripts/dev-up.ps1
Normal file
135
scripts/dev-up.ps1
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Vignette 로컬 개발 스택을 깔끔하게 (재)기동한다: 엔진 게이트웨이(9099) + API(8000) + 웹(5173).
|
||||
|
||||
.DESCRIPTION
|
||||
- 기존(고아 포함) 프로세스를 커맨드라인 기준으로 정확히 정리한 뒤 새로 띄운다(--reload 미사용: 결정론적).
|
||||
- API는 DB 미가용 시 in-memory degraded로 기동된다(Postgres 불필요). dev-login + seed 페르소나 활성.
|
||||
- 로그는 .devlogs/ 에 남긴다. 종료는 scripts/dev-down.ps1.
|
||||
|
||||
.PARAMETER NoGateway
|
||||
엔진 게이트웨이를 띄우지 않는다(AI 턴 생성 불가, UI만 테스트 시).
|
||||
.PARAMETER NoWeb
|
||||
vite 웹 서버를 띄우지 않는다(API만 필요할 때).
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
||||
#>
|
||||
param(
|
||||
[switch]$NoGateway,
|
||||
[switch]$NoWeb,
|
||||
[switch]$NoDb
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$api = Join-Path $repo 'apps\api'
|
||||
$web = Join-Path $repo 'apps\web'
|
||||
$logs = Join-Path $repo '.devlogs'
|
||||
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||
|
||||
# uvicorn 이 설치된 python 을 해석한다(시스템에 3.11/3.14 등 복수 python 공존 — 'python' 별칭이
|
||||
# uvicorn 없는 인터프리터를 가리킬 수 있다). 후보를 순회해 import uvicorn 성공하는 것을 고른다.
|
||||
$pyCandidates = @(
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python311\python.exe'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'),
|
||||
'py',
|
||||
'python'
|
||||
)
|
||||
$Python = $null
|
||||
foreach ($c in $pyCandidates) {
|
||||
$exe = $c; $pre = @()
|
||||
if ($c -eq 'py') { $pre = @('-3') }
|
||||
try {
|
||||
& $exe @pre '-c' 'import uvicorn' 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { $Python = $exe; $PyPre = $pre; break }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $Python) { Write-Host 'ERROR: uvicorn 설치된 python 을 못 찾음 (pip install -r apps/api/requirements.txt)'; exit 1 }
|
||||
Write-Host ("python: {0} {1}" -f $Python, ($PyPre -join ' '))
|
||||
|
||||
function Stop-Stale([string]$pattern, [string]$label) {
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } |
|
||||
ForEach-Object {
|
||||
Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId)
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-Health([string]$url, [string]$label, [int]$timeoutSec = 40) {
|
||||
$deadline = (Get-Date).AddSeconds($timeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $url -TimeoutSec 4 -UseBasicParsing -ErrorAction Stop
|
||||
if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { Write-Host " OK $label ($url)"; return $true }
|
||||
} catch { Start-Sleep -Milliseconds 600 }
|
||||
}
|
||||
Write-Host " WARN $label 미응답 ($url) — .devlogs 로그 확인"; return $false
|
||||
}
|
||||
|
||||
function Ensure-DevDb {
|
||||
# Docker Postgres(pgvector)를 apps/api/.env 의 DATABASE_URL 자격증명에 맞춰 보장한다.
|
||||
# DB가 있어야 페르소나 source=database 가 되어 UI 회기 시작이 열린다(무DB면 degraded로 막힘).
|
||||
docker ps *> $null 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host " WARN Docker 데몬 미응답 — DB 없이 degraded(UI 세션 시작 제한). Docker Desktop 실행 필요."; return }
|
||||
$running = docker ps --filter name=vignette-dev-db --format "{{.Names}}" 2>$null
|
||||
if ("$running" -match 'vignette-dev-db') { Write-Host " OK db (vignette-dev-db 실행 중)"; return }
|
||||
docker rm -f vignette-dev-db *> $null 2>&1
|
||||
$envLines = Get-Content (Join-Path $api '.env')
|
||||
$dbUrl = (($envLines | Where-Object { $_ -match '^DATABASE_URL=' }) -replace '^DATABASE_URL=','').Trim()
|
||||
if ($dbUrl -notmatch 'postgresql://([^:]+):([^@]+)@[^:]+:([0-9]+)/(\S+)') { Write-Host " WARN DATABASE_URL 파싱 실패 — DB 스킵"; return }
|
||||
$u=$Matches[1]; $p=$Matches[2]; $port=$Matches[3]; $db=$Matches[4]
|
||||
$initPath = Join-Path $repo 'infra\db\init'
|
||||
docker run -d --name vignette-dev-db -p "$port`:5432" -e POSTGRES_USER=$u -e POSTGRES_PASSWORD=$p -e POSTGRES_DB=$db -e APP_DB_USER=vignette_app -e APP_DB_PASSWORD=vignette_app -v "$initPath`:/docker-entrypoint-initdb.d:ro" pgvector/pgvector:pg16 *> $null
|
||||
for ($i=0; $i -lt 24; $i++) {
|
||||
Start-Sleep -Seconds 2
|
||||
docker exec vignette-dev-db pg_isready -U $u *> $null 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host " OK db (Postgres 준비됨, init 스키마 적용)"; Start-Sleep -Seconds 1; return }
|
||||
}
|
||||
Write-Host " WARN db 준비 타임아웃"
|
||||
}
|
||||
|
||||
Write-Host "[1/4] 기존 스택 정리..."
|
||||
Stop-Stale 'engine_gateway\.gateway' 'gateway'
|
||||
Stop-Stale 'app\.main:app' 'api'
|
||||
Stop-Stale 'vite' 'web'
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
if (-not $NoDb) {
|
||||
Write-Host "[DB] Postgres(pgvector) 보장..."
|
||||
Ensure-DevDb
|
||||
}
|
||||
|
||||
if (-not $NoGateway) {
|
||||
Write-Host "[2/4] 엔진 게이트웨이 :9099 (claude_cli)..."
|
||||
Start-Process -FilePath $Python `
|
||||
-ArgumentList (@($PyPre) + @('-m','uvicorn','engine_gateway.gateway:app','--host','127.0.0.1','--port','9099')) `
|
||||
-WorkingDirectory $api -WindowStyle Hidden `
|
||||
-RedirectStandardOutput (Join-Path $logs 'gateway.out.log') `
|
||||
-RedirectStandardError (Join-Path $logs 'gateway.err.log')
|
||||
} else { Write-Host "[2/4] (게이트웨이 건너뜀)" }
|
||||
|
||||
Write-Host "[3/4] API :8000 (degraded in-memory OK, seed 페르소나)..."
|
||||
$env:AUTO_SEED_PERSONAS = 'true'
|
||||
$env:ALLOW_SEED_PERSONA_FALLBACK = 'true'
|
||||
Start-Process -FilePath $Python `
|
||||
-ArgumentList (@($PyPre) + @('-m','uvicorn','app.main:app','--host','127.0.0.1','--port','8000')) `
|
||||
-WorkingDirectory $api -WindowStyle Hidden `
|
||||
-RedirectStandardOutput (Join-Path $logs 'api.out.log') `
|
||||
-RedirectStandardError (Join-Path $logs 'api.err.log')
|
||||
|
||||
if (-not $NoWeb) {
|
||||
Write-Host "[4/4] 웹 vite :5173..."
|
||||
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','npm run dev' `
|
||||
-WorkingDirectory $web -WindowStyle Hidden `
|
||||
-RedirectStandardOutput (Join-Path $logs 'web.out.log') `
|
||||
-RedirectStandardError (Join-Path $logs 'web.err.log')
|
||||
} else { Write-Host "[4/4] (웹 건너뜀)" }
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host "`n헬스 체크:"
|
||||
if (-not $NoGateway) { Wait-Health 'http://127.0.0.1:9099/health' 'gateway' | Out-Null }
|
||||
Wait-Health 'http://127.0.0.1:8000/health' 'api' | Out-Null
|
||||
if (-not $NoWeb) { Wait-Health 'http://localhost:5173/' 'web' | Out-Null }
|
||||
|
||||
Write-Host "`n준비 완료. 진입점: http://localhost:5173 (로그인 페이지에서 dev-login)"
|
||||
Write-Host "종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1"
|
||||
80
scripts/install-public-runtime-task.ps1
Normal file
80
scripts/install-public-runtime-task.ps1
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[string]$TaskName = "VignettePublicRuntimeWatchdog",
|
||||
[int]$IntervalMinutes = 5,
|
||||
[switch]$SkipPublicHealth,
|
||||
[switch]$SkipCloudflaredRestart,
|
||||
[switch]$RunNow
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ($IntervalMinutes -lt 1) {
|
||||
throw "IntervalMinutes must be 1 or greater"
|
||||
}
|
||||
|
||||
$watchScript = Join-Path $Workspace "scripts\watch-public-runtime.ps1"
|
||||
if (!(Test-Path $watchScript)) {
|
||||
throw "Watchdog script not found at $watchScript"
|
||||
}
|
||||
|
||||
$powershell = (Get-Command powershell.exe).Source
|
||||
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
|
||||
$actionArguments = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy Bypass",
|
||||
"-File `"$watchScript`"",
|
||||
"-Workspace `"$Workspace`""
|
||||
)
|
||||
if ($SkipPublicHealth) {
|
||||
$actionArguments += "-SkipPublicHealth"
|
||||
}
|
||||
if ($SkipCloudflaredRestart) {
|
||||
$actionArguments += "-SkipCloudflaredRestart"
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction `
|
||||
-Execute $powershell `
|
||||
-Argument ($actionArguments -join " ") `
|
||||
-WorkingDirectory $Workspace
|
||||
|
||||
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
|
||||
$repeatTrigger = New-ScheduledTaskTrigger `
|
||||
-Once `
|
||||
-At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
|
||||
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-StartWhenAvailable `
|
||||
-WakeToRun
|
||||
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId $userId `
|
||||
-LogonType Interactive `
|
||||
-RunLevel Limited
|
||||
|
||||
$description = "Runs Vignette public runtime watchdog as $userId. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets."
|
||||
$task = New-ScheduledTask `
|
||||
-Action $action `
|
||||
-Trigger @($logonTrigger, $repeatTrigger) `
|
||||
-Settings $settings `
|
||||
-Principal $principal `
|
||||
-Description $description
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
|
||||
|
||||
Write-Output "Installed scheduled task '$TaskName' for $userId"
|
||||
Write-Output "Action: $powershell $($actionArguments -join ' ')"
|
||||
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
|
||||
|
||||
if ($RunNow) {
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
Write-Output "Started scheduled task '$TaskName'"
|
||||
}
|
||||
303
scripts/probe-engine-gateway.py
Normal file
303
scripts/probe-engine-gateway.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Measure a running engine gateway without embedding credentials.
|
||||
|
||||
The gateway process owns Claude authentication. This script only sends local HTTP
|
||||
requests to an already-running gateway and is not used by normal tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _endpoint(base_url: str, path: str) -> str:
|
||||
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _json_request(
|
||||
base_url: str,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
_endpoint(base_url, path),
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ProbeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ProbeError(f"{method} {path} transport failed: {exc}") from exc
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProbeError(f"{method} {path} returned non-JSON: {raw[:200]}") from exc
|
||||
|
||||
|
||||
def _stream_request(
|
||||
base_url: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
) -> dict[str, Any]:
|
||||
req = urllib.request.Request(
|
||||
_endpoint(base_url, "/v1/stream"),
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Accept": "text/event-stream", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
first_token_at: float | None = None
|
||||
chunks: list[str] = []
|
||||
done_meta: dict[str, Any] = {}
|
||||
done_seen = False
|
||||
event_name: str | None = None
|
||||
data_lines: list[str] = []
|
||||
|
||||
def flush_event(now: float) -> None:
|
||||
nonlocal first_token_at, done_meta, done_seen, event_name, data_lines
|
||||
if not event_name:
|
||||
data_lines = []
|
||||
return
|
||||
data = "\n".join(data_lines)
|
||||
if event_name == "token":
|
||||
if first_token_at is None:
|
||||
first_token_at = now
|
||||
try:
|
||||
token_payload = json.loads(data)
|
||||
chunks.append(str(token_payload.get("text", "")))
|
||||
except json.JSONDecodeError:
|
||||
chunks.append(data)
|
||||
elif event_name == "done":
|
||||
done_meta = json.loads(data or "{}")
|
||||
done_seen = True
|
||||
elif event_name == "error":
|
||||
try:
|
||||
err = json.loads(data)
|
||||
detail = err.get("detail", data)
|
||||
except json.JSONDecodeError:
|
||||
detail = data
|
||||
raise ProbeError(f"stream returned error event: {detail}")
|
||||
event_name = None
|
||||
data_lines = []
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
for raw_line in resp:
|
||||
now = time.perf_counter()
|
||||
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
if not line:
|
||||
flush_event(now)
|
||||
if done_seen:
|
||||
break
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ProbeError(f"POST /v1/stream failed with HTTP {exc.code}: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ProbeError(f"POST /v1/stream transport failed: {exc}") from exc
|
||||
|
||||
finished = time.perf_counter()
|
||||
return {
|
||||
"ttft_ms": None if first_token_at is None else round((first_token_at - started) * 1000, 1),
|
||||
"latency_ms": round((finished - started) * 1000, 1),
|
||||
"text_chars": len("".join(chunks)),
|
||||
"done": done_meta,
|
||||
}
|
||||
|
||||
|
||||
def _generate_payload(
|
||||
*,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
session_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"ai_role": "client",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 128,
|
||||
"metadata": {"probe": "engine-gateway-probe"},
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
return payload
|
||||
|
||||
|
||||
def _summarize(values: list[float | None]) -> dict[str, Any]:
|
||||
clean = [v for v in values if v is not None]
|
||||
if not clean:
|
||||
return {"count": 0}
|
||||
return {
|
||||
"count": len(clean),
|
||||
"min_ms": round(min(clean), 1),
|
||||
"median_ms": round(statistics.median(clean), 1),
|
||||
"mean_ms": round(statistics.fmean(clean), 1),
|
||||
"max_ms": round(max(clean), 1),
|
||||
}
|
||||
|
||||
|
||||
def _summaries(measurements: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for kind in sorted({item["kind"] for item in measurements}):
|
||||
group = [item for item in measurements if item["kind"] == kind]
|
||||
result[kind] = {
|
||||
"ttft": _summarize([item.get("ttft_ms") for item in group]),
|
||||
"latency": _summarize([item.get("latency_ms") for item in group]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _print_text(result: dict[str, Any]) -> None:
|
||||
print(f"Gateway: {result['base_url']}")
|
||||
health = result["health"]
|
||||
print(f"Health: ok={health.get('ok')} engine={health.get('engine')} sessions={health.get('sessions')}")
|
||||
session_id = result.get("session_id")
|
||||
if session_id:
|
||||
print(f"Session: {session_id[:12]}... closed={result.get('closed')}")
|
||||
for kind, summary in result["summary"].items():
|
||||
print(
|
||||
f"{kind}: "
|
||||
f"ttft median={summary['ttft'].get('median_ms')} ms "
|
||||
f"latency median={summary['latency'].get('median_ms')} ms "
|
||||
f"n={summary['latency'].get('count', 0)}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Probe a running Vignette engine gateway for streaming TTFT, total latency, "
|
||||
"and session reuse. Claude credentials are never passed to this script."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.environ.get("ENGINE_URL", "http://127.0.0.1:9099"),
|
||||
help="Gateway base URL. Defaults to ENGINE_URL or http://127.0.0.1:9099.",
|
||||
)
|
||||
parser.add_argument("--prompt", default="Reply with exactly OK.", help="Probe user prompt.")
|
||||
parser.add_argument(
|
||||
"--system-prompt",
|
||||
default="You are an engine gateway probe. Reply concisely.",
|
||||
help="System prompt used when creating the reusable session.",
|
||||
)
|
||||
parser.add_argument("--budget-usd", type=float, default=0.5, help="Budget for the created /session.")
|
||||
parser.add_argument("--reuse-runs", type=int, default=2, help="Number of /v1/stream calls using one session_id.")
|
||||
parser.add_argument(
|
||||
"--ephemeral-runs",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Optional cold /v1/stream calls without session_id. Defaults to 0 to limit spend.",
|
||||
)
|
||||
parser.add_argument("--timeout-sec", type=float, default=120.0, help="HTTP timeout per generation.")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.reuse_runs < 0 or args.ephemeral_runs < 0:
|
||||
raise ProbeError("run counts must be non-negative")
|
||||
|
||||
measurements: list[dict[str, Any]] = []
|
||||
session_id: str | None = None
|
||||
closed = False
|
||||
health = _json_request(args.base_url, "GET", "/health", timeout=10.0)
|
||||
if not health.get("ok"):
|
||||
raise ProbeError(f"gateway health is not ok: {health}")
|
||||
|
||||
try:
|
||||
if args.reuse_runs:
|
||||
created = _json_request(
|
||||
args.base_url,
|
||||
"POST",
|
||||
"/session",
|
||||
{"system_prompt": args.system_prompt, "budget_usd": args.budget_usd},
|
||||
timeout=args.timeout_sec,
|
||||
)
|
||||
session_id = str(created["session_id"])
|
||||
for index in range(args.reuse_runs):
|
||||
measured = _stream_request(
|
||||
args.base_url,
|
||||
_generate_payload(
|
||||
prompt=args.prompt,
|
||||
system_prompt=args.system_prompt,
|
||||
session_id=session_id,
|
||||
),
|
||||
timeout=args.timeout_sec,
|
||||
)
|
||||
measured.update({"kind": "reused_stream", "run": index + 1})
|
||||
measurements.append(measured)
|
||||
|
||||
for index in range(args.ephemeral_runs):
|
||||
measured = _stream_request(
|
||||
args.base_url,
|
||||
_generate_payload(
|
||||
prompt=args.prompt,
|
||||
system_prompt=args.system_prompt,
|
||||
session_id=None,
|
||||
),
|
||||
timeout=args.timeout_sec,
|
||||
)
|
||||
measured.update({"kind": "ephemeral_stream", "run": index + 1})
|
||||
measurements.append(measured)
|
||||
finally:
|
||||
if session_id:
|
||||
quoted = urllib.parse.quote(session_id, safe="")
|
||||
closed_payload = _json_request(args.base_url, "DELETE", f"/session/{quoted}", timeout=10.0)
|
||||
closed = bool(closed_payload.get("closed"))
|
||||
|
||||
result = {
|
||||
"base_url": args.base_url,
|
||||
"health": health,
|
||||
"session_id": session_id,
|
||||
"closed": closed,
|
||||
"measurements": measurements,
|
||||
"summary": _summaries(measurements),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
_print_text(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except ProbeError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
|
@ -1,17 +1,68 @@
|
|||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[int]$ApiPort = 8001,
|
||||
[int]$EnginePort = 9099,
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
|
||||
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
|
||||
[switch]$SkipEngineRestart,
|
||||
[switch]$SkipCloudflaredRestart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ApiDir = Join-Path $Workspace "apps\api"
|
||||
$Python = Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"
|
||||
$OutLog = Join-Path $ApiDir "api.public.out.log"
|
||||
$ErrLog = Join-Path $ApiDir "api.public.err.log"
|
||||
$EngineOutLog = Join-Path $ApiDir "engine.public.out.log"
|
||||
$EngineErrLog = Join-Path $ApiDir "engine.public.err.log"
|
||||
|
||||
function Get-JsonHealth {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[int]$TimeoutSec = 5
|
||||
)
|
||||
|
||||
try {
|
||||
Invoke-RestMethod -Uri $Uri -TimeoutSec $TimeoutSec
|
||||
} catch {
|
||||
$null
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-JsonHealth {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[scriptblock]$IsHealthy,
|
||||
[int]$TimeoutSec = 30
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
$health = Get-JsonHealth -Uri $Uri -TimeoutSec 5
|
||||
if ($null -ne $health -and (& $IsHealthy $health)) {
|
||||
return $health
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
throw "Timed out waiting for healthy response from $Uri"
|
||||
}
|
||||
|
||||
function Stop-UvicornByPort {
|
||||
param(
|
||||
[string]$AppImport,
|
||||
[int]$Port
|
||||
)
|
||||
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*uvicorn $AppImport*" -and
|
||||
$_.CommandLine -like "*--port $Port*"
|
||||
} |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
}
|
||||
|
||||
if (!(Test-Path $Python)) {
|
||||
throw "Python 3.11 not found at $Python"
|
||||
|
|
@ -20,16 +71,37 @@ if (!(Test-Path $ApiDir)) {
|
|||
throw "API directory not found at $ApiDir"
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.CommandLine -like "*uvicorn app.main:app*--port $ApiPort*" } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
$engineHealth = Get-JsonHealth -Uri "http://127.0.0.1:$EnginePort/health"
|
||||
if ($SkipEngineRestart) {
|
||||
if ($null -eq $engineHealth -or -not $engineHealth.ok) {
|
||||
throw "Engine gateway is not healthy on http://127.0.0.1:$EnginePort/health"
|
||||
}
|
||||
} elseif ($null -eq $engineHealth -or -not $engineHealth.ok) {
|
||||
Stop-UvicornByPort -AppImport "engine_gateway.gateway:app" -Port $EnginePort
|
||||
|
||||
Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-m", "uvicorn", "engine_gateway.gateway:app", "--host", "127.0.0.1", "--port", "$EnginePort") `
|
||||
-WorkingDirectory $ApiDir `
|
||||
-RedirectStandardOutput $EngineOutLog `
|
||||
-RedirectStandardError $EngineErrLog `
|
||||
-PassThru | Out-Null
|
||||
|
||||
$engineHealth = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$EnginePort/health" `
|
||||
-IsHealthy { param($health) $health.ok -eq $true } `
|
||||
-TimeoutSec 30
|
||||
}
|
||||
|
||||
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
|
||||
|
||||
$env:ENVIRONMENT = "prod"
|
||||
$env:ENGINE_URL = "http://127.0.0.1:$EnginePort"
|
||||
$env:ENGINE_MODE = "claude_cli"
|
||||
$env:AUTH_DEV_LOGIN_ENABLED = "false"
|
||||
$env:AUTO_SEED_PERSONAS = "false"
|
||||
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
|
||||
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
|
||||
$env:CORS_ORIGINS = '["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev"]'
|
||||
$env:CORS_ORIGINS = '["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"]'
|
||||
|
||||
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
|
||||
|
|
@ -39,7 +111,10 @@ $proc = Start-Process -WindowStyle Hidden -FilePath $Python `
|
|||
-PassThru
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$ApiPort/health" -TimeoutSec 20
|
||||
$health = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-TimeoutSec 30
|
||||
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
|
||||
throw "Public API health is not production-safe: $($health | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
|
@ -89,5 +164,6 @@ if (!$SkipCloudflaredRestart) {
|
|||
-PassThru | Out-Null
|
||||
}
|
||||
|
||||
Write-Output "Engine gateway healthy on http://127.0.0.1:$EnginePort"
|
||||
Write-Output "Public API running on http://127.0.0.1:$ApiPort with PID $($proc.Id)"
|
||||
Write-Output "Health: $($health | ConvertTo-Json -Compress)"
|
||||
|
|
|
|||
157
scripts/watch-public-runtime.ps1
Normal file
157
scripts/watch-public-runtime.ps1
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[int]$ApiPort = 8001,
|
||||
[int]$EnginePort = 9099,
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
|
||||
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
|
||||
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
|
||||
[string]$LogPath = "",
|
||||
[switch]$CheckOnly,
|
||||
[switch]$SkipPublicHealth,
|
||||
[switch]$SkipCloudflaredRestart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (!$LogPath) {
|
||||
$LogPath = Join-Path $Workspace "public-runtime-watchdog.log"
|
||||
}
|
||||
|
||||
function Write-WatchdogLog {
|
||||
param([string]$Message)
|
||||
|
||||
$line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"), $Message
|
||||
Write-Output $line
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Test-JsonHealth {
|
||||
param(
|
||||
[string]$Name,
|
||||
[string]$Uri,
|
||||
[scriptblock]$IsHealthy,
|
||||
[int]$TimeoutSec = 10
|
||||
)
|
||||
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $Uri -TimeoutSec $TimeoutSec
|
||||
$ok = [bool](& $IsHealthy $response)
|
||||
$detail = $response | ConvertTo-Json -Compress -Depth 5
|
||||
[pscustomobject]@{
|
||||
Name = $Name
|
||||
Ok = $ok
|
||||
Detail = $detail
|
||||
}
|
||||
} catch {
|
||||
[pscustomobject]@{
|
||||
Name = $Name
|
||||
Ok = $false
|
||||
Detail = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CloudflaredProcess {
|
||||
if ($SkipCloudflaredRestart) {
|
||||
return [pscustomobject]@{
|
||||
Name = "cloudflared"
|
||||
Ok = $true
|
||||
Detail = "skipped"
|
||||
}
|
||||
}
|
||||
|
||||
$configLeaf = Split-Path -Leaf $CloudflaredConfig
|
||||
$process = Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.Name -eq "cloudflared.exe" -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*$configLeaf*"
|
||||
} |
|
||||
Select-Object -First 1
|
||||
|
||||
[pscustomobject]@{
|
||||
Name = "cloudflared"
|
||||
Ok = $null -ne $process
|
||||
Detail = if ($process) { "pid=$($process.ProcessId)" } else { "not running" }
|
||||
}
|
||||
}
|
||||
|
||||
$startScript = Join-Path $PSScriptRoot "start-public-runtime.ps1"
|
||||
if (!(Test-Path $startScript)) {
|
||||
throw "Start script not found at $startScript"
|
||||
}
|
||||
|
||||
$checks = @(
|
||||
(Test-JsonHealth `
|
||||
-Name "engine" `
|
||||
-Uri "http://127.0.0.1:$EnginePort/health" `
|
||||
-IsHealthy { param($health) $health.ok -eq $true }),
|
||||
(Test-JsonHealth `
|
||||
-Name "api" `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine }),
|
||||
(Test-CloudflaredProcess)
|
||||
)
|
||||
|
||||
if (!$SkipPublicHealth) {
|
||||
$checks += Test-JsonHealth `
|
||||
-Name "public-api" `
|
||||
-Uri $PublicHealthUrl `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-TimeoutSec 20
|
||||
}
|
||||
|
||||
$failed = @($checks | Where-Object { -not $_.Ok })
|
||||
if ($failed.Count -eq 0) {
|
||||
Write-WatchdogLog "healthy: $($checks.Name -join ', ')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-WatchdogLog "unhealthy: $((($failed | ForEach-Object { "$($_.Name)=$($_.Detail)" }) -join '; '))"
|
||||
if ($CheckOnly) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$startArgs = @{
|
||||
Workspace = $Workspace
|
||||
ApiPort = $ApiPort
|
||||
EnginePort = $EnginePort
|
||||
Python = $Python
|
||||
Cloudflared = $Cloudflared
|
||||
CloudflaredConfig = $CloudflaredConfig
|
||||
}
|
||||
if ($SkipCloudflaredRestart) {
|
||||
$startArgs["SkipCloudflaredRestart"] = $true
|
||||
}
|
||||
|
||||
try {
|
||||
& $startScript @startArgs 2>&1 | ForEach-Object {
|
||||
Write-WatchdogLog "$_"
|
||||
}
|
||||
} catch {
|
||||
Write-WatchdogLog "restart failed: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
|
||||
$apiAfter = Test-JsonHealth `
|
||||
-Name "api" `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-TimeoutSec 20
|
||||
if (!$apiAfter.Ok) {
|
||||
throw "Public API still unhealthy after restart: $($apiAfter.Detail)"
|
||||
}
|
||||
|
||||
if (!$SkipPublicHealth) {
|
||||
$publicAfter = Test-JsonHealth `
|
||||
-Name "public-api" `
|
||||
-Uri $PublicHealthUrl `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
|
||||
-TimeoutSec 20
|
||||
if (!$publicAfter.Ok) {
|
||||
throw "Public API tunnel still unhealthy after restart: $($publicAfter.Detail)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-WatchdogLog "restart verified"
|
||||
Loading…
Add table
Add a link
Reference in a new issue