197 lines
6.7 KiB
Python
197 lines
6.7 KiB
Python
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())
|