常规归档
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user