Commit b7324fcb670831962235f8c03f079f3e7d71f24d

Authored by bboonnee
1 parent 96555315

U-006: c_work_graph_execute dynamic-workflow wrapper (skeleton)

write_workflow_script(): writes an orchestration script to
<target>/.claude/workflows/<name>.js -- versioned with the target project's
own git history, not graphlemon's (03-C §3.5 step 1).

execute_work_graph(): `claude -p 'Run /<name> with args {...}' --output-format
json --json-schema <schema> --permission-mode auto`, no `--bare` (A-1 spike:
skips credential discovery, breaks headless invocation), never
bypassPermissions. Returns structured_output. No budget-usage bookkeeping
or CLASSIFIER_BLOCKED handling yet -- U-012's job ("骨架" here).

Real-call coverage reuses spikes/a1-dynamic-workflows/workflow-under-test.js
verbatim (read from that file, not retyped) through the new wrapper --
"直接复用已验证的代码路径" per development-plan.md U-006, not just citing the
old spike as prior art. Confirmed passing for real: same result as the
original spike (sum=29 for [2,3,4]).

8 new mocked stdlib unittest cases + 1 gated real-agent test. 53 total
mocked tests across U-001-006, all passing; both gated real-agent tests
(U-004/U-005's planner call, U-006's reused spike workflow) re-verified
manually this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/test_work_graph_execute.py 0 → 100644
  1 +"""U-006 tests: c_work_graph_execute's dynamic-workflow wrapper, mocked at the
  2 +subprocess boundary (CLAUDE.md Testing constraint). Real end-to-end coverage
  3 +is in src/test_work_graph_execute_e2e.py (RUN_REAL_AGENT_E2E=1), reusing the
  4 +exact spike script the A-1 assumption was validated against.
  5 +
  6 +Run: python3 -m unittest src.test_work_graph_execute -v
  7 +"""
  8 +
  9 +import json
  10 +import os
  11 +import subprocess
  12 +import tempfile
  13 +import unittest
  14 +from unittest.mock import patch
  15 +
  16 +from src import work_graph_execute
  17 +
  18 +
  19 +def _completed_process(returncode=0, stdout_obj=None, stderr=""):
  20 + stdout = json.dumps(stdout_obj) if stdout_obj is not None else ""
  21 + return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr)
  22 +
  23 +
  24 +class WriteWorkflowScriptTest(unittest.TestCase):
  25 + def test_writes_into_target_claude_workflows_not_graphlemon_repo(self):
  26 + with tempfile.TemporaryDirectory() as tmp:
  27 + path = work_graph_execute.write_workflow_script(tmp, "run-123", "export const meta = {}")
  28 + self.assertEqual(path, os.path.join(tmp, ".claude", "workflows", "run-123.js"))
  29 + with open(path, encoding="utf-8") as f:
  30 + self.assertEqual(f.read(), "export const meta = {}")
  31 +
  32 +
  33 +class ExecuteWorkGraphTest(unittest.TestCase):
  34 + SCHEMA = {"type": "object", "required": ["sum"], "properties": {"sum": {"type": "number"}}}
  35 +
  36 + def test_happy_path_returns_structured_output(self):
  37 + with patch("src.work_graph_execute.subprocess.run") as run:
  38 + run.return_value = _completed_process(
  39 + stdout_obj={"is_error": False, "structured_output": {"sum": 29}}
  40 + )
  41 + output = work_graph_execute.execute_work_graph(
  42 + "/tmp/some-project", "run-123", {"numbers": [2, 3, 4]}, self.SCHEMA
  43 + )
  44 + self.assertEqual(output, {"sum": 29})
  45 +
  46 + def test_invokes_claude_with_expected_args_and_cwd(self):
  47 + with patch("src.work_graph_execute.subprocess.run") as run:
  48 + run.return_value = _completed_process(stdout_obj={"is_error": False, "structured_output": {"sum": 1}})
  49 + work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {"n": 1}, self.SCHEMA)
  50 +
  51 + args, kwargs = run.call_args
  52 + argv = args[0]
  53 + self.assertEqual(argv[0], "claude")
  54 + self.assertIn("-p", argv)
  55 + self.assertIn("Run /run-123 with args {\"n\": 1}", argv)
  56 + self.assertIn("--json-schema", argv)
  57 + self.assertIn(json.dumps(self.SCHEMA), argv)
  58 + self.assertIn("--permission-mode", argv)
  59 + self.assertIn("auto", argv)
  60 + self.assertNotIn("--bare", argv)
  61 + self.assertNotIn("--dangerously-skip-permissions", argv)
  62 + self.assertNotIn("bypassPermissions", argv)
  63 + self.assertEqual(kwargs["cwd"], "/tmp/some-project")
  64 + self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
  65 +
  66 + def test_nonzero_exit_raises(self):
  67 + with patch("src.work_graph_execute.subprocess.run") as run:
  68 + run.return_value = _completed_process(returncode=1, stdout_obj=None, stderr="boom")
  69 + with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
  70 + work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)
  71 +
  72 + def test_is_error_true_raises(self):
  73 + with patch("src.work_graph_execute.subprocess.run") as run:
  74 + run.return_value = _completed_process(stdout_obj={"is_error": True})
  75 + with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
  76 + work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)
  77 +
  78 + def test_missing_structured_output_raises(self):
  79 + with patch("src.work_graph_execute.subprocess.run") as run:
  80 + run.return_value = _completed_process(stdout_obj={"is_error": False})
  81 + with self.assertRaises(work_graph_execute.WorkGraphExecuteError):
  82 + work_graph_execute.execute_work_graph("/tmp/some-project", "run-123", {}, self.SCHEMA)
  83 +
  84 +
  85 +if __name__ == "__main__":
  86 + unittest.main()
