Skip to content

Commit d77075f

Browse files
feat(workflow): script-tier run surface + author shim — U5+U6 (#312) (#399)
* feat(workflow): script-tier run surface + author shim — U5+U6 (#312) Tier dispatch on run/validate/resume/cancel/status, ScriptSpec + .py indexing with collision reject, typed-error→HTTP mapping, stdlib cao_workflow shim, authoring guide + examples gallery. * fix(workflow): close CodeQL path-injection alerts + journal schema self-heal _safe_spec_path now uses resolve()+is_relative_to (recognized sanitizer, covers both flagged sinks); workflow_journal._connect runs idempotent migrators so test_cancel_run_unknown_404 passes in isolation. Adds traversal/symlink rejection tests. * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(workflow): validate every spec sink + bound spec reads _read_script_spec self-validates via _safe_spec_path (closes .py-by-name index bypass); rebuild/upsert store validated resolved paths; all four spec reads capped at WORKFLOW_MAX_SPEC_BYTES+1. Fixes cancel journal fallback, .py byte cap, UnicodeDecodeError mapping; restores absolute-path contract. * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(workflow): use repo-standard realpath containment idiom _safe_spec_path now mirrors path_validation.py (realpath + startswith(base+os.sep)) — the barrier this repo's CodeQL accepts; reverts the broken autofix re-anchoring (37 test failures) and removes the crashing WORKFLOW_NAME_RE.match str call. All 13b02de protections stand. * fix(workflow): return plain realpath str from _safe_spec_path Matches path_validation.py's return type exactly — the Path() wrap after the containment check was the remaining round-trip CodeQL's barrier model doesn't track (alerts #158/#160). Behavior-neutral type ripple; all sinks consume the validated str directly. * fix(workflow): script-tier list crash + steps stuck RUNNING Bug 1: cao workflow list crashed (TypeError) on a script spec — step_count is None and was formatted with :<6; render it as '-'. Bug 2: script per-step state was seeded RUNNING but never transitioned; add record_step_completion (RUNNING->COMPLETED/FAILED, mirroring the BR-31 guard + YAML tier), wired into the run_step endpoint with best-effort journal write-through so resume sees completed steps. * fix(workflow): CI format, env_vars on session_name path, idle completion + interruptible cancel Black-format two files; merge per-step env_vars into existing-session windows (#408); accept stable post-input IDLE as step completion and let cancel interrupt the in-flight wait so runs converge (#409). * fix(workflow): address script run review findings --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent d8af82b commit d77075f

49 files changed

Lines changed: 3989 additions & 247 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish-to-pypi.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ jobs:
115115
cao --help
116116
cao-server --help
117117
cao-mcp-server --help
118+
python -c "import cao_workflow; assert cao_workflow.run_step"
118119
119120
publish-pypi:
120121
name: Publish to PyPI (maintainer-approved)

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525

2626
- add provider support (#272)
2727

28+
- add script-tier workflows: a `.py` workflow spec is now runnable via `cao workflow run` (and the `workflow_run`/`workflow_cancel` MCP tools), with the same `resume`/`cancel`/`status` support as YAML workflows — tier is detected automatically from the file extension (#312)
29+
- add optional `skills` field to `AgentProfile` to scope the per-agent skill catalog via an fnmatch allowlist; runtime-prompt providers only, `load_skill` resolution unchanged (#351)
2830
- add herdr terminal backend with event-driven inbox delivery (#271)
2931

3032
- bundle built-in memory plugins for Claude Code, Kiro, and Codex (#269)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Conditional shape — shim-based (M1, FR-7.1).
2+
3+
Branches on a simple author-controlled condition and runs a DIFFERENT step
4+
depending on which branch is taken — proving only the taken branch's
5+
``run_step`` call executes, never both.
6+
7+
The branch condition is a plain in-script constant (NOT read from the
8+
environment or an external input) — U4's constructed spawn env carries only
9+
the fixed identity allowlist (``CAO_WORKFLOW_RUN_ID``/``GENERATION``/
10+
``CAO_API_BASE_URL``/``PATH``/``HOME``), so a script's own control flow must
11+
be deterministic from its own source, not from ambient environment state
12+
(the authoring guide's determinism obligation). Edit ``IS_URGENT`` below to
13+
exercise the other branch.
14+
15+
Run standalone via ``cao workflow run --script docs/examples/conditional_example.py``.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from cao_workflow import emit_output, run_step
21+
22+
IS_URGENT = True
23+
24+
25+
def main() -> None:
26+
if IS_URGENT:
27+
handle = run_step("kiro_cli", "reviewer", "escalate this incident", step_id="urgent-branch")
28+
branch_taken = "urgent"
29+
else:
30+
handle = run_step("kiro_cli", "reviewer", "file this for later", step_id="routine-branch")
31+
branch_taken = "routine"
32+
33+
emit_output({"branch_taken": branch_taken, "output": handle.output})
34+
35+
36+
if __name__ == "__main__":
37+
main()

docs/examples/fanout_example.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Fan-out shape — shim-based (M1, FR-7.1).
2+
3+
Runs several agent steps concurrently via ``concurrent.futures``. Each
4+
concurrent call passes an EXPLICIT ``step_id`` — the sequential counter
5+
fallback is UNSAFE across runs under concurrent scheduling (BR-13); an
6+
explicit label per shard is REQUIRED, not optional, whenever ``run_step`` is
7+
called from more than one thread.
8+
9+
Run standalone via ``cao workflow run --script docs/examples/fanout_example.py``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from concurrent.futures import ThreadPoolExecutor, as_completed
15+
16+
from cao_workflow import emit_output, run_step
17+
18+
SHARDS = ["alpha", "beta", "gamma"]
19+
20+
21+
def _run_shard(shard: str):
22+
handle = run_step("kiro_cli", "reviewer", f"review shard {shard}", step_id=f"shard-{shard}")
23+
return shard, handle.output
24+
25+
26+
def main() -> None:
27+
results = {}
28+
with ThreadPoolExecutor(max_workers=len(SHARDS)) as pool:
29+
futures = [pool.submit(_run_shard, shard) for shard in SHARDS]
30+
for future in as_completed(futures):
31+
shard, output = future.result()
32+
results[shard] = output
33+
emit_output({"shards": results})
34+
35+
36+
if __name__ == "__main__":
37+
main()

docs/examples/loop_example.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Loop shape — shim-based (M1, FR-7.1).
2+
3+
Runs the same agent step N times, once per iteration. Sequential calls with
4+
no explicit ``step_id`` are safe: the shim's lock-guarded counter assigns
5+
``call-1``, ``call-2``, ... in program order (ADR-10's sequential case).
6+
7+
Run standalone via ``cao workflow run --script docs/examples/loop_example.py``.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from cao_workflow import emit_output, run_step
13+
14+
ITERATIONS = 3
15+
16+
17+
def main() -> None:
18+
outputs = []
19+
for i in range(ITERATIONS):
20+
handle = run_step("kiro_cli", "reviewer", f"summarize item {i}")
21+
outputs.append(handle.output)
22+
emit_output({"iterations": ITERATIONS, "outputs": outputs})
23+
24+
25+
if __name__ == "__main__":
26+
main()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Loop shape — raw HTTP, NO shim (Q2=A shim-optionality proof, FR-6.1).
2+
3+
Demonstrates that the shim is a convenience, not a requirement: this script
4+
reads the SAME three identity env vars ``cao workflow run`` injects and posts
5+
to ``/terminals/run-step`` with stdlib ``urllib`` directly, with no
6+
``cao_workflow`` import at all. Compare against ``loop_example.py`` — same
7+
shape, same env contract, no shim.
8+
9+
Run standalone via ``cao workflow run --script docs/examples/loop_raw_http_example.py``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import os
16+
from urllib.request import Request, urlopen
17+
18+
ITERATIONS = 3
19+
20+
21+
def main() -> None:
22+
run_id = os.environ["CAO_WORKFLOW_RUN_ID"]
23+
generation = os.environ["CAO_WORKFLOW_GENERATION"]
24+
base_url = os.environ["CAO_API_BASE_URL"]
25+
26+
outputs = []
27+
for i in range(ITERATIONS):
28+
body = {
29+
"provider": "kiro_cli",
30+
"agent": "reviewer",
31+
"prompt": f"summarize item {i}",
32+
"env_vars": {
33+
"CAO_WORKFLOW_RUN_ID": run_id,
34+
"CAO_WORKFLOW_GENERATION": generation,
35+
"CAO_WORKFLOW_STEP_ID": f"call-{i + 1}",
36+
},
37+
}
38+
request = Request(
39+
f"{base_url}/terminals/run-step",
40+
data=json.dumps(body).encode("utf-8"),
41+
headers={"Content-Type": "application/json"},
42+
method="POST",
43+
)
44+
with urlopen(request, timeout=630.0) as response:
45+
data = json.loads(response.read().decode("utf-8"))
46+
outputs.append(data["last_message"])
47+
48+
print(f"CAO_WORKFLOW_OUTPUT:{json.dumps({'iterations': ITERATIONS, 'outputs': outputs})}")
49+
50+
51+
if __name__ == "__main__":
52+
main()
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Authoring `cao workflow` scripts
2+
3+
This guide is for anyone writing a Python script to run under
4+
`cao workflow run --script <path>` (the "script tier" — a full Python
5+
program driving one or more agent steps, as opposed to the declarative YAML
6+
tier). It covers the `cao_workflow` shim's contract, when to reach for a
7+
script instead of YAML, the determinism obligation resume relies on, and the
8+
resume boundary itself.
9+
10+
## The contract: `run_step` and `emit_output`
11+
12+
Your script's entire interface to the CAO server is the `cao_workflow`
13+
package — a thin, stdlib-only client library (`urllib` transport, zero
14+
`cli_agent_orchestrator` imports) that runs *inside your script's own
15+
process*, not inside the server.
16+
17+
```python
18+
from cao_workflow import run_step, emit_output
19+
20+
handle = run_step("kiro_cli", "reviewer", "review this diff")
21+
print(handle.output) # handle.terminal_id, handle.status also available
22+
23+
emit_output({"reviewed": True})
24+
```
25+
26+
- **`run_step(provider, agent, prompt, *, step_id=None, timeout=None, **opts) -> StepHandle`**
27+
runs one agent step through the same shared substrate the server's own
28+
handoff path uses. It resolves your run's identity from the environment,
29+
posts to `/terminals/run-step`, and returns a `StepHandle` with
30+
`.step_id`, `.terminal_id`, `.output`, `.status`.
31+
- **`emit_output(value)`** prints the run-level `CAO_WORKFLOW_OUTPUT:{json}`
32+
sentinel line the server scans once your script exits — a one-line
33+
convenience so you never hand-format the prefix/JSON encoding yourself.
34+
It is pure `print()`; no HTTP call, no state.
35+
- **Errors are typed and never retried:** `ShimIdentityError` (identity env
36+
missing — nothing was attempted), `ShimTransportError` (network failure,
37+
wraps the underlying `urllib` error), `ShimHTTPError` (non-2xx response,
38+
carries `.status`/`.body`). All four (`ShimError` plus these three
39+
subclasses) are importable from `cao_workflow` directly:
40+
`from cao_workflow import run_step, ShimHTTPError`.
41+
- **`step_id` is required for concurrent fan-out.** If you call `run_step`
42+
from more than one thread (e.g. via `concurrent.futures`), pass an
43+
explicit, stable `step_id` per call. See "Fan-out and `step_id`" below —
44+
this is not optional ergonomics, it is a correctness requirement.
45+
- **`reuse_terminal_id` is not supported through `run_step`.** The shim
46+
always sends identity `env_vars`, and the server unconditionally rejects
47+
`env_vars` + `reuse_terminal_id` together (422) — `run_step` fails fast
48+
client-side with a `ShimError` instead of round-tripping an opaque 422. If
49+
you genuinely need terminal reuse without the identity fence, call the
50+
HTTP API directly.
51+
52+
## YAML vs. script: which tier should this workflow use?
53+
54+
Use the **YAML tier** (`cao workflow validate`/`list`/`get`/`run` against a
55+
declarative spec) when your workflow is a fixed sequence or simple
56+
branch/loop expressible in the YAML grammar — it is simpler to author,
57+
lint, and reason about, and it is the tier most of CAO's tooling assumes by
58+
default.
59+
60+
Use a **script** (this guide) when your workflow's control flow needs
61+
something YAML can't express yet: nontrivial branching logic, real
62+
concurrent fan-out (`concurrent.futures`/`threading`), or per-iteration
63+
Python computation over agent output. A script is a full Python program —
64+
more power, more responsibility (see the determinism obligation next).
65+
66+
If you're unsure, start with YAML. Reach for a script only when you hit a
67+
concrete limitation.
68+
69+
## The determinism obligation (and repeated work on resume)
70+
71+
`run_step` never retries, backs off, or reconnects. A client-side retry after
72+
an unknown-completion-state transport failure could issue the same agent work
73+
twice, so failures are returned to your script unchanged.
74+
75+
This puts an obligation on **your script**: its control flow and the
76+
prompts it sends must be deterministic across runs of the *same* script
77+
source. If your script's behavior can vary run-to-run — a `random()` call
78+
that isn't seeded, a prompt that embeds `datetime.now()`, branching on
79+
wall-clock time — a **resume** can repeat different work from the original
80+
attempt. The current runtime does not replay completed calls or reject that
81+
divergence: it re-executes the frozen script from the top, including every
82+
`run_step` call. Determinism makes that repeated work predictable, but your
83+
workflow must tolerate it being issued again.
84+
85+
## Fan-out and `step_id`
86+
87+
`run_step`'s step key defaults to a lock-guarded sequential counter
88+
(`call-1`, `call-2`, ...) when you omit `step_id`. That counter is
89+
**race-free** under concurrent callers — the lock guarantees no two calls
90+
ever get the same key — but it is **not safe for fan-out**. Thread
91+
scheduling, not the counter's correctness, decides which call claims which
92+
`call-N`, so two runs of the same fan-out script can assign `call-1`/
93+
`call-2` to different logical calls depending on how the OS scheduled your
94+
threads that run. On resume, that reassignment makes journal history and
95+
diagnostics refer to different logical calls under the same `call-N` key.
96+
97+
**The rule:** any time you call `run_step` from more than one thread
98+
(`concurrent.futures.ThreadPoolExecutor`, manual `threading.Thread`), pass
99+
an explicit, stable `step_id` per call:
100+
101+
```python
102+
def _run_shard(shard):
103+
return run_step("kiro_cli", "reviewer", f"review {shard}", step_id=f"shard-{shard}")
104+
105+
with ThreadPoolExecutor(max_workers=3) as pool:
106+
futures = [pool.submit(_run_shard, s) for s in ("alpha", "beta", "gamma")]
107+
```
108+
109+
The linter (`cao workflow validate`) does not infer concurrency or check this
110+
rule. It reports syntax errors, disallowed or unverifiable dynamic imports,
111+
and imports associated with nondeterminism. Supplying stable `step_id` values
112+
for fan-out is the author's responsibility.
113+
114+
## Resume re-executes the frozen script
115+
116+
`run_step()` behaves **identically** whether the surrounding script is a
117+
fresh run or a resume drive. There is no `if resuming:` branch inside the
118+
shim. `cao workflow resume` re-executes the frozen source snapshot from the
119+
top with a new generation token, and each `run_step` call makes a new HTTP
120+
request that executes the agent step again. Completed calls are journaled,
121+
and a replay lookup primitive exists in the journal layer, but it is not
122+
connected to the run-step route and cannot currently suppress repeated work.
123+
124+
Design scripts so repeated steps are acceptable, or add application-level
125+
idempotency around external side effects. `CAO_WORKFLOW_RESUME=1` is present
126+
in the resumed subprocess environment for code that must distinguish the
127+
drive, but the `cao_workflow` shim itself does not branch on it.
128+
129+
## The `reuse_terminal_id` 422 trap
130+
131+
If you pass `reuse_terminal_id` through `run_step(..., reuse_terminal_id=...)`,
132+
you'll get a `ShimError` immediately, before any network call:
133+
134+
```
135+
ShimError: reuse_terminal_id is not supported by run_step() — the shim
136+
always sends env_vars (RUN_ID/GENERATION/STEP_ID), and the server rejects
137+
env_vars + reuse_terminal_id together (422). Omit reuse_terminal_id, or
138+
call the HTTP API directly if you need to reuse a terminal without the
139+
identity fence.
140+
```
141+
142+
This isn't a shim bug or an arbitrary restriction: `run_step` **always**
143+
populates the identity `env_vars` fence (`CAO_WORKFLOW_RUN_ID`/
144+
`GENERATION`/`STEP_ID`), and the server's own request validator
145+
unconditionally rejects any request carrying both `env_vars` and
146+
`reuse_terminal_id` — the combination can never legitimately round-trip.
147+
Passing it through `**opts` would always produce an opaque 422; the shim
148+
fails fast instead so the mutual exclusivity is visible immediately. If you
149+
need terminal reuse without the identity fence, that's a case for calling
150+
`/terminals/run-step` directly over HTTP, outside the shim.
151+
152+
## Examples
153+
154+
Each example under [`docs/examples/`](examples/) demonstrates one of these
155+
patterns end-to-end, with a matching e2e test proving it runs:
156+
157+
- [`loop_example.py`](examples/loop_example.py) — sequential loop, default
158+
`step_id` counter.
159+
- [`conditional_example.py`](examples/conditional_example.py) — branching
160+
control flow, explicit `step_id` per branch.
161+
- [`fanout_example.py`](examples/fanout_example.py) — concurrent fan-out via
162+
`ThreadPoolExecutor`, explicit `step_id` per shard (the fan-out rule
163+
above, applied).
164+
- [`loop_raw_http_example.py`](examples/loop_raw_http_example.py) — the
165+
SAME loop shape with **no** `cao_workflow` import at all, using raw
166+
`urllib` directly against the identity env vars `cao workflow run`
167+
injects. Proves the shim is a convenience, not a requirement — a script
168+
is free to skip it entirely and talk to `/terminals/run-step` on its own.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ requires = ["hatchling"]
7575
build-backend = "hatchling.build"
7676

7777
[tool.hatch.build.targets.wheel]
78-
packages = ["src/cli_agent_orchestrator"]
78+
packages = ["src/cli_agent_orchestrator", "src/cao_workflow"]
7979

8080
[tool.hatch.build]
8181
artifacts = [

0 commit comments

Comments
 (0)