更新了langgraph-plan-view,支持windows下使用 langgraph cli;更新了langgraph-vivado-install,要求安装langgraph cli,确定conda环境的名称为langgraph
This commit is contained in:
@@ -7,6 +7,29 @@ description: Use the LangGraph plan-review workflow from the current Claude Code
|
||||
|
||||
Use this skill to run the LangGraph plan-review workflow. All scripts are bundled under the skill's own `scripts\` directory (`C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\`) — no dependency on `E:\LangGraph_Design\lab00`.
|
||||
|
||||
## Pre-flight: Environment Setup
|
||||
|
||||
Before running the workflow for the first time (or when unsure the environment is ready), verify that the required packages are installed in the `langgraph` conda env:
|
||||
|
||||
```powershell
|
||||
# Install langgraph dev server, SDK, and HTTP client
|
||||
conda run -n langgraph pip install "langgraph-cli[inmem]" langgraph-sdk httpx
|
||||
|
||||
# Verify the SDK is importable
|
||||
conda run -n langgraph python -c "from langgraph_sdk import get_sync_client; print('SDK OK')"
|
||||
|
||||
# Verify the langgraph dev command is available
|
||||
conda run -n langgraph langgraph --version
|
||||
```
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `langgraph-cli[inmem]` | Provides the `langgraph dev` server command |
|
||||
| `langgraph-sdk` | Python client used by `run_workflow.py` to submit runs |
|
||||
| `httpx` | HTTP health-check in `run_workflow.py` |
|
||||
|
||||
**Codex auth** (if not already set up): `C:\Users\bowei\.codex\auth.json` must contain a valid ChatGPT OAuth token. Run `codex auth login` if the file is missing.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Use the current Claude Code session for all Claude Code work. Do not launch another `claude` process.
|
||||
@@ -22,13 +45,40 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
|
||||
- Keep all workflow files under `<Claude launch work_dir>\Notes` unless the user explicitly overrides `-NotesDir`.
|
||||
- `TaskId` is the numeric suffix, such as `01`; write the plan to `Notes\Task01.md`.
|
||||
- `discussxx-yy.md` numbering is managed by the LangGraph app for Codex review files. If Claude rejects a review comment, create the next numbered discuss file manually before rerunning review.
|
||||
- The workflow uses LangGraph `interrupt()` and persists state in `workflow_state.db`.
|
||||
- The workflow uses LangGraph `interrupt()` and persists state via the langgraph dev server.
|
||||
- Each `run_langgraph.ps1` invocation advances the graph to the next interrupt or completion. Use `-Resume` to continue the same thread after completing the requested Claude Code action.
|
||||
|
||||
## Dev Server Lifecycle
|
||||
|
||||
The `langgraph dev` server must be running for the entire duration of a workflow session.
|
||||
Use `manage_dev_server.ps1` to start it before the first `run_langgraph.ps1` call and stop it after the workflow completes.
|
||||
|
||||
```powershell
|
||||
# Start (idempotent — safe to call even if already running)
|
||||
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
|
||||
|
||||
# Stop (call once when workflow returns {"status":"done"})
|
||||
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
|
||||
|
||||
# Status check
|
||||
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 status
|
||||
```
|
||||
|
||||
- `start` waits up to 45 s for `http://127.0.0.1:2024/ok` to respond, then prints the Studio URL.
|
||||
- `stop` kills the process by PID file and removes it; falls back to killing by port 2024 if the PID is stale.
|
||||
- If the server is already running, `start` is a no-op.
|
||||
- Server stdout/stderr logs are written to `%TEMP%\langgraph_dev_server.stdout.log` and `%TEMP%\langgraph_dev_server.stderr.log` for debugging.
|
||||
- **Studio URL**: `https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024` — open in browser to track workflow execution in real time.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine `TaskId` from the user. If missing, ask for it.
|
||||
2. Start or resume the LangGraph app:
|
||||
2. Start the dev server (idempotent):
|
||||
```powershell
|
||||
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 start
|
||||
```
|
||||
Wait for the "Ready" confirmation before proceeding. Open the Studio URL in a browser for real-time tracking.
|
||||
3. Start or resume the LangGraph app:
|
||||
```powershell
|
||||
# Use double backslashes (\\) in the JSON string so Windows paths are valid JSON.
|
||||
# $task_path / $latest_discuss_path / etc. are Python Template placeholders — keep them literal.
|
||||
@@ -39,7 +89,7 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
|
||||
-CodexReviewCommand $codexCmd `
|
||||
-Timeout 300
|
||||
```
|
||||
3. Inspect the returned JSON. If `status` is `waiting`, perform `action_required` in the current Claude Code session:
|
||||
4. Inspect the returned JSON. If `status` is `waiting`, perform `action_required` in the current Claude Code session:
|
||||
- `write_plan`: write `Task<TaskId>.md` with project background, task objective, and concrete implementation plan, then resume with `-Resume 'true'`.
|
||||
- `decide`: read the discuss file and resume with `-Resume '{"accepts":true}'` or `-Resume '{"accepts":false,"reason":"..."}'`.
|
||||
- `revise`: revise `Task<TaskId>.md`, then resume with `-Resume 'true'`.
|
||||
@@ -48,10 +98,13 @@ Use this skill to run the LangGraph plan-review workflow. All scripts are bundle
|
||||
**Why `review_task.py`**: Using the shim keeps all codex-specific logic in Python, and the JSON array stays simple.
|
||||
|
||||
`run_langgraph.ps1` base64-encodes the JSON command and resume payload before passing them through `conda run`, so normal PowerShell single-quoted JSON can be used. **Windows paths inside the JSON string must use `\\` (double backslash) to produce valid JSON**, e.g. `C:\\Users\\bowei\\...`. `run_langgraph.ps1` automatically sets `PYTHONIOENCODING=utf-8` so prompts containing Unicode (Chinese, etc.) pass through correctly.
|
||||
4. Inspect the command output after each resume.
|
||||
5. Inspect the command output after each resume.
|
||||
- Check `"status"` to determine next action: `"waiting"` means perform the next `action_required`; `"done"` means the workflow is complete.
|
||||
- If a new `latest_discuss_path` is present in the output, read that discuss file for context.
|
||||
5. Stop when the workflow returns `{"status":"done", ...}`.
|
||||
6. Stop when the workflow returns `{"status":"done", ...}`:
|
||||
```powershell
|
||||
C:\Users\bowei\.claude\skills\langgraph-plan-review\scripts\manage_dev_server.ps1 stop
|
||||
```
|
||||
|
||||
## Rejection File Rule
|
||||
|
||||
@@ -82,3 +135,5 @@ Write the rejection content directly to `action_required.output_path`:
|
||||
- `review_task.py` persists the Codex session id through LangGraph state and uses `codex exec resume` on later review passes.
|
||||
- `review_task.py` runs Codex with the original Claude Code launch working directory as cwd.
|
||||
- Codex review output is normalized to JSON for `agents.py`: `{"has_serious_issue":true/false,"comment":"...","codex_session_id":"..."}`.
|
||||
- If the dev server restarts, `run_workflow.py` detects that the stored thread no longer exists and creates a new thread automatically.
|
||||
- `run_workflow.py` passes the codex command and timeout as `config["configurable"]` per run — no server restart needed when switching projects.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"langgraph",
|
||||
"langgraph-checkpoint-sqlite"
|
||||
],
|
||||
"graphs": {
|
||||
"plan_review": "./scripts/graph.py:graph"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Module-level graph export for `langgraph dev`.
|
||||
|
||||
The Codex review command is injected per-run via config["configurable"],
|
||||
so no runner is needed at build time. The server loads this module once;
|
||||
every submitted run brings its own command through the LangGraph SDK client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure sibling modules (agents, workflow) are importable when langgraph dev
|
||||
# loads this file from the skill root via ./scripts/graph.py:graph.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from workflow import build_workflow # noqa: E402
|
||||
|
||||
# langgraph dev manages its own persistence — do not pass a checkpointer here.
|
||||
graph = build_workflow()
|
||||
@@ -0,0 +1,193 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Manage the langgraph dev server on Windows.
|
||||
.DESCRIPTION
|
||||
Start, stop, or check the status of the langgraph dev server.
|
||||
The server starts from the skill root (where langgraph.json lives) so the
|
||||
graph module is discovered correctly.
|
||||
.PARAMETER Command
|
||||
start | stop | status
|
||||
.EXAMPLE
|
||||
manage_dev_server.ps1 start # idempotent — safe to call if already running
|
||||
manage_dev_server.ps1 stop # call once workflow returns {"status":"done"}
|
||||
manage_dev_server.ps1 status # print running/stopped + Studio URL
|
||||
#>
|
||||
param([string]$Command = "status")
|
||||
|
||||
$SkillDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$PidFile = Join-Path $env:TEMP "langgraph_dev_server.pid"
|
||||
$StdoutLog = Join-Path $env:TEMP "langgraph_dev_server.stdout.log"
|
||||
$StderrLog = Join-Path $env:TEMP "langgraph_dev_server.stderr.log"
|
||||
$ApiUrl = "http://127.0.0.1:2024"
|
||||
$MaxWait = 45 # seconds to wait for the server to become ready
|
||||
|
||||
# --- helpers -----------------------------------------------------------
|
||||
|
||||
function Test-HttpOk([string]$Url) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 3 -ErrorAction Stop
|
||||
return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 300)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-StoredPid {
|
||||
if (-not (Test-Path $PidFile)) { return 0 }
|
||||
$raw = (Get-Content $PidFile -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
if (-not $raw) { return 0 }
|
||||
[int]$raw
|
||||
}
|
||||
|
||||
function Test-ServerRunning {
|
||||
$pid = Get-StoredPid
|
||||
if ($pid -eq 0) { return $false }
|
||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { return $false }
|
||||
return (Test-HttpOk "$ApiUrl/ok")
|
||||
}
|
||||
|
||||
function Find-LangGraphExe {
|
||||
$up = $env:USERPROFILE
|
||||
$candidates = @(
|
||||
"$up\.conda\envs\langgraph\Scripts\langgraph.exe",
|
||||
"$up\miniconda3\envs\langgraph\Scripts\langgraph.exe",
|
||||
"$up\anaconda3\envs\langgraph\Scripts\langgraph.exe",
|
||||
"$up\miniforge3\envs\langgraph\Scripts\langgraph.exe",
|
||||
"$up\AppData\Local\miniconda3\envs\langgraph\Scripts\langgraph.exe",
|
||||
"C:\ProgramData\miniconda3\envs\langgraph\Scripts\langgraph.exe",
|
||||
"C:\ProgramData\anaconda3\envs\langgraph\Scripts\langgraph.exe"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) { return $c }
|
||||
}
|
||||
# Try PATH
|
||||
$cmd = Get-Command langgraph -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
# Try resolving via conda
|
||||
$condaCandidates = @($env:CONDA_EXE, "C:\ProgramData\miniconda3\Scripts\conda.exe",
|
||||
"$up\AppData\Local\miniconda3\Scripts\conda.exe")
|
||||
foreach ($conda in $condaCandidates) {
|
||||
if ($conda -and (Test-Path $conda)) {
|
||||
try {
|
||||
$lgPath = (& $conda run -n langgraph python -c "import shutil; print(shutil.which('langgraph') or '')" 2>$null).Trim()
|
||||
if ($lgPath -and (Test-Path $lgPath)) { return $lgPath }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
throw ("Cannot find langgraph.exe in the 'langgraph' conda env. " +
|
||||
"Install it: conda run -n langgraph pip install 'langgraph-cli[inmem]'")
|
||||
}
|
||||
|
||||
function Stop-ServerProcess([int]$PidValue) {
|
||||
$stopped = $false
|
||||
if ($PidValue -gt 0) {
|
||||
$proc = Get-Process -Id $PidValue -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Stop-Process -Id $PidValue -Force -ErrorAction SilentlyContinue
|
||||
$stopped = $true
|
||||
}
|
||||
}
|
||||
# Fallback: kill whoever is listening on port 2024
|
||||
if (-not $stopped) {
|
||||
$conn = Get-NetTCPConnection -LocalPort 2024 -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($conn) {
|
||||
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "[dev-server] Stopped process on port 2024 (PID $($conn.OwningProcess))"
|
||||
$stopped = $true
|
||||
}
|
||||
}
|
||||
return $stopped
|
||||
}
|
||||
|
||||
# --- command dispatch ---------------------------------------------------
|
||||
|
||||
switch ($Command) {
|
||||
|
||||
"start" {
|
||||
if (Test-ServerRunning) {
|
||||
$pid = Get-StoredPid
|
||||
Write-Host "[dev-server] Already running (PID $pid) at $ApiUrl"
|
||||
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$lgExe = Find-LangGraphExe
|
||||
Write-Host "[dev-server] Starting langgraph dev server..."
|
||||
Write-Host "[dev-server] exe: $lgExe"
|
||||
Write-Host "[dev-server] dir: $SkillDir"
|
||||
|
||||
# Remove stale logs
|
||||
Remove-Item $StdoutLog -ErrorAction SilentlyContinue
|
||||
Remove-Item $StderrLog -ErrorAction SilentlyContinue
|
||||
|
||||
$proc = Start-Process -FilePath $lgExe `
|
||||
-ArgumentList "dev", "--no-browser" `
|
||||
-WorkingDirectory $SkillDir `
|
||||
-RedirectStandardOutput $StdoutLog `
|
||||
-RedirectStandardError $StderrLog `
|
||||
-WindowStyle Hidden -PassThru
|
||||
|
||||
if (-not $proc) {
|
||||
Write-Error "[dev-server] Failed to launch process"
|
||||
exit 1
|
||||
}
|
||||
$proc.Id | Out-File -FilePath $PidFile -Encoding ascii -NoNewline
|
||||
|
||||
# Wait for server to respond
|
||||
$elapsed = 0
|
||||
while (-not (Test-HttpOk "$ApiUrl/ok") -and $elapsed -lt $MaxWait) {
|
||||
Start-Sleep -Seconds 1
|
||||
$elapsed++
|
||||
if ($proc.HasExited) {
|
||||
Write-Error "[dev-server] Process exited unexpectedly (code $($proc.ExitCode))"
|
||||
Write-Host "--- stdout ($StdoutLog) ---"
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
|
||||
Write-Host "--- stderr ($StderrLog) ---"
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
|
||||
Remove-Item $PidFile -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($elapsed -ge $MaxWait) {
|
||||
Write-Error "[dev-server] Server did not become ready within ${MaxWait}s"
|
||||
Write-Host "--- stdout ($StdoutLog) ---"
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog -Tail 30 }
|
||||
Write-Host "--- stderr ($StderrLog) ---"
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog -Tail 30 }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[dev-server] Ready in ${elapsed}s"
|
||||
Write-Host "[dev-server] API: $ApiUrl"
|
||||
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
|
||||
Write-Host "[dev-server] Logs: $StdoutLog / $StderrLog"
|
||||
}
|
||||
|
||||
"stop" {
|
||||
$pid = Get-StoredPid
|
||||
if ((Stop-ServerProcess -PidValue $pid)) {
|
||||
Write-Host "[dev-server] Stopped (PID $pid)"
|
||||
} else {
|
||||
Write-Host "[dev-server] Not running (nothing to stop)"
|
||||
}
|
||||
Remove-Item $PidFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
"status" {
|
||||
if (Test-ServerRunning) {
|
||||
$pid = Get-StoredPid
|
||||
Write-Host "[dev-server] Running (PID $pid) at $ApiUrl"
|
||||
Write-Host "[dev-server] Studio: https://smith.langchain.com/studio/?baseUrl=$ApiUrl"
|
||||
} else {
|
||||
Write-Host "[dev-server] Not running"
|
||||
}
|
||||
}
|
||||
|
||||
default {
|
||||
Write-Error "Usage: manage_dev_server.ps1 {start|stop|status}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
"""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
|
||||
@@ -5,63 +14,68 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
SERVER_URL = "http://127.0.0.1:2024"
|
||||
ASSISTANT_ID = "plan_review"
|
||||
THREAD_MAP_FILENAME = "workflow_threads.json"
|
||||
|
||||
|
||||
DB_FILENAME = "workflow_state.db"
|
||||
# ── path helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_work_dir(value: str) -> Path:
|
||||
return Path(value or ".").resolve()
|
||||
|
||||
|
||||
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.
|
||||
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:
|
||||
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
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
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 _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")
|
||||
|
||||
|
||||
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)
|
||||
# ── 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(
|
||||
*,
|
||||
@@ -69,113 +83,210 @@ def run_workflow(
|
||||
user_prompt: str = "",
|
||||
notes_dir: str = "Notes",
|
||||
work_dir: str = "",
|
||||
runner: AgentRunner,
|
||||
codex_review_command: list[str],
|
||||
thread_id: str | None = None,
|
||||
resume_payload=None,
|
||||
timeout: int = 600,
|
||||
server_url: str = SERVER_URL,
|
||||
) -> 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)
|
||||
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)
|
||||
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}}
|
||||
# ── verify server is up ───────────────────────────────────────────────────
|
||||
try:
|
||||
httpx.get(f"{server_url}/ok", timeout=5).raise_for_status()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"langgraph dev server not reachable at {server_url}.\n"
|
||||
f"Start it first:\n"
|
||||
f" PowerShell -File C:\\Users\\bowei\\.claude\\skills\\langgraph-plan-review\\scripts\\manage_dev_server.ps1 start\n"
|
||||
f"Error: {exc}"
|
||||
)
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
interrupts = result.get("__interrupt__", [])
|
||||
if interrupts:
|
||||
intr = interrupts[0]
|
||||
action_required = intr.value if hasattr(intr, "value") else intr
|
||||
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_thread,
|
||||
"db_path": str(db_path),
|
||||
"action_required": action_required,
|
||||
"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_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),
|
||||
"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="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.",
|
||||
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="",
|
||||
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.")
|
||||
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_review_command = args.codex_review_command_b64 or args.codex_review_command
|
||||
if not codex_review_command:
|
||||
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")
|
||||
|
||||
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
|
||||
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(
|
||||
@@ -183,9 +294,11 @@ def main() -> int:
|
||||
user_prompt=args.prompt,
|
||||
notes_dir=args.notes_dir,
|
||||
work_dir=args.work_dir,
|
||||
runner=runner,
|
||||
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))
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TypedDict
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from agents import AgentRunner
|
||||
|
||||
|
||||
def _default_review_cmd() -> list[str]:
|
||||
"""Return the real review_task.py command using the current interpreter."""
|
||||
review_py = Path(__file__).parent / "review_task.py"
|
||||
return [
|
||||
sys.executable,
|
||||
str(review_py),
|
||||
"$task_path",
|
||||
"$latest_discuss_path",
|
||||
"$codex_session_id",
|
||||
"$work_dir",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowState(TypedDict):
|
||||
task_id: str
|
||||
user_prompt: str
|
||||
@@ -129,7 +144,7 @@ def values_from_state(state: WorkflowState) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def build_workflow(runner: AgentRunner, *, checkpointer=None):
|
||||
def build_workflow(*, checkpointer=None):
|
||||
graph = StateGraph(WorkflowState)
|
||||
|
||||
def initialize(state: WorkflowState) -> WorkflowState:
|
||||
@@ -163,7 +178,11 @@ def build_workflow(runner: AgentRunner, *, checkpointer=None):
|
||||
validate_task_file(state["task_path"])
|
||||
return state
|
||||
|
||||
def review(state: WorkflowState) -> WorkflowState:
|
||||
def review(state: WorkflowState, config: RunnableConfig) -> WorkflowState:
|
||||
cfg = config.get("configurable", {})
|
||||
codex_cmd: list[str] = cfg.get("codex_review_command") or _default_review_cmd()
|
||||
timeout: int = int(cfg.get("codex_timeout", 600))
|
||||
runner = AgentRunner(codex_review_command=codex_cmd, timeout=timeout)
|
||||
result = runner.review(values_from_state(state))
|
||||
updates = {
|
||||
**state,
|
||||
|
||||
Reference in New Issue
Block a user