src/test_work_graph_execute_e2e.py 0 → 100644
  1 +"""U-006's real-call coverage: reuses the exact script the A-1 spike validated
  2 +(spikes/a1-dynamic-workflows/workflow-under-test.js) through the new
  3 +write_workflow_script/execute_work_graph wrapper, not a re-typed copy --
  4 +"直接复用...已验证的代码路径" per development-plan.md U-006. Gated behind
  5 +RUN_REAL_AGENT_E2E=1 (CLAUDE.md Testing constraint).
  6 +
  7 +Run: RUN_REAL_AGENT_E2E=1 python3 -m unittest src.test_work_graph_execute_e2e -v
  8 +"""
  9 +
  10 +import os
  11 +import subprocess
  12 +import tempfile
  13 +import unittest
  14 +
  15 +from src import work_graph_execute
  16 +from src.test_support import skip_unless_env
  17 +
  18 +_SPIKE_SCRIPT = os.path.join(
  19 + os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  20 + "spikes", "a1-dynamic-workflows", "workflow-under-test.js",
  21 +)
  22 +_SCHEMA = {
  23 + "type": "object",
  24 + "required": ["input_numbers", "squares", "sum"],
  25 + "properties": {
  26 + "input_numbers": {"type": "array"},
  27 + "squares": {"type": "array"},
  28 + "sum": {"type": "number"},
  29 + },
  30 +}
  31 +
  32 +
  33 +@skip_unless_env("RUN_REAL_AGENT_E2E")
  34 +class RealWorkGraphExecuteTest(unittest.TestCase):
  35 + def test_reused_a1_spike_script_runs_through_the_new_wrapper(self):
  36 + with tempfile.TemporaryDirectory() as tmp:
  37 + subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
  38 + with open(_SPIKE_SCRIPT, encoding="utf-8") as f:
  39 + script_content = f.read()
  40 +
  41 + work_graph_execute.write_workflow_script(tmp, "graphlemon-a1-spike", script_content)
  42 + output = work_graph_execute.execute_work_graph(
  43 + tmp, "graphlemon-a1-spike", {"numbers": [2, 3, 4]}, _SCHEMA, timeout=120
  44 + )
  45 +
  46 + self.assertEqual(output["input_numbers"], [2, 3, 4])
  47 + self.assertEqual(output["squares"], [4, 9, 16])
  48 + self.assertEqual(output["sum"], 29)
  49 +
  50 +
  51 +if __name__ == "__main__":
  52 + unittest.main()
src/work_graph_execute.py 0 → 100644
  1 +"""c_work_graph_execute (03-C §3.5): dynamic-workflow invocation skeleton.
  2 +
  3 +U-006 scope: write an orchestration script into the target project's
  4 +`.claude/workflows/<name>.js` and invoke it via `claude -p 'Run /<name> with
  5 +args {...}'`, reusing the exact code path the A-1 spike validated
  6 +(spikes/a1-dynamic-workflows/, confirmed 2026-07-29). No budget-usage
  7 +bookkeeping or CLASSIFIER_BLOCKED handling yet -- that's U-012's job
  8 +("骨架" here, per development-plan.md).
  9 +"""
  10 +
  11 +import json
  12 +import os
  13 +import subprocess
  14 +
  15 +
  16 +class WorkGraphExecuteError(Exception):
  17 + """Raised when the claude subprocess exits non-zero, reports is_error, or
  18 + produces no structured_output."""
  19 +
  20 +
  21 +def write_workflow_script(target_folder_path, name, script_content):
  22 + """Writes `<target>/.claude/workflows/<name>.js` -- versioned with the
  23 + target project's own git history (03-C §3.5 step 1), not graphlemon's."""
  24 + workflows_dir = os.path.join(target_folder_path, ".claude", "workflows")
  25 + os.makedirs(workflows_dir, exist_ok=True)
  26 + path = os.path.join(workflows_dir, name + ".js")
  27 + with open(path, "w", encoding="utf-8") as f:
  28 + f.write(script_content)
  29 + return path
  30 +
  31 +
  32 +def execute_work_graph(target_folder_path, name, args, output_schema, timeout=600):
  33 + """Runs the workflow already written to `.claude/workflows/<name>.js`
  34 + (write_workflow_script) via `claude -p 'Run /<name> with args {...}'`.
  35 +
  36 + No `--bare` (A-1 spike: skips credential/login discovery, unrelated to
  37 + workflow presence). `--permission-mode auto`, never `bypassPermissions`/
  38 + `--dangerously-skip-permissions` (05 §5.3.1). Returns the parsed
  39 + `structured_output` dict.
  40 + """
  41 + prompt = "Run /{} with args {}".format(name, json.dumps(args))
  42 + result = subprocess.run(
  43 + [
  44 + "claude", "-p", prompt,
  45 + "--output-format", "json",
  46 + "--json-schema", json.dumps(output_schema),
  47 + "--permission-mode", "auto",
  48 + ],
  49 + cwd=target_folder_path,
  50 + stdin=subprocess.DEVNULL,
  51 + capture_output=True,
  52 + text=True,
  53 + timeout=timeout,
  54 + )
  55 + if result.returncode != 0:
  56 + raise WorkGraphExecuteError(
  57 + "workflow {} exited {}: {}".format(name, result.returncode, result.stderr)
  58 + )
  59 +
  60 + payload = json.loads(result.stdout)
  61 + if payload.get("is_error"):
  62 + raise WorkGraphExecuteError("workflow {} reported is_error: {}".format(name, payload))
  63 +
  64 + structured_output = payload.get("structured_output")
  65 + if structured_output is None:
  66 + raise WorkGraphExecuteError("workflow {} produced no structured_output: {}".format(name, payload))
  67 + return structured_output