470 lines
18 KiB
Python
470 lines
18 KiB
Python
|
|
"""Tool: phase gate validation (real implementation, DB-backed).
|
||
|
|
|
||
|
|
Replaces the stub. Persists every run to orch_quality_runs in patterns.db.
|
||
|
|
|
||
|
|
Args (kwargs):
|
||
|
|
action (str): 'run' | 'show_last' | 'list_runs' | 'show_config'
|
||
|
|
Default: 'run'
|
||
|
|
project_name (str): project name (preferred, e.g. 'rentman-clone')
|
||
|
|
project_root (str): alternative — path to project root, name derived from basename
|
||
|
|
gate_level (str): 'implementation' | 'quality-review' | 'deployment' | 'release'
|
||
|
|
Default: 'quality-review'
|
||
|
|
triggered_by (str): 'manual' | 'pre-commit' | 'pre-deploy' | etc.
|
||
|
|
Default: 'manual'
|
||
|
|
dry_run (bool): if True, list checks that would run without executing
|
||
|
|
Default: False
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
JSON string with decision, checks, blockers, next_action.
|
||
|
|
Persists to orch_quality_runs (one row per run).
|
||
|
|
For action=show_last: returns last run's full record.
|
||
|
|
For action=list_runs: returns summary of recent runs.
|
||
|
|
For action=show_config: returns orch_quality_config row.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import shlex
|
||
|
|
import time
|
||
|
|
from typing import Any, Dict, List, Optional
|
||
|
|
|
||
|
|
from helpers.tool import Tool, Response
|
||
|
|
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
|
||
|
|
DB_PATH,
|
||
|
|
get_project_id,
|
||
|
|
get_kv,
|
||
|
|
set_kv,
|
||
|
|
)
|
||
|
|
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
|
||
|
|
from usr.plugins.a0_software_orchestrator.helpers.safety_rules import redact_secrets
|
||
|
|
|
||
|
|
|
||
|
|
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
|
||
|
|
if project_name:
|
||
|
|
return project_name.strip()
|
||
|
|
if project_root:
|
||
|
|
return os.path.basename(project_root.rstrip("/"))
|
||
|
|
return os.path.basename(os.getcwd())
|
||
|
|
|
||
|
|
|
||
|
|
# Registry of L1-L3 checks. Each entry: (name, level, command_template, expect_exit, parser)
|
||
|
|
# command_template uses {project_root} placeholder. timeout in seconds.
|
||
|
|
PYTHON_FASTAPI_CHECKS = {
|
||
|
|
"L1": [
|
||
|
|
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||
|
|
],
|
||
|
|
"L2": [
|
||
|
|
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||
|
|
],
|
||
|
|
"L3": [
|
||
|
|
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
PYTHON_FASTAPI_VUE_CHECKS = {
|
||
|
|
"L1": [
|
||
|
|
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||
|
|
],
|
||
|
|
"L2": [
|
||
|
|
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||
|
|
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
|
||
|
|
],
|
||
|
|
"L3": [
|
||
|
|
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
PYTHON_FASTAPI_REACT_CHECKS = {
|
||
|
|
"L1": [
|
||
|
|
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
|
||
|
|
{"name": "L1_tsc_noemit", "cmd": "cd {project_root}/frontend && ./node_modules/.bin/tsc --noEmit 2>&1", "expect": 0},
|
||
|
|
],
|
||
|
|
"L2": [
|
||
|
|
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
|
||
|
|
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
|
||
|
|
],
|
||
|
|
"L3": [
|
||
|
|
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _detect_tech_stack(project_root: str) -> str:
|
||
|
|
"""Auto-detect tech stack from project structure."""
|
||
|
|
has_pyproject = os.path.isfile(os.path.join(project_root, "backend", "pyproject.toml")) or os.path.isfile(os.path.join(project_root, "pyproject.toml"))
|
||
|
|
has_reqs = os.path.isfile(os.path.join(project_root, "backend", "requirements.txt")) or os.path.isfile(os.path.join(project_root, "requirements.txt"))
|
||
|
|
has_python_app = os.path.isdir(os.path.join(project_root, "backend", "app")) or os.path.isdir(os.path.join(project_root, "app"))
|
||
|
|
has_react = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and os.path.isfile(os.path.join(project_root, "frontend", "tsconfig.json"))
|
||
|
|
has_vue = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and (
|
||
|
|
os.path.isfile(os.path.join(project_root, "frontend", "vite.config.js")) or os.path.isfile(os.path.join(project_root, "frontend", "vite.config.ts"))
|
||
|
|
)
|
||
|
|
|
||
|
|
if has_reqs and has_python_app and has_react:
|
||
|
|
return "python_fastapi+node_react"
|
||
|
|
if has_reqs and has_python_app and has_vue:
|
||
|
|
return "python_fastapi+vue"
|
||
|
|
if (has_reqs or has_pyproject) and has_python_app:
|
||
|
|
return "python_fastapi"
|
||
|
|
return "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
def _get_checks_for_stack(tech_stack: str, gate_level: str) -> List[Dict[str, Any]]:
|
||
|
|
"""Return list of check definitions for the given stack + gate level."""
|
||
|
|
if tech_stack == "python_fastapi+node_react":
|
||
|
|
registry = PYTHON_FASTAPI_REACT_CHECKS
|
||
|
|
elif tech_stack == "python_fastapi+vue":
|
||
|
|
registry = PYTHON_FASTAPI_VUE_CHECKS
|
||
|
|
elif tech_stack == "python_fastapi":
|
||
|
|
registry = PYTHON_FASTAPI_CHECKS
|
||
|
|
else:
|
||
|
|
return []
|
||
|
|
|
||
|
|
# Map gate_level → which L1-L3 levels are required
|
||
|
|
if gate_level in ("implementation", "quality-review"):
|
||
|
|
levels = ["L1", "L2"]
|
||
|
|
elif gate_level == "deployment":
|
||
|
|
levels = ["L1", "L2", "L3"]
|
||
|
|
elif gate_level == "release":
|
||
|
|
levels = ["L1", "L2", "L3"]
|
||
|
|
else:
|
||
|
|
levels = ["L1"]
|
||
|
|
|
||
|
|
checks: List[Dict[str, Any]] = []
|
||
|
|
for lvl in levels:
|
||
|
|
for c in registry.get(lvl, []):
|
||
|
|
checks.append({"level": lvl, **c})
|
||
|
|
return checks
|
||
|
|
|
||
|
|
|
||
|
|
def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) -> Dict[str, Any]:
|
||
|
|
"""Run a single check, return result dict."""
|
||
|
|
name = check["name"]
|
||
|
|
safe_project_root = shlex.quote(os.path.abspath(project_root))
|
||
|
|
cmd = check["cmd"].format(project_root=safe_project_root)
|
||
|
|
expect = check.get("expect", 0)
|
||
|
|
start = time.time()
|
||
|
|
try:
|
||
|
|
proc = subprocess.run(
|
||
|
|
["bash", "-lc", cmd],
|
||
|
|
shell=False,
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
timeout=timeout,
|
||
|
|
)
|
||
|
|
duration_ms = int((time.time() - start) * 1000)
|
||
|
|
stdout = redact_secrets(proc.stdout[-500:] if proc.stdout else "")
|
||
|
|
stderr = redact_secrets(proc.stderr[-500:] if proc.stderr else "")
|
||
|
|
detail = redact_secrets((stdout + stderr).strip()[:300]) or f"exit={proc.returncode}"
|
||
|
|
if proc.returncode == expect:
|
||
|
|
result = "pass"
|
||
|
|
elif proc.returncode == 5: # ruff format: "would reformat" = 0 fixed
|
||
|
|
result = "fail"
|
||
|
|
else:
|
||
|
|
result = "fail"
|
||
|
|
return {
|
||
|
|
"name": name,
|
||
|
|
"level": check["level"],
|
||
|
|
"result": result,
|
||
|
|
"duration_ms": duration_ms,
|
||
|
|
"detail": detail,
|
||
|
|
"exit_code": proc.returncode,
|
||
|
|
}
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
return {
|
||
|
|
"name": name,
|
||
|
|
"level": check["level"],
|
||
|
|
"result": "fail",
|
||
|
|
"duration_ms": timeout * 1000,
|
||
|
|
"detail": f"timeout after {timeout}s",
|
||
|
|
"exit_code": -1,
|
||
|
|
}
|
||
|
|
except FileNotFoundError as e:
|
||
|
|
return {
|
||
|
|
"name": name,
|
||
|
|
"level": check["level"],
|
||
|
|
"result": "skip",
|
||
|
|
"duration_ms": 0,
|
||
|
|
"detail": f"command not found: {e}",
|
||
|
|
"exit_code": -1,
|
||
|
|
}
|
||
|
|
except Exception as e: # pragma: no cover
|
||
|
|
return {
|
||
|
|
"name": name,
|
||
|
|
"level": check["level"],
|
||
|
|
"result": "fail",
|
||
|
|
"duration_ms": 0,
|
||
|
|
"detail": f"exception: {e}",
|
||
|
|
"exit_code": -1,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _persist_run(
|
||
|
|
project_id: int,
|
||
|
|
gate_level: str,
|
||
|
|
decision: str,
|
||
|
|
checks: List[Dict[str, Any]],
|
||
|
|
blockers: List[str],
|
||
|
|
next_action: str,
|
||
|
|
duration_ms: int,
|
||
|
|
triggered_by: str,
|
||
|
|
) -> int:
|
||
|
|
"""Insert a run into orch_quality_runs, return the new id."""
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
try:
|
||
|
|
cur = conn.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO orch_quality_runs (project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
|
|
""",
|
||
|
|
(
|
||
|
|
project_id,
|
||
|
|
gate_level,
|
||
|
|
decision,
|
||
|
|
json.dumps(checks),
|
||
|
|
json.dumps(blockers),
|
||
|
|
next_action,
|
||
|
|
duration_ms,
|
||
|
|
triggered_by,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
conn.commit()
|
||
|
|
return cur.lastrowid
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _suggest_next_action(failed_checks: List[Dict[str, Any]]) -> str:
|
||
|
|
"""Generate a human-readable next-action suggestion from failed checks."""
|
||
|
|
actions: List[str] = []
|
||
|
|
for c in failed_checks:
|
||
|
|
name = c["name"]
|
||
|
|
if name == "L1_ruff_check":
|
||
|
|
actions.append("Run 'ruff check --fix' to auto-fix")
|
||
|
|
elif name == "L1_ruff_format":
|
||
|
|
actions.append("Run 'ruff format' to fix formatting")
|
||
|
|
elif name == "L1_mypy_strict":
|
||
|
|
actions.append("Add type annotations manually")
|
||
|
|
elif name == "L1_tsc_noemit":
|
||
|
|
actions.append("Fix TypeScript errors (unused imports, type annotations)")
|
||
|
|
elif name == "L2_vite_build":
|
||
|
|
actions.append("Fix build errors (usually TS or import issues)")
|
||
|
|
elif name == "L3_pytest":
|
||
|
|
actions.append("Fix failing tests")
|
||
|
|
else:
|
||
|
|
actions.append(f"Investigate {name}")
|
||
|
|
return "; ".join(actions) if actions else "No actions needed"
|
||
|
|
|
||
|
|
|
||
|
|
def _fetch_last_run(project_id: int) -> Optional[Dict[str, Any]]:
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
try:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT id, project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT 1",
|
||
|
|
(project_id,),
|
||
|
|
).fetchone()
|
||
|
|
if not row:
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"id": row[0],
|
||
|
|
"project_id": row[1],
|
||
|
|
"gate_level": row[2],
|
||
|
|
"decision": row[3],
|
||
|
|
"checks": json.loads(row[4]) if row[4] else [],
|
||
|
|
"blockers": json.loads(row[5]) if row[5] else [],
|
||
|
|
"next_action": row[6],
|
||
|
|
"duration_ms": row[7],
|
||
|
|
"triggered_by": row[8],
|
||
|
|
"created_at": row[9],
|
||
|
|
}
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _list_runs(project_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
try:
|
||
|
|
rows = conn.execute(
|
||
|
|
"SELECT id, gate_level, decision, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT ?",
|
||
|
|
(project_id, limit),
|
||
|
|
).fetchall()
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"id": r[0],
|
||
|
|
"gate_level": r[1],
|
||
|
|
"decision": r[2],
|
||
|
|
"duration_ms": r[3],
|
||
|
|
"triggered_by": r[4],
|
||
|
|
"created_at": r[5],
|
||
|
|
}
|
||
|
|
for r in rows
|
||
|
|
]
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _show_config(project_id: int) -> Optional[Dict[str, Any]]:
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
try:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, custom_checks_json, updated_at FROM orch_quality_config WHERE project_id = ?",
|
||
|
|
(project_id,),
|
||
|
|
).fetchone()
|
||
|
|
if not row:
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"project_id": row[0],
|
||
|
|
"tech_stack": row[1],
|
||
|
|
"coverage_min_pct": row[2],
|
||
|
|
"strict_types": bool(row[3]),
|
||
|
|
"require_security_scan": bool(row[4]),
|
||
|
|
"require_openapi_check": bool(row[5]),
|
||
|
|
"custom_checks_json": row[6],
|
||
|
|
"updated_at": row[7],
|
||
|
|
}
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _ensure_config(project_id: int, tech_stack: str) -> None:
|
||
|
|
"""Create or update orch_quality_config for a project."""
|
||
|
|
import sqlite3
|
||
|
|
conn = sqlite3.connect(DB_PATH)
|
||
|
|
try:
|
||
|
|
conn.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO orch_quality_config (project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, updated_at)
|
||
|
|
VALUES (?, ?, 70, 1, 1, 0, datetime('now'))
|
||
|
|
ON CONFLICT(project_id) DO UPDATE SET
|
||
|
|
tech_stack = excluded.tech_stack,
|
||
|
|
updated_at = datetime('now')
|
||
|
|
""",
|
||
|
|
(project_id, tech_stack),
|
||
|
|
)
|
||
|
|
conn.commit()
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
class QualityGate(Tool):
|
||
|
|
"""Phase gate validation (real implementation, DB-backed).
|
||
|
|
|
||
|
|
Actions:
|
||
|
|
run: execute checks, persist run, return decision
|
||
|
|
show_last: return most recent run for project
|
||
|
|
list_runs: return summary of recent runs (default 10)
|
||
|
|
show_config: return orch_quality_config for project
|
||
|
|
|
||
|
|
Auto-detects tech_stack from project_root if no config exists yet.
|
||
|
|
"""
|
||
|
|
|
||
|
|
async def execute(
|
||
|
|
self,
|
||
|
|
project_name: str = "",
|
||
|
|
project_root: str = "",
|
||
|
|
action: str = "run",
|
||
|
|
gate_level: str = "quality-review",
|
||
|
|
triggered_by: str = "manual",
|
||
|
|
dry_run: bool = False,
|
||
|
|
**kwargs,
|
||
|
|
) -> Response:
|
||
|
|
name = _resolve_name(project_name, project_root)
|
||
|
|
if not name:
|
||
|
|
return Response(message=json.dumps({"error": "no project_name or project_root given"}), break_loop=False)
|
||
|
|
|
||
|
|
# resolve project_id (no auto-create; LookupError on unknown name)
|
||
|
|
try:
|
||
|
|
project_id = get_project_id(name)
|
||
|
|
if project_id is None:
|
||
|
|
raise LookupError(f"project '{name}' is not registered")
|
||
|
|
except (ValueError, LookupError) as e:
|
||
|
|
return Response(message=json.dumps({"error": str(e)}), break_loop=False)
|
||
|
|
except PluginDBError as e:
|
||
|
|
return Response(message=json.dumps({"error": f"Database error: {e}"}), break_loop=False)
|
||
|
|
|
||
|
|
# Determine project_root: prefer explicit, else default to /a0/usr/workdir/<name>-check
|
||
|
|
if not project_root:
|
||
|
|
project_root = f"/a0/usr/workdir/{name}-check"
|
||
|
|
if not os.path.isdir(project_root):
|
||
|
|
return Response(message=json.dumps({"error": f"project_root {project_root!r} does not exist; clone the repo first"}), break_loop=False)
|
||
|
|
|
||
|
|
if action == "show_last":
|
||
|
|
run = _fetch_last_run(project_id)
|
||
|
|
return Response(message=json.dumps(run or {"info": "no runs yet"}, indent=2), break_loop=False)
|
||
|
|
|
||
|
|
if action == "list_runs":
|
||
|
|
runs = _list_runs(project_id)
|
||
|
|
return Response(message=json.dumps(runs, indent=2), break_loop=False)
|
||
|
|
|
||
|
|
if action == "show_config":
|
||
|
|
cfg = _show_config(project_id)
|
||
|
|
return Response(message=json.dumps(cfg or {"info": "no config yet"}, indent=2), break_loop=False)
|
||
|
|
|
||
|
|
# action == 'run' (default)
|
||
|
|
tech_stack = _detect_tech_stack(project_root)
|
||
|
|
if tech_stack == "unknown":
|
||
|
|
return Response(message=json.dumps({"error": f"could not detect tech_stack in {project_root}", "project_root": project_root}), break_loop=False)
|
||
|
|
|
||
|
|
_ensure_config(project_id, tech_stack)
|
||
|
|
checks_def = _get_checks_for_stack(tech_stack, gate_level)
|
||
|
|
if not checks_def:
|
||
|
|
return Response(message=json.dumps({"error": f"no checks defined for tech_stack={tech_stack} gate_level={gate_level}"}), break_loop=False)
|
||
|
|
|
||
|
|
if dry_run:
|
||
|
|
return Response(message=json.dumps({
|
||
|
|
"project": name,
|
||
|
|
"project_id": project_id,
|
||
|
|
"tech_stack": tech_stack,
|
||
|
|
"gate_level": gate_level,
|
||
|
|
"would_run": [c["name"] for c in checks_def],
|
||
|
|
}, indent=2), break_loop=False)
|
||
|
|
|
||
|
|
start = time.time()
|
||
|
|
results: List[Dict[str, Any]] = []
|
||
|
|
for c in checks_def:
|
||
|
|
results.append(_run_check(c, project_root))
|
||
|
|
duration_ms = int((time.time() - start) * 1000)
|
||
|
|
|
||
|
|
failed = [r for r in results if r["result"] == "fail"]
|
||
|
|
skipped = [r for r in results if r["result"] == "skip"]
|
||
|
|
decision = "fail" if failed else ("warn" if skipped else "pass")
|
||
|
|
blockers = [f"{r['name']}: {r['detail']}" for r in failed]
|
||
|
|
next_action = _suggest_next_action(failed)
|
||
|
|
|
||
|
|
run_id = _persist_run(
|
||
|
|
project_id=project_id,
|
||
|
|
gate_level=gate_level,
|
||
|
|
decision=decision,
|
||
|
|
checks=results,
|
||
|
|
blockers=blockers,
|
||
|
|
next_action=next_action,
|
||
|
|
duration_ms=duration_ms,
|
||
|
|
triggered_by=triggered_by,
|
||
|
|
)
|
||
|
|
|
||
|
|
summary = {
|
||
|
|
"run_id": run_id,
|
||
|
|
"project": name,
|
||
|
|
"project_id": project_id,
|
||
|
|
"tech_stack": tech_stack,
|
||
|
|
"gate_level": gate_level,
|
||
|
|
"decision": decision,
|
||
|
|
"checks": results,
|
||
|
|
"blockers": blockers,
|
||
|
|
"next_action": next_action,
|
||
|
|
"duration_ms": duration_ms,
|
||
|
|
"triggered_by": triggered_by,
|
||
|
|
}
|
||
|
|
return Response(message=json.dumps(summary, indent=2), break_loop=False)
|