from __future__ import annotations import argparse import base64 import json import logging import sys from pathlib import Path from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.types import Command from agents import AgentRunner from workflow import build_workflow, resolve_notes_dir, resolve_work_dir DB_FILENAME = "workflow_state.db" def parse_command(value: str | None) -> list[str] | None: if not value: return None try: parsed = json.loads(value) except json.JSONDecodeError: # Fallback: the string may contain Windows paths with unescaped backslashes # that make it invalid JSON. Re-escape backslashes and retry before giving up. try: import re # Replace bare backslashes (not already doubled) with \\ re_escaped = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', value) parsed = json.loads(re_escaped) except json.JSONDecodeError: stripped = value.strip() if stripped.startswith("[") and stripped.endswith("]"): # Last-resort comma split; strip surrounding quotes from each item. parsed = [item.strip().strip('"').strip("'") for item in stripped[1:-1].split(",")] else: raise if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): raise argparse.ArgumentTypeError("command must be a JSON string array") return parsed def parse_command_b64(value: str | None) -> list[str] | None: if not value: return None try: decoded = base64.b64decode(value).decode("utf-8") except Exception as exc: raise argparse.ArgumentTypeError(f"invalid base64 command: {exc}") from exc return parse_command(decoded) def parse_json_b64(value: str | None): if not value: return None try: decoded = base64.b64decode(value).decode("utf-8") except Exception as exc: raise argparse.ArgumentTypeError(f"invalid base64 JSON: {exc}") from exc return json.loads(decoded) def run_workflow( *, task_id: str, user_prompt: str = "", notes_dir: str = "Notes", work_dir: str = "", runner: AgentRunner, thread_id: str | None = None, resume_payload=None, ) -> dict: effective_thread = 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) db_path = resolved_notes_dir / DB_FILENAME with SqliteSaver.from_conn_string(str(db_path)) as checkpointer: app = build_workflow(runner, checkpointer=checkpointer) config = {"configurable": {"thread_id": effective_thread}} if resume_payload is not None: result = app.invoke(Command(resume=resume_payload), config=config) else: result = app.invoke( { "task_id": task_id, "user_prompt": user_prompt, "notes_dir": str(resolved_notes_dir), "work_dir": str(resolved_work_dir), }, config=config, ) interrupts = result.get("__interrupt__", []) if interrupts: intr = interrupts[0] action_required = intr.value if hasattr(intr, "value") else intr return { "status": "waiting", "thread_id": effective_thread, "db_path": str(db_path), "action_required": action_required, } return { "status": "done", "thread_id": effective_thread, "db_path": str(db_path), "task_path": result.get("task_path", ""), "latest_discuss_path": result.get("latest_discuss_path", ""), "work_dir": result.get("work_dir", ""), "codex_session_id": result.get("codex_session_id", ""), "has_serious_issue": result.get("has_serious_issue", False), "should_exit": result.get("should_exit", True), } def main() -> int: parser = argparse.ArgumentParser( description="Run the LangGraph Claude/Codex review workflow." ) parser.add_argument("--task-id", required=True, help="Task id, for example 02") parser.add_argument("--prompt", default="", help="User request recorded for traceability") parser.add_argument("--notes-dir", default="Notes", help="Directory for Task/discuss files") parser.add_argument("--work-dir", default="", help="Claude Code launch working directory") parser.add_argument( "--codex-review-command", type=parse_command, default=None, help="JSON argv for Codex review step.", ) parser.add_argument( "--codex-review-command-b64", type=parse_command_b64, default=None, help="Base64-encoded JSON argv for Codex review step.", ) parser.add_argument("--timeout", type=int, default=600) parser.add_argument( "--thread-id", default="", help="Thread ID for resuming an existing run (defaults to task-id)", ) parser.add_argument( "--resume", default="", help=( "JSON payload for Command(resume=...). " "Use true for plan/revise/rejection steps; {\"accepts\":true} for decision step." ), ) parser.add_argument( "--resume-b64", default="", help="Base64-encoded JSON payload for Command(resume=...).", ) parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging to stderr.") args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format="%(asctime)s %(levelname)s %(message)s") codex_review_command = args.codex_review_command_b64 or args.codex_review_command if not codex_review_command: parser.error("--codex-review-command or --codex-review-command-b64 is required") runner = AgentRunner( codex_review_command=codex_review_command, timeout=args.timeout, ) # resume=True tells LangGraph to resume from the last interrupt without a specific value. # Callers should pass 'true' (JSON boolean) for plan/revise/rejection steps. 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, runner=runner, thread_id=args.thread_id or None, resume_payload=resume_payload, ) print(json.dumps(output, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())