← Files LogForgeARCHIVED FILE
scripts/logforge_run.py
17.3 KB · Nov 4, 2026 · 06:13 UTC
"""Run a .logforge project plan and produce a JSON diagnostic report."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import secrets
import shutil
import signal
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from uuid import uuid4
MAX_EVENTS = 5000
MAX_BYTES = 6 * 1024 * 1024
LEVELS = {"debug": 10, "info": 20, "warn": 30, "error": 40}
ROOT = Path(__file__).resolve().parents[1]
def now():
return datetime.now(timezone.utc).isoformat()
def tags(values):
return list(dict.fromkeys(t.strip().lower() for t in values if t.strip()))
def matches(event_tags, selected):
return not selected or "*" in selected or bool(set(tags(event_tags)) & set(selected))
def load_plan(path):
plan = json.loads(path.read_text(encoding="utf-8"))
if plan.get("kind") != "project" or plan.get("schema_version") != "1.0":
raise ValueError("Expected a .logforge project document with schema_version 1.0")
if not isinstance(plan.get("commands"), list) or not plan["commands"]:
raise ValueError("At least one explicit command is required")
for spec in [*plan["commands"], *plan.get("services", [])]:
if not isinstance(spec.get("argv"), list) or not spec["argv"] or not all(isinstance(a, str) for a in spec["argv"]):
raise ValueError("Commands must be nonempty argv arrays; shell strings are unsupported")
cwd = (path.parent / spec.get("cwd", ".")).resolve()
if not cwd.is_relative_to(path.parent.resolve()):
raise ValueError("Command working directories must remain within the project")
timeout = spec.get("timeout_seconds", 60)
if not isinstance(timeout, (int, float)) or not 0 < timeout <= 3600:
raise ValueError("Command timeouts must be between 0 and 3600 seconds")
for expected in plan.get("expect", []):
if not isinstance(expected.get("name"), str) or not isinstance(expected.get("tags", []), list):
raise ValueError("Each expectation needs a name and optional tag list")
if expected.get("kind", "output") not in ("output", "check") or not isinstance(expected.get("min_count", 1), int) or expected.get("min_count", 1) < 1:
raise ValueError("Expected kind must be output/check and min_count must be positive")
if expected.get("level", "debug") not in LEVELS:
raise ValueError("Expected level must be debug/info/warn/error")
return plan
class Capture:
def __init__(self, run_id, selected):
self.run_id, self.selected = run_id, selected
self.events, self.summaries, self.seen = [], {}, set()
self.bytes = self.dropped = self.rejected = 0
self.lock = threading.Lock()
def accept(self, data, producer):
if not isinstance(data, dict) or data.get("_logforge") not in ("event", "summary"):
return False
if data.get("run_id") != self.run_id or data.get("schema_version") != "1.0":
self.rejected += 1
return False
encoded_size = len(json.dumps(data).encode("utf-8"))
if encoded_size > 65536:
self.dropped += 1
return False
with self.lock:
if data["_logforge"] == "summary":
stats = data.get("stats")
if not isinstance(stats, dict) or not all(isinstance(stats.get(key), int) and stats[key] >= 0 for key in ("emitted", "filtered", "errors")):
self.rejected += 1
return False
self.summaries[(producer, data.get("service", "application"))] = data
return True
if not isinstance(data.get("tags"), list) or not all(isinstance(t, str) for t in data["tags"]):
self.rejected += 1
return False
if not isinstance(data.get("event_id"), str) or not isinstance(data.get("name"), str) or data.get("kind") not in ("output", "check"):
self.rejected += 1
return False
if not matches(data["tags"], self.selected):
self.rejected += 1
return False
if data["event_id"] in self.seen:
return True
if len(self.events) >= MAX_EVENTS or self.bytes + encoded_size > MAX_BYTES:
self.dropped += 1
return False
self.events.append(data)
self.seen.add(data["event_id"])
self.bytes += encoded_size
return True
def consume(self, pipe, producer):
# Bound individual lines even when a program writes an enormous non-log line.
while line := pipe.readline(262145):
if len(line) > 262144:
self.dropped += 1
while line and not line.endswith("\n"):
line = pipe.readline(262145)
continue
try:
self.accept(json.loads(line), producer)
except (ValueError, TypeError):
continue
def start_collector(capture, token, origins):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def allowed_origin(self):
origin = self.headers.get("Origin")
return not origin or origin in origins
def respond(self, status):
self.send_response(status)
if self.headers.get("Origin") in origins:
self.send_header("Access-Control-Allow-Origin", self.headers["Origin"])
self.send_header("Vary", "Origin")
self.send_header("Content-Length", "0")
self.end_headers()
def do_OPTIONS(self):
if not self.allowed_origin():
self.respond(403)
return
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", self.headers.get("Origin", "null"))
self.send_header("Access-Control-Allow-Methods", "POST")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Content-Length", "0")
self.end_headers()
def do_POST(self):
if self.path != "/events":
self.respond(404)
return
if not self.allowed_origin() or not secrets.compare_digest(self.headers.get("Authorization", ""), "Bearer " + token):
self.respond(403)
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 65536:
self.respond(413)
return
self.connection.settimeout(5)
data = json.loads(self.rfile.read(length))
ok = capture.accept(data, "http")
except (ValueError, OSError, TypeError):
self.respond(400)
return
self.respond(204 if ok else 422)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def source_context(source, project, source_roots=None):
if not isinstance(source, dict) or not isinstance(source.get("file"), str):
return source
result = dict(source)
value = source["file"]
# Browser locations are URLs. They remain links, never local file read targets.
if value.startswith(("http://", "https://", "webpack:")):
for mapping in source_roots or []:
prefix = mapping["url_prefix"]
if value.startswith(prefix):
value = str(Path(mapping.get("path", ".")) / urllib.parse.unquote(urllib.parse.urlsplit(value[len(prefix):]).path))
break
else:
return result
path = (project / value).resolve()
if not path.is_relative_to(project) or not path.is_file() or path.stat().st_size > 1024 * 1024:
return result
try:
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
line = int(source.get("line", 0))
except (OSError, ValueError, UnicodeError):
return result
if not 1 <= line <= len(lines):
return result
result.update(file=path.relative_to(project).as_posix(), sha256=hashlib.sha256(content.encode()).hexdigest(),
snippet={"start_line": max(1, line - 2), "lines": lines[max(0, line - 3):line + 2]})
return result
def stop_process(process):
if process.poll() is not None:
return
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
if os.name != "nt":
os.killpg(process.pid, signal.SIGKILL)
else:
process.kill()
process.wait(timeout=5)
def launch(spec, project, environment, capture, producer):
replacements = {"${PYTHON}": sys.executable, "${NODE}": shutil.which("node") or "node", "${PLUGIN_ROOT}": str(ROOT)}
argv = []
for original in spec["argv"]:
arg = original
for key, value in replacements.items():
arg = arg.replace(key, value)
argv.append(arg)
child_env = {**environment, **spec.get("env", {})}
# A child cannot accidentally split correlation or replace the selected run tags.
for key in ("LOGFORGE_RUN_ID", "LOGFORGE_TAGS", "LOGFORGE_LEVEL", "LOGFORGE_PROJECT_ROOT", "LOGFORGE_COLLECTOR_URL", "LOGFORGE_COLLECTOR_TOKEN"):
child_env[key] = environment[key]
child_env["LOGFORGE_SERVICE"] = spec.get("name", producer)
process = subprocess.Popen(argv, cwd=project / spec.get("cwd", "."), env=child_env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
threads = [threading.Thread(target=capture.consume, args=(pipe, producer), daemon=True) for pipe in (process.stdout, process.stderr)]
for thread in threads:
thread.start()
return process, threads
def wait_ready(process, spec):
url = spec.get("ready_url")
if not url:
raise ValueError("A service requires ready_url so tests cannot race startup")
parsed = urllib.parse.urlsplit(url)
if parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
raise ValueError("Service readiness URLs must use loopback")
deadline = time.monotonic() + spec.get("timeout_seconds", 30)
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"Service {spec.get('name', 'unnamed')} exited before readiness")
try:
with urllib.request.urlopen(url, timeout=1) as response:
if 200 <= response.status < 400:
return
except (OSError, urllib.error.URLError):
pass
time.sleep(0.1)
raise RuntimeError(f"Service {spec.get('name', 'unnamed')} did not become ready")
def run(plan_path, selected, level="debug"):
plan_path = plan_path.resolve()
project, plan = plan_path.parent, load_plan(plan_path)
run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + "-" + uuid4().hex[:8]
started, capture = now(), Capture(run_id, selected)
token = secrets.token_urlsafe(32)
collector, collector_thread = start_collector(capture, token, plan.get("browser_origins", []))
environment = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8",
"PYTHONPATH": str(ROOT / "sdk/python") + os.pathsep + os.getenv("PYTHONPATH", ""),
"LOGFORGE_RUN_ID": run_id, "LOGFORGE_TAGS": ",".join(selected), "LOGFORGE_LEVEL": level,
"LOGFORGE_PROJECT_ROOT": str(project), "LOGFORGE_COLLECTOR_URL": f"http://127.0.0.1:{collector.server_port}/events",
"LOGFORGE_COLLECTOR_TOKEN": token}
processes, commands, errors, services = [], [], [], []
try:
for index, spec in enumerate(plan.get("services", [])):
process, threads = launch(spec, project, environment, capture, f"service-{index}")
processes.append((process, threads))
services.append((process, spec.get("name", f"service-{index}")))
wait_ready(process, spec)
for index, spec in enumerate(plan["commands"]):
begin = time.monotonic()
process, threads = launch(spec, project, environment, capture, f"command-{index}")
processes.append((process, threads))
timed_out = False
try:
process.wait(timeout=spec.get("timeout_seconds", 60))
except subprocess.TimeoutExpired:
timed_out = True
stop_process(process)
for thread in threads:
thread.join(timeout=3)
commands.append({"name": spec.get("name", f"command-{index}"), "argv": spec["argv"], "exit_code": process.returncode,
"timed_out": timed_out, "duration_ms": round((time.monotonic() - begin) * 1000)})
except (OSError, ValueError, RuntimeError, KeyboardInterrupt) as error:
errors.append(f"{type(error).__name__}: {error}")
finally:
for process, name in services:
if process.poll() is not None:
errors.append(f"Service {name} exited before the run finished (exit {process.returncode})")
for process, threads in reversed(processes):
stop_process(process)
for thread in threads:
thread.join(timeout=3)
collector.shutdown()
collector.server_close()
collector_thread.join(timeout=3)
events = capture.events
if not events:
errors.append("No selected diagnostic events were observed")
for event in events:
event["source"] = source_context(event.get("source"), project, plan.get("source_roots"))
expectations = []
for expected in plan.get("expect", []):
expected_level = expected.get("level", "info" if expected.get("kind") == "check" else "debug")
selected_expected = matches(expected.get("tags", []), selected) and LEVELS[expected_level] >= LEVELS[level]
observed = sum(e["name"] == expected["name"] and e["kind"] == expected.get("kind", "output") for e in events)
state = "filtered" if not selected_expected else "reached" if observed >= expected.get("min_count", 1) else "unreached"
expectations.append({**expected, "observed_count": observed, "status": state})
checks = [e for e in events if e["kind"] == "check"]
failed_checks = [e["name"] for e in checks if e.get("passed") is not True]
diagnostic_errors = sum(s["stats"]["errors"] for s in capture.summaries.values())
status = "failed" if errors or capture.dropped or capture.rejected or diagnostic_errors or failed_checks or any(e["status"] == "unreached" for e in expectations) or any(c["exit_code"] != 0 or c["timed_out"] for c in commands) else "passed"
report = {"schema_version": "1.0", "kind": "run", "run_id": run_id, "project": plan.get("name", project.name),
"plan": plan_path.name, "started_at": started, "finished_at": now(), "selected_tags": selected, "level": level,
"status": status, "commands": commands, "expectations": expectations, "events": events,
"producers": list(capture.summaries.values()), "errors": errors,
"summary": {"outputs": sum(e["kind"] == "output" for e in events), "checks": len(checks), "failed_checks": failed_checks,
"filtered": sum(s.get("stats", {}).get("filtered", 0) for s in capture.summaries.values()),
"unreached": sum(e["status"] == "unreached" for e in expectations), "dropped": capture.dropped, "rejected": capture.rejected, "diagnostic_errors": diagnostic_errors},
"coverage_note": "Coverage is measured against this plan's explicit expectations. Unexecuted code is not automatically tested."}
directory = project / ".logforge/runs"
directory.mkdir(parents=True, exist_ok=True)
output = directory / (run_id + ".logforge")
output.write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
from logforge_usage import record_run, kick
record_run(report)
kick()
return output, report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["run", "out"])
parser.add_argument("plan", type=Path)
parser.add_argument("--tag", action="append", default=[], help="Case-insensitive OR filter; repeat to select multiple tags. Omit to select all.")
parser.add_argument("--level", choices=["debug", "info", "warn", "error"], default="debug")
args = parser.parse_args()
try:
path, report = run(args.plan, tags(args.tag), args.level)
print(json.dumps({"report": str(path), "status": report["status"], "selected_tags": report["selected_tags"], "summary": report["summary"]}, indent=2))
return 0 if report["status"] == "passed" else 1
except (ValueError, OSError) as error:
print(str(error), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: ed360f564de4a249a43533704b6c49a1158bacec65117fb2101ffd7fb7352aad