Loom / tools /orchestrate_client.py
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
7.31 kB
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
# Orchestrator client: drive a template/schematic on the hosted panel and
# MATERIALIZE the result — the final body plus any multi-file artifacts — into a
# real workspace so you (the orchestrator) reference files, not a wall of text.
#
# python tools/orchestrate_client.py --template research_paper \
# --input-file prompt.md --profile research --out runs/
# python tools/orchestrate_client.py --schematic-file my_schematic.json \
# --input "Build a CLI todo app in Python." --out runs/
#
# URL + token default to $CRITIQUE_URL / $CRITIQUE_TOKEN.
def _req(method, url, token, body=None, timeout=60.0):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read())
except Exception:
return e.code, {"error": f"HTTP {e.code}"}
except (urllib.error.URLError, TimeoutError) as e:
return 0, {"error": f"network: {e}"}
def _slug(s: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "run"
def submit(url, token, body, retries=2):
for attempt in range(retries + 1):
status, resp = _req("POST", f"{url}/api/run", token, body, timeout=120.0)
if status in (200, 202):
return status, resp
if attempt < retries and status in (0, 503, 504):
wait = 15 * (attempt + 1)
print(f" submit failed ({status}); cold start? waiting {wait}s", file=sys.stderr)
time.sleep(wait)
continue
raise SystemExit(f"submit failed: HTTP {status}: {resp.get('error')}")
raise SystemExit("submit failed after retries")
def poll(url, token, job_id, *, interval, timeout):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
status, snap = _req("GET", f"{url}/api/jobs/{job_id}", token, timeout=60.0)
if status == 200:
last = snap
for line in snap.get("stage_log", [])[-3:]:
print(f" {line}", file=sys.stderr)
if snap.get("status") == "complete":
return snap.get("result") or {}
time.sleep(interval)
print(" timeout; returning last partial", file=sys.stderr)
return (last or {}).get("result") or {}
def _safe_under(root: Path, rel: str) -> Path | None:
# defence in depth: never write outside the run dir even if the server
# somehow returned an unsafe path.
p = (root / rel).resolve()
try:
p.relative_to(root.resolve())
except ValueError:
return None
return p
def materialize_run(result: dict, out_root: Path) -> Path:
# write a run result into out_root/<ts>-<task>/: each artifact file at its
# path, plus _body.md, _judge.md, _manifest.json. returns the run dir.
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
out = out_root / f"{ts}-{_slug(result.get('task', 'run'))}"
out.mkdir(parents=True, exist_ok=True)
written = []
for f in result.get("artifacts", []):
dest = _safe_under(out, f.get("path", ""))
if dest is None:
continue
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(f.get("content", ""), encoding="utf-8")
written.append(str(dest.relative_to(out)) + (" (truncated)" if f.get("truncated") else ""))
if result.get("body") and not result.get("artifacts"):
(out / "_body.md").write_text(result["body"], encoding="utf-8")
written.append("_body.md")
judge = result.get("judge") or {}
judge_md = "# Judge report\n\n"
judge_md += "## Hard failures\n" + ("\n".join(f"- {h}" for h in judge.get("hard_fails", [])) or "- (none)") + "\n\n"
judge_md += "## Soft flags\n" + ("\n".join(f"- {s}" for s in judge.get("soft_flags", [])) or "- (none)") + "\n"
(out / "_judge.md").write_text(judge_md, encoding="utf-8")
(out / "_manifest.json").write_text(json.dumps({
"task": result.get("task"), "ok": result.get("ok"), "rounds": result.get("rounds"),
"error": result.get("error"), "stages": result.get("stages", []),
"providers_used": result.get("providers_used", []),
"artifacts": [f.get("path") for f in result.get("artifacts", [])],
}, indent=2), encoding="utf-8")
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Run an orchestrator template/schematic; materialize the result as files.")
ap.add_argument("--url", default=os.environ.get("CRITIQUE_URL", ""))
ap.add_argument("--token", default=os.environ.get("CRITIQUE_TOKEN", ""))
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--input", help="prompt text inline")
src.add_argument("--input-file", help="file holding the prompt text")
tpl = ap.add_mutually_exclusive_group()
tpl.add_argument("--template", help="built-in template id (e.g. research_paper, freeform)")
tpl.add_argument("--schematic-file", help="path to a custom schematic JSON")
ap.add_argument("--profile", default="default")
ap.add_argument("--effort", default="med")
ap.add_argument("--out", default="runs")
ap.add_argument("--sync", action="store_true", help="block instead of async+poll")
ap.add_argument("--poll-interval", type=float, default=10.0)
ap.add_argument("--timeout", type=float, default=3600.0)
args = ap.parse_args()
if not args.url or not args.token:
raise SystemExit("set --url/--token or CRITIQUE_URL/CRITIQUE_TOKEN")
prompt = Path(args.input_file).read_text(encoding="utf-8") if args.input_file else args.input
body = {"prompt": prompt, "profile": args.profile, "effort": args.effort,
"async": not args.sync}
if args.template:
body["template"] = args.template
if args.schematic_file:
body["schematic"] = json.loads(Path(args.schematic_file).read_text(encoding="utf-8"))
print(f"submitting run (template={args.template}, profile={args.profile})...", file=sys.stderr)
status, resp = submit(args.url, args.token, body)
if status == 200: # sync
result = resp
else: # 202 async
print(f" job_id={resp.get('job_id')}", file=sys.stderr)
result = poll(args.url, args.token, resp["job_id"],
interval=args.poll_interval, timeout=args.timeout)
out = materialize_run(result, Path(args.out))
print(f"\nrun {'OK' if result.get('ok') else 'INCOMPLETE'} -> {out}")
for f in sorted(out.rglob("*")):
if f.is_file():
print(f" {f}")
if not result.get("ok"):
print(f"note: {result.get('error') or 'judge still has hard fails'}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())