Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""run_workflow.py — submit workflow runs to the local langgraph dev server.
|
|
|
|
Instead of running the graph directly in-process, every invocation sends a
|
|
run to the langgraph dev server (http://127.0.0.1:2024) via the LangGraph
|
|
SDK sync client. This lets LangGraph Studio track execution in real time.
|
|
|
|
The Codex review command is forwarded in the run's config so the server does
|
|
not need to be restarted when the command changes between projects.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SERVER_URL = "http://127.0.0.1:2024"
|
|
ASSISTANT_ID = "plan_review"
|
|
THREAD_MAP_FILENAME = "workflow_threads.json"
|
|
|
|
|
|
# ── path helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _resolve_work_dir(value: str) -> Path:
|
|
return Path(value or ".").resolve()
|
|
|
|
|
|
def _resolve_notes_dir(work_dir: Path, value: str) -> Path:
|
|
notes_dir = Path(value or "Notes")
|
|
if not notes_dir.is_absolute():
|
|
notes_dir = work_dir / notes_dir
|
|
return notes_dir.resolve()
|
|
|
|
|
|
# ── thread map (task_id → server thread_id) ──────────────────────────────────
|
|
|
|
def _load_thread_map(notes_dir: Path) -> dict:
|
|
path = notes_dir / THREAD_MAP_FILENAME
|
|
if path.exists():
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _save_thread_map(notes_dir: Path, mapping: dict) -> None:
|
|
path = notes_dir / THREAD_MAP_FILENAME
|
|
path.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
# ── interrupt extraction ──────────────────────────────────────────────────────
|
|
|
|
def _extract_interrupt(state: dict):
|
|
"""Return the first interrupt value from a thread-state dict, or None."""
|
|
# Primary: tasks[].interrupts (langgraph-api >= 0.5)
|
|
for task in state.get("tasks", []):
|
|
for intr in task.get("interrupts", []):
|
|
if isinstance(intr, dict):
|
|
return intr.get("value", intr)
|
|
return intr
|
|
# Fallback: __interrupt__ channel value
|
|
interrupt_list = state.get("values", {}).get("__interrupt__", [])
|
|
if interrupt_list:
|
|
intr = interrupt_list[0]
|
|
return intr.value if hasattr(intr, "value") else intr
|
|
return None
|
|
|
|
|
|
def _is_interrupted(state: dict) -> bool:
|
|
"""Return True when the thread is paused at an interrupt."""
|
|
return bool(state.get("next")) and _extract_interrupt(state) is not None
|
|
|
|
|
|
# ── core workflow runner ──────────────────────────────────────────────────────
|
|
|
|
def run_workflow(
|
|
*,
|
|
task_id: str,
|
|
user_prompt: str = "",
|
|
notes_dir: str = "Notes",
|
|
work_dir: str = "",
|
|
codex_review_command: list[str],
|
|
thread_id: str | None = None,
|
|
resume_payload=None,
|
|
timeout: int = 600,
|
|
server_url: str = SERVER_URL,
|
|
) -> dict:
|
|
try:
|
|
from langgraph_sdk import get_sync_client
|
|
except ImportError:
|
|
raise ImportError(
|
|
"langgraph_sdk not installed. Run: "
|
|
"conda run -n langgraph pip install langgraph-sdk"
|
|
)
|
|
|
|
import httpx
|
|
|
|
effective_task_id = thread_id or task_id
|
|
resolved_work_dir = _resolve_work_dir(work_dir)
|
|
resolved_notes_dir = _resolve_notes_dir(resolved_work_dir, notes_dir)
|
|
resolved_notes_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ── verify server is up ───────────────────────────────────────────────────
|
|
try:
|
|
httpx.get(f"{server_url}/ok", timeout=5).raise_for_status()
|
|
except Exception as exc:
|
|
manage_script = Path(__file__).resolve().with_name("manage_dev_server.ps1")
|
|
raise RuntimeError(
|
|
f"langgraph dev server not reachable at {server_url}.\n"
|
|
f"Start it first:\n"
|
|
f" PowerShell -File {manage_script} start\n"
|
|
f"Error: {exc}"
|
|
)
|
|
|
|
client = get_sync_client(url=server_url)
|
|
|
|
# ── resolve server thread_id ──────────────────────────────────────────────
|
|
thread_map = _load_thread_map(resolved_notes_dir)
|
|
server_thread_id: str | None = thread_map.get(effective_task_id)
|
|
|
|
# Verify the stored thread still exists on the server (server may have restarted)
|
|
if server_thread_id:
|
|
try:
|
|
client.threads.get(server_thread_id)
|
|
except Exception:
|
|
logging.warning(
|
|
"Stored thread %s no longer exists on server (server restarted?); "
|
|
"creating a new thread.",
|
|
server_thread_id,
|
|
)
|
|
server_thread_id = None
|
|
|
|
if not server_thread_id:
|
|
thread = client.threads.create(
|
|
metadata={"task_id": effective_task_id}
|
|
)
|
|
server_thread_id = thread["thread_id"]
|
|
thread_map[effective_task_id] = server_thread_id
|
|
_save_thread_map(resolved_notes_dir, thread_map)
|
|
|
|
# ── submit run ────────────────────────────────────────────────────────────
|
|
run_config = {
|
|
"configurable": {
|
|
"codex_review_command": codex_review_command,
|
|
"codex_timeout": timeout,
|
|
}
|
|
}
|
|
|
|
if resume_payload is not None:
|
|
run = client.runs.create(
|
|
server_thread_id,
|
|
ASSISTANT_ID,
|
|
command={"resume": resume_payload},
|
|
config=run_config,
|
|
)
|
|
else:
|
|
run = client.runs.create(
|
|
server_thread_id,
|
|
ASSISTANT_ID,
|
|
input={
|
|
"task_id": effective_task_id,
|
|
"user_prompt": user_prompt,
|
|
"notes_dir": str(resolved_notes_dir),
|
|
"work_dir": str(resolved_work_dir),
|
|
},
|
|
config=run_config,
|
|
)
|
|
|
|
run_id = run["run_id"]
|
|
logging.info("Submitted run %s on thread %s", run_id, server_thread_id)
|
|
|
|
# ── wait for completion or interrupt ──────────────────────────────────────
|
|
ACTIVE = {"pending", "running"}
|
|
deadline = time.monotonic() + timeout
|
|
run_status = run.get("status", "pending")
|
|
|
|
while run_status in ACTIVE and time.monotonic() < deadline:
|
|
time.sleep(1)
|
|
try:
|
|
final_run = client.runs.get(server_thread_id, run_id)
|
|
run_status = final_run.get("status", run_status)
|
|
except Exception as exc:
|
|
logging.debug("runs.get error (will retry): %s", exc)
|
|
|
|
if run_status in ACTIVE:
|
|
raise TimeoutError(
|
|
f"Run {run_id} did not complete within {timeout}s (status={run_status})"
|
|
)
|
|
|
|
# ── detect interrupt from thread state ────────────────────────────────────
|
|
state = client.threads.get_state(server_thread_id)
|
|
|
|
if _is_interrupted(state):
|
|
interrupt_value = _extract_interrupt(state)
|
|
return {
|
|
"status": "waiting",
|
|
"thread_id": effective_task_id,
|
|
"server_thread_id": server_thread_id,
|
|
"action_required": interrupt_value,
|
|
}
|
|
|
|
if run_status not in ("success",):
|
|
error = final_run.get("error") or run_status
|
|
raise RuntimeError(f"Run ended with status '{run_status}': {error}")
|
|
|
|
# ── extract final state ───────────────────────────────────────────────────
|
|
values = state.get("values", {})
|
|
|
|
return {
|
|
"status": "done",
|
|
"thread_id": effective_task_id,
|
|
"server_thread_id": server_thread_id,
|
|
"task_path": values.get("task_path", ""),
|
|
"latest_discuss_path": values.get("latest_discuss_path", ""),
|
|
"work_dir": values.get("work_dir", ""),
|
|
"codex_session_id": values.get("codex_session_id", ""),
|
|
"has_serious_issue": values.get("has_serious_issue", False),
|
|
"should_exit": values.get("should_exit", True),
|
|
}
|
|
|
|
|
|
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
|
|
def _parse_command(value: str | None) -> list[str] | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
import re
|
|
try:
|
|
parsed = json.loads(re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value))
|
|
except json.JSONDecodeError:
|
|
stripped = value.strip()
|
|
if stripped.startswith("[") and stripped.endswith("]"):
|
|
parsed = [i.strip().strip('"').strip("'") for i in stripped[1:-1].split(",")]
|
|
else:
|
|
raise
|
|
if not isinstance(parsed, list) or not all(isinstance(i, str) for i in parsed):
|
|
raise argparse.ArgumentTypeError("command must be a JSON string array")
|
|
return parsed
|
|
|
|
|
|
def _parse_b64(value: str | None) -> list[str] | None:
|
|
if not value:
|
|
return None
|
|
return _parse_command(base64.b64decode(value).decode("utf-8"))
|
|
|
|
|
|
def _parse_json_b64(value: str | None):
|
|
if not value:
|
|
return None
|
|
return json.loads(base64.b64decode(value).decode("utf-8"))
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Submit a plan-review workflow run to the local langgraph dev server."
|
|
)
|
|
parser.add_argument("--task-id", required=True)
|
|
parser.add_argument("--prompt", default="")
|
|
parser.add_argument("--notes-dir", default="Notes")
|
|
parser.add_argument("--work-dir", default="")
|
|
parser.add_argument("--codex-review-command", type=_parse_command, default=None)
|
|
parser.add_argument("--codex-review-command-b64", type=_parse_b64, default=None)
|
|
parser.add_argument("--timeout", type=int, default=600)
|
|
parser.add_argument("--thread-id", default="")
|
|
parser.add_argument("--resume", default="")
|
|
parser.add_argument("--resume-b64", default="")
|
|
parser.add_argument("--server-url", default=SERVER_URL)
|
|
parser.add_argument("--verbose", "-v", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
if args.verbose:
|
|
logging.basicConfig(
|
|
level=logging.DEBUG, stream=sys.stderr,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
|
|
codex_cmd = args.codex_review_command_b64 or args.codex_review_command
|
|
if not codex_cmd:
|
|
parser.error("--codex-review-command or --codex-review-command-b64 is required")
|
|
|
|
resume_payload = (
|
|
_parse_json_b64(args.resume_b64) if args.resume_b64
|
|
else (json.loads(args.resume) if args.resume else None)
|
|
)
|
|
|
|
output = run_workflow(
|
|
task_id=args.task_id,
|
|
user_prompt=args.prompt,
|
|
notes_dir=args.notes_dir,
|
|
work_dir=args.work_dir,
|
|
codex_review_command=codex_cmd,
|
|
thread_id=args.thread_id or None,
|
|
resume_payload=resume_payload,
|
|
timeout=args.timeout,
|
|
server_url=args.server_url,
|
|
)
|
|
|
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|