feat(mm-worktree-task): run from any planner-backed repo — cp_cli dir + context resolution (--context, MM_WORKTREE_TASK_CP_*), empty-docs guard, tests + spec

cp_cli lives in agent-runtimes; previously every planner call ran 'python3 -m lib.cp_cli' from the current repo with no --context, so the script only worked inside agent-runtimes. The context now resolves from ~/.cpconfig by matching project_id to the repo directory name. Verified live from hatchmate (item + parent + documents fetched via the hatchmate context).

Claude-Session: https://claude.ai/code/session_01JgJbWzVJioVznVTFyQFEfx
This commit is contained in:
Paul O'Reilly
2026-09-11 16:27:08 +12:00
parent 9488335713
commit f50561ea78
9 changed files with 969 additions and 10 deletions

View File

@@ -1,10 +1,13 @@
{ {
"version": 1, "version": 1,
"last_run": "2026-03-24T10:36:46Z", "last_run": "2026-05-11T09:48:44Z",
"processed": { "processed": {
"log/2026-03-17.095436.md": "253f75f7ae43fb2547cd3377afb8d3b2", "log/2026-03-17.095436.md": "253f75f7ae43fb2547cd3377afb8d3b2",
"log/2026-03-17.105314.md": "9c81013e9ab545527a363f525eff93e3", "log/2026-03-17.105314.md": "9c81013e9ab545527a363f525eff93e3",
"log/2026-03-17.110414.md": "5ad1a211737b604f33e31e9ce754be74", "log/2026-03-17.110414.md": "5ad1a211737b604f33e31e9ce754be74",
"log/2026-03-23.123551.md": "6c9560f5348b0625abb5c4e52b1e0464" "log/2026-03-23.123551.md": "6c9560f5348b0625abb5c4e52b1e0464",
"log/2026-04-12.002212.md": "7bc1c971149fdd04f528e432fd8a1e16",
"log/2026-04-12.003747.md": "17fafffbfc6735c777b2fc71fdd463d2",
"log/2026-04-24.075643.md": "894d58fc7e390bb6af39e71d5219cc3f"
} }
} }

Binary file not shown.

View File

@@ -31,11 +31,21 @@ Options:
--docs all|recent|none Which item documents to bundle (default: recent) --docs all|recent|none Which item documents to bundle (default: recent)
--tools "<list>" Extra --allowedTools entries (comma-separated) --tools "<list>" Extra --allowedTools entries (comma-separated)
--timeout <secs> Sub-session timeout (default: 1800) --timeout <secs> Sub-session timeout (default: 1800)
--context <name> cp-cli planner context (default: the ~/.cpconfig context
whose project_id equals the repo name, else cp-cli's own
current_context)
--upload After --collect, upload the report + mark impl-complete --upload After --collect, upload the report + mark impl-complete
--collect Don't launch; print status for an already-run slug --collect Don't launch; print status for an already-run slug
--dryrun, -n Assemble everything, print it, create nothing --dryrun, -n Assemble everything, print it, create nothing
--help, -h Show this help --help, -h Show this help
Environment:
MM_WORKTREE_TASK_CP_CLI_DIR=<dir> checkout holding lib/cp_cli (default: the
current repo if it has one, else
~/dev/claude/projects/agent-runtimes)
MM_WORKTREE_TASK_CP_CONTEXT=<name> same as --context
MM_WORKTREE_TASK_CPCONFIG=<file> cpconfig to resolve the context from
(default: ~/.cpconfig)
Env overrides (for testing): Env overrides (for testing):
MM_WORKTREE_TASK_FAKE_ITEM=<file> fake `planner items get --json` output MM_WORKTREE_TASK_FAKE_ITEM=<file> fake `planner items get --json` output
MM_WORKTREE_TASK_FAKE_DOCS_DIR=<dir> fake docs: <dir>/list.json + <dir>/<name> MM_WORKTREE_TASK_FAKE_DOCS_DIR=<dir> fake docs: <dir>/list.json + <dir>/<name>
@@ -52,6 +62,7 @@ TIMEOUT=1800
UPLOAD=0 UPLOAD=0
COLLECT=0 COLLECT=0
DRYRUN=0 DRYRUN=0
CP_CONTEXT="${MM_WORKTREE_TASK_CP_CONTEXT:-}"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -61,6 +72,7 @@ while [[ $# -gt 0 ]]; do
--docs) DOCS="$2"; shift 2 ;; --docs) DOCS="$2"; shift 2 ;;
--tools) EXTRA_TOOLS="$2"; shift 2 ;; --tools) EXTRA_TOOLS="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;;
--context) CP_CONTEXT="$2"; shift 2 ;;
--upload) UPLOAD=1; shift ;; --upload) UPLOAD=1; shift ;;
--collect) COLLECT=1; shift ;; --collect) COLLECT=1; shift ;;
--dryrun|-n) DRYRUN=1; shift ;; --dryrun|-n) DRYRUN=1; shift ;;
@@ -97,6 +109,50 @@ if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
fi fi
REPO_NAME="$(basename "$REPO_ROOT")" REPO_NAME="$(basename "$REPO_ROOT")"
# --- planner CLI: where it runs from, and which context it talks to ---
# The script is used from any planner-backed project; cp_cli itself lives in
# agent-runtimes. Resolve the checkout once and the context once, then every
# planner call goes through cp_cli().
resolve_cp_cli_dir() {
local d
for d in "${MM_WORKTREE_TASK_CP_CLI_DIR:-}" "$REPO_ROOT" "$HOME/dev/claude/projects/agent-runtimes"; do
[[ -n "$d" && ( -f "$d/lib/cp_cli.py" || -d "$d/lib/cp_cli" ) ]] && { echo "$d"; return 0; }
done
return 1
}
CP_CLI_DIR="$(resolve_cp_cli_dir || true)"
if [[ -z "$CP_CONTEXT" ]]; then
CPCONFIG="${MM_WORKTREE_TASK_CPCONFIG:-$HOME/.cpconfig}"
if [[ -f "$CPCONFIG" ]]; then
CP_CONTEXT="$(python3 - "$CPCONFIG" "$REPO_NAME" <<'PY' 2>/dev/null || true
import sys
try:
import yaml
cfg = yaml.safe_load(open(sys.argv[1])) or {}
except Exception:
sys.exit(0)
for ctx in cfg.get("contexts") or []:
if ctx.get("project_id") == sys.argv[2]:
print(ctx.get("name", ""))
break
PY
)"
fi
fi
cp_cli() {
if [[ -z "$CP_CLI_DIR" ]]; then
err "cannot find lib/cp_cli.py (set MM_WORKTREE_TASK_CP_CLI_DIR or check out agent-runtimes)"
return 1
fi
if [[ -n "$CP_CONTEXT" ]]; then
( cd "$CP_CLI_DIR" && python3 -m lib.cp_cli --context "$CP_CONTEXT" "$@" )
else
( cd "$CP_CLI_DIR" && python3 -m lib.cp_cli "$@" )
fi
}
REPORT_DIR="${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/${REPO_NAME}" REPORT_DIR="${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/${REPO_NAME}"
WORKTREE="${REPO_ROOT}/.claude/worktrees/mm-${SLUG}" WORKTREE="${REPO_ROOT}/.claude/worktrees/mm-${SLUG}"
BRANCH="mm/${SLUG}" BRANCH="mm/${SLUG}"
@@ -140,9 +196,9 @@ if [[ $COLLECT -eq 1 ]]; then
DATE="$(date +%Y-%m-%d)" DATE="$(date +%Y-%m-%d)"
DOC_NAME="mm-${SLUG}-${DATE}.md" DOC_NAME="mm-${SLUG}-${DATE}.md"
info "uploading ${DOC_NAME} to item ${UUID}" info "uploading ${DOC_NAME} to item ${UUID}"
( cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items documents put "$UUID" "$DOC_NAME" --file "$RESULT_FILE" ) cp_cli planner items documents put "$UUID" "$DOC_NAME" --file "$RESULT_FILE"
( cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items patch "$UUID" --flow-state impl-complete \ cp_cli planner items patch "$UUID" --flow-state impl-complete \
--description "mm-worktree-task: ${SLUG} committed on mm/${SLUG}" ) --description "mm-worktree-task: ${SLUG} committed on mm/${SLUG}"
ok "uploaded and marked impl-complete" ok "uploaded and marked impl-complete"
fi fi
exit 0 exit 0
@@ -160,7 +216,7 @@ fetch_item() {
if [[ -n "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then if [[ -n "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then
ITEM_JSON="$(cat "$MM_WORKTREE_TASK_FAKE_ITEM")" ITEM_JSON="$(cat "$MM_WORKTREE_TASK_FAKE_ITEM")"
else else
ITEM_JSON="$(cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items get "$UUID" --json)" ITEM_JSON="$(cp_cli planner items get "$UUID" --json)"
fi fi
TITLE="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("title",""))')" TITLE="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("title",""))')"
fi fi
@@ -173,7 +229,7 @@ fetch_docs() {
[[ -f "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json" ]] || return [[ -f "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json" ]] || return
list_json="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json")" list_json="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json")"
elif [[ -n "$UUID" ]]; then elif [[ -n "$UUID" ]]; then
list_json="$(cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items documents list "$UUID" --json 2>/dev/null || echo '{"documents":[]}')" list_json="$(cp_cli planner items documents list "$UUID" --json 2>/dev/null || echo '{"documents":[]}')"
else else
return return
fi fi
@@ -201,7 +257,7 @@ fetch_docs() {
if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then
DOC_BODY["$name"]="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/${name}" 2>/dev/null || echo "")" DOC_BODY["$name"]="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/${name}" 2>/dev/null || echo "")"
else else
DOC_BODY["$name"]="$(cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items documents get "$UUID" "$name" 2>/dev/null || echo "")" DOC_BODY["$name"]="$(cp_cli planner items documents get "$UUID" "$name" 2>/dev/null || echo "")"
fi fi
fi fi
done < <(echo "$list_json" | python3 -c ' done < <(echo "$list_json" | python3 -c '
@@ -223,7 +279,7 @@ if [[ -n "$ITEM_JSON" ]]; then
PARENT_ID="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("parent_id") or "")')" PARENT_ID="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("parent_id") or "")')"
fi fi
if [[ -n "$PARENT_ID" && -z "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then if [[ -n "$PARENT_ID" && -z "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then
PARENT_JSON="$(cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items get "$PARENT_ID" --json 2>/dev/null || echo "")" PARENT_JSON="$(cp_cli planner items get "$PARENT_ID" --json 2>/dev/null || echo "")"
fi fi
# --- build bundle content --- # --- build bundle content ---
@@ -256,6 +312,7 @@ build_bundle() {
fi fi
echo echo
for n in "${DOC_BUNDLED[@]:-}"; do for n in "${DOC_BUNDLED[@]:-}"; do
[[ -z "$n" ]] && continue
echo "## Document: ${n}" echo "## Document: ${n}"
echo '```' echo '```'
echo "${DOC_BODY[$n]:-}" echo "${DOC_BODY[$n]:-}"
@@ -308,6 +365,8 @@ CMD="cd ${WORKTREE} && setsid nohup timeout ${TIMEOUT} ask-minimax-run --model \
if [[ $DRYRUN -eq 1 ]]; then if [[ $DRYRUN -eq 1 ]]; then
echo "[dryrun] mm-worktree-task — no changes will be made" echo "[dryrun] mm-worktree-task — no changes will be made"
echo " Repo: ${REPO_ROOT}" echo " Repo: ${REPO_ROOT}"
echo " cp-cli from: ${CP_CLI_DIR:-<not found>}"
echo " cp-cli ctx: ${CP_CONTEXT:-<cp-cli default>}"
echo " Slug: ${SLUG}" echo " Slug: ${SLUG}"
echo " UUID: ${UUID:-(none, --brief only)}" echo " UUID: ${UUID:-(none, --brief only)}"
echo " Worktree: ${WORKTREE}" echo " Worktree: ${WORKTREE}"

158
specs/bg-model-call.spec.md Normal file
View File

@@ -0,0 +1,158 @@
# bg-model-call
## Purpose
Submit a prompt to a third-party model via the agent-runtimes control plane, wait for completion, and write the result to a file — enabling the `ScheduleWakeup` polling pattern for second-opinion consultations without blocking the main Claude Code session.
## Usage
```
bg-model-call [OPTIONS]
```
### Required flags
| Flag | Description |
|------|-------------|
| `--model <name>` | Model alias: `minimax`, `sonnet`, `haiku`, `opus` |
| `--prompt <file>` | Path to a file containing the prompt text |
| `--output <file>` | Path where the result will be written |
### Optional flags
| Flag | Default | Description |
|------|---------|-------------|
| `--template <name>` | (derived from `--model`) | Override the task template name (without `.yaml`). Takes precedence over the `--model` → template mapping. |
| `--project <id>` | `bg-model-call` | Project ID sent to the CP, used for task grouping in agent-monitor |
| `--url <cp-url>` | `$CP_URL`, then `http://localhost:8100` | Control plane base URL |
| `--task-name <name>` | `bg-model-call-<model>` | Short task name shown in agent-monitor |
| `--timeout <seconds>` | `1800` | Max seconds to wait for the task before declaring it failed |
| `--poll-interval <seconds>` | `10` | Seconds between CP state polls |
| `--dryrun`, `-n` | — | Resolve all values and print what would be submitted; do not call the CP or write any files |
| `--help`, `-h` | — | Show usage information and exit 0 |
## Model-to-Template Mapping
A constant defined at the top of the script:
| Model alias | Template name |
|-------------|---------------|
| `minimax` | `minimax-planning` |
| `sonnet` | `sonnet-code-repo` |
| `haiku` | `haiku-code-repo` |
| `opus` | `opus-code-repo` |
The `--template` flag overrides this mapping entirely.
## Behaviour
1. Parse and validate all flags. Exit 1 with an error message if required flags are missing or values are invalid.
2. Resolve the CP URL: `--url` flag → `$CP_URL` env var → `http://localhost:8100`.
3. Resolve the template name: `--template` flag if given, otherwise look up `--model` in the mapping table. If the model alias is not in the table, exit 1.
4. Read the prompt text from the `--prompt` file. If the file is not readable, exit 1.
5. Locate the `dispatch-task` CLI:
- Check if `dispatch-task` is on PATH.
- Otherwise try `<script-dir>/../../../projects/agent-runtimes/scripts/dispatch-task` as a fallback (for dev environments).
- If neither is found, fall back to direct `curl` POST to `$CP_URL/tasks`.
6. Submit the task to the CP via the resolved `dispatch-task` (or curl fallback):
- `--name <task-name>`
- `--project-id <project>`
- `--prompt-file <prompt-file>`
- `--template <template>`
- `--cp-url <cp-url>`
- `--format json` (to capture the task_id from stdout)
- The `dispatch-task` tool uses `CP_AUTH_TOKEN` from the environment if present.
7. Extract the `task_id` from the submission response.
8. Write a `.bg-model-call-<task-id>` tracking file in the same directory as `--output` (content: task ID, model, template, timestamp). This avoids collisions between concurrent calls.
9. Poll the CP using `wait-for-tasks` (or the `dispatch-task --wait` pattern):
- Locate `wait-for-tasks` using the same PATH + fallback logic as `dispatch-task`.
- If not found, poll manually via `curl GET $CP_URL/tasks/<task-id>` in a sleep loop.
- Poll every `--poll-interval` seconds up to `--timeout` seconds total.
10. Once the task reaches a terminal state:
- **Succeeded:** Fetch logs via `GET $CP_URL/tasks/<task-id>/logs`. Write the log content to `--output`. Write `<output>.done` sentinel (content: `task_id=<id>` + newline + `completed_at=<timestamp>`). Remove the tracking file.
- **Failed / timed_out / cancelled:** Write `<output>.failed` sentinel (content: `task_id=<id>`, `state=<state>`, `error=<detail if available>`). Remove the tracking file. Exit 1.
11. Exit 0 on success.
## Output Files
| File | When written | Content |
|------|-------------|---------|
| `<output>` | On success | Raw text from `GET /tasks/<id>/logs` |
| `<output>.done` | On success | `task_id=<id>`, `completed_at=<timestamp>` |
| `<output>.failed` | On failure/timeout | `task_id=<id>`, `state=<state>`, `error=<detail>` |
| `.bg-model-call-<task-id>` | After submission | `task_id=<id>`, `model=<alias>`, `template=<name>`, `submitted_at=<timestamp>` |
The `.done` sentinel is the signal the caller polls for. The caller reads `<output>` once `.done` appears.
## Dryrun Behaviour
When `--dryrun` or `-n` is passed, the script:
1. Resolves all values (CP URL, template, task name, project ID).
2. Reads (and validates) the prompt file.
3. Prints a summary of what would be submitted — no CP calls, no file writes.
Example output:
```
[dryrun] bg-model-call — no changes will be made
CP URL: http://localhost:8100
Model: minimax
Template: minimax-planning
Task name: bg-model-call-minimax
Project ID: bg-model-call
Prompt file: /tmp/my-prompt.txt
Prompt size: 142 bytes
Output file: /tmp/result.txt
Sentinel: /tmp/result.txt.done
dispatch-task: /home/user/dev/claude/projects/agent-runtimes/scripts/dispatch-task
```
## Edge Cases
| Case | Handling |
|------|----------|
| `--model` missing and `--template` missing | Error: `--model is required (or use --template to specify a template directly)`, exit 1 |
| `--prompt` missing | Error: `--prompt is required`, exit 1 |
| `--output` missing | Error: `--output is required`, exit 1 |
| `--model` not in mapping table | Error: `Unknown model alias '<name>'. Valid values: minimax, sonnet, haiku, opus`, exit 1 |
| Prompt file not readable | Error: `Cannot read prompt file: <path>`, exit 1 |
| `--timeout` not a positive integer | Error: `--timeout must be a positive integer`, exit 1 |
| `--poll-interval` not a positive integer | Error: `--poll-interval must be a positive integer`, exit 1 |
| CP submission fails | Write `<output>.failed`, exit 1 |
| Task times out (wait exceeds `--timeout`) | Write `<output>.failed` with `state=timed_out`, exit 1 |
| Output directory does not exist | Error: `Output directory does not exist: <dir>`, exit 1 |
## Security
- **No credentials in the script.** CP authentication (if required) is handled by `dispatch-task` reading `CP_AUTH_TOKEN` from the environment. The `bg-model-call` script only passes `--cp-url` and never handles tokens directly.
- **Prompt content is passed via file** (`--prompt-file`), never as a command-line argument. This avoids the prompt appearing in `ps` output.
## Examples
```bash
# Submit to MiniMax and poll in the background; check for result from a wakeup
echo "Review this spec for security issues: $(cat spec/auth.md)" > /tmp/minimax-prompt.txt
bg-model-call \
--model minimax \
--prompt /tmp/minimax-prompt.txt \
--output /tmp/minimax-result.txt \
--project my-project &
# Later (from ScheduleWakeup), check for completion:
if [[ -f /tmp/minimax-result.txt.done ]]; then
cat /tmp/minimax-result.txt
fi
# Dryrun to inspect resolved values
bg-model-call \
--model sonnet \
--prompt /tmp/my-prompt.txt \
--output /tmp/result.txt \
--dryrun
# Use a specific template instead of the model mapping
bg-model-call \
--template minimax-planning \
--prompt /tmp/prompt.txt \
--output /tmp/result.txt \
--dryrun
```

View File

@@ -26,11 +26,20 @@ mm-worktree-task <item-uuid> [OPTIONS]
| `--docs all\|recent\|none` | `recent` | Which item documents to bundle. `recent` = documents updated in the last 14 days; falls back to `all` if the CLI does not expose update dates. The full list of document names is always included in the prompt regardless of which are bundled. | | `--docs all\|recent\|none` | `recent` | Which item documents to bundle. `recent` = documents updated in the last 14 days; falls back to `all` if the CLI does not expose update dates. The full list of document names is always included in the prompt regardless of which are bundled. |
| `--tools "<list>"` | — | Extra `--allowedTools` entries merged into the default allowlist (comma-separated). | | `--tools "<list>"` | — | Extra `--allowedTools` entries merged into the default allowlist (comma-separated). |
| `--timeout <secs>` | `1800` | Max seconds the sub-session may run (`timeout <secs>` wrapper). | | `--timeout <secs>` | `1800` | Max seconds the sub-session may run (`timeout <secs>` wrapper). |
| `--context <name>` | resolved (see §0) | `cp-cli --context` used for every planner call. |
| `--upload` | off | After the sub-session exits, upload its report as a document on the item and patch `flow_state` to `impl-complete`. Implemented only in combination with `--collect` (see Behaviour §6) since launch is detached. | | `--upload` | off | After the sub-session exits, upload its report as a document on the item and patch `flow_state` to `impl-complete`. Implemented only in combination with `--collect` (see Behaviour §6) since launch is detached. |
| `--collect` | off | Don't launch. Print `git log --oneline main..HEAD`, `git diff --stat main...HEAD`, and the report file for an already-run slug's worktree. | | `--collect` | off | Don't launch. Print `git log --oneline main..HEAD`, `git diff --stat main...HEAD`, and the report file for an already-run slug's worktree. |
| `--dryrun`, `-n` | off | Assemble everything, print the bundle manifest, the full prompt, and the exact command that would run. Create nothing: no worktree, no branch, no launch, no bundle/prompt files left outside the report dir's dryrun preview. | | `--dryrun`, `-n` | off | Assemble everything, print the bundle manifest, the full prompt, and the exact command that would run. Create nothing: no worktree, no branch, no launch, no bundle/prompt files left outside the report dir's dryrun preview. |
| `--help`, `-h` | — | Show usage and exit 0. | | `--help`, `-h` | — | Show usage and exit 0. |
### Environment
| Var | Effect |
|-----|--------|
| `MM_WORKTREE_TASK_CP_CLI_DIR=<dir>` | Checkout holding `lib/cp_cli.py` to run the planner CLI from. Default resolution: this repo if it has `lib/cp_cli.py`, else `~/dev/claude/projects/agent-runtimes`. |
| `MM_WORKTREE_TASK_CP_CONTEXT=<name>` | Same as `--context` (the flag wins). |
| `MM_WORKTREE_TASK_CPCONFIG=<file>` | cpconfig file used for context auto-resolution (default `~/.cpconfig`). |
### Environment overrides (for testing) ### Environment overrides (for testing)
| Var | Effect | | Var | Effect |
@@ -40,10 +49,14 @@ mm-worktree-task <item-uuid> [OPTIONS]
## Behaviour ## Behaviour
0. **Planner CLI resolution** — the script runs from *any* planner-backed repo; `cp_cli` lives in agent-runtimes.
- CLI dir: `MM_WORKTREE_TASK_CP_CLI_DIR`, else `<repo>/lib/cp_cli.py` (or the `lib/cp_cli/` package) if present, else `~/dev/claude/projects/agent-runtimes`. Only fails (exit 1, "cannot find lib/cp_cli.py") when a planner call is actually made — fake-item runs and `--brief`-only runs never need it.
- Context: `--context`, else `MM_WORKTREE_TASK_CP_CONTEXT`, else the context in `~/.cpconfig` (or `MM_WORKTREE_TASK_CPCONFIG`) whose `project_id` equals the repo directory name, else none (cp-cli's `current_context`).
- Every planner call is `cd <cli-dir> && python3 -m lib.cp_cli [--context <ctx>] …`. `--dryrun` prints both resolved values (`cp-cli from:`, `cp-cli ctx:`).
1. Determine repo root: `git rev-parse --show-toplevel` from cwd. Fail (exit 1) if not inside a git repository. 1. Determine repo root: `git rev-parse --show-toplevel` from cwd. Fail (exit 1) if not inside a git repository.
2. Resolve `<slug>`: `--slug` if given, else first 8 chars of `<item-uuid>` (or required alongside `--brief` when no uuid is given). 2. Resolve `<slug>`: `--slug` if given, else first 8 chars of `<item-uuid>` (or required alongside `--brief` when no uuid is given).
3. Resolve report dir: `${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/<repo-name>/`. Create it (unless `--dryrun`, which only prints the path). 3. Resolve report dir: `${XDG_RUNTIME_DIR:-/tmp}/mm-worktree-task/<repo-name>/`. Create it (unless `--dryrun`, which only prints the path).
4. **Fetch the item** (run from repo root, needs the repo's `lib`), unless `MM_WORKTREE_TASK_FAKE_ITEM` is set: 4. **Fetch the item** (through the §0 wrapper), unless `MM_WORKTREE_TASK_FAKE_ITEM` is set:
- `python3 -m lib.cp_cli planner items get <uuid> --json` → fields used: `id`, `title`, `label`, `state`, `flow_state`, `body`, `tags`, `parent_id`, `related_items`. - `python3 -m lib.cp_cli planner items get <uuid> --json` → fields used: `id`, `title`, `label`, `state`, `flow_state`, `body`, `tags`, `parent_id`, `related_items`.
- `python3 -m lib.cp_cli planner items documents list <uuid> --json` → document names (+ `updated_at` if present). - `python3 -m lib.cp_cli planner items documents list <uuid> --json` → document names (+ `updated_at` if present).
- For each document selected by `--docs`: `python3 -m lib.cp_cli planner items documents get <uuid> <name>` (fake dir equivalent: read `<dir>/<name>`). - For each document selected by `--docs`: `python3 -m lib.cp_cli planner items documents get <uuid> <name>` (fake dir equivalent: read `<dir>/<name>`).

View File

@@ -0,0 +1,125 @@
# wait-for-version
## Purpose
Poll a Gitea repository's Actions runs until the CI workflow for HEAD of the main branch completes successfully, then print the latest release tag. Solves the race condition where a dependency's main branch has been updated but CI has not yet produced a release.
## Usage
```
wait-for-version [OPTIONS] REPO
```
### Arguments
| Argument | Description |
|----------|-------------|
| `REPO` | Gitea repo in `owner/name` form (e.g. `skynet/hugo-accelerator`). |
### Flags
| Flag | Description |
|------|-------------|
| `-t`, `--token TOKEN` | Gitea access token. Fallback order: `$GITEA_TOKEN`, `$GITHUB_TOKEN`. |
| `--workflow NAME` | Name of the CI workflow to wait for (default: `version`). Matched against the `name` field of each workflow run. |
| `--timeout SECONDS` | Max total wait time (default: 300). `0` means poll once then give up. |
| `--interval SECONDS` | Polling interval (default: 15; min: 1). |
| `--server URL` | Gitea base URL. Fallback order: `$GITHUB_SERVER_URL`, `https://gitea.oreillyit.nz`. |
| `-v`, `--verbose` | Emit polling progress to stderr. |
| `-n`, `--dryrun` | Print resolved configuration and exit; make no API calls. |
| `-h`, `--help` | Show usage. |
## Behaviour
1. Validate inputs: REPO must be `owner/name`; `--timeout` is a non-negative integer; `--interval` is an integer ≥ 1.
2. Compute `API_BASE = <server>/api/v1/repos/<owner>/<name>`.
3. Compute `DEADLINE = now + timeout`.
4. Resolve HEAD SHA: `GET <API_BASE>/branches/main`. Extract `.commit.id`. On HTTP 401/403 exit 3; on HTTP 404 exit 3; on any other failure exit 3.
5. Emit `Waiting for '<workflow>' CI on <repo> @ <sha[0:8]>...` to stderr.
6. Loop:
1. `GET <API_BASE>/actions/runs?head_sha=<SHA>&limit=50`. Add `Authorization: token <TOKEN>` when a token is available.
2. From the JSON response, filter `.workflow_runs[]` where `.name == <workflow>`. Take the first (index 0) result.
3. If no matching run: treat as `pending`, continue polling.
4. If `status == "completed"` and `conclusion == "success"`: fetch `GET <API_BASE>/releases/latest`, extract `.tag_name`, print to stdout, exit 0.
5. If `status == "completed"` and `conclusion != "success"`: print error to stderr (include conclusion value), exit 1.
6. Otherwise (queued / in_progress / waiting / etc.): continue polling.
7. On HTTP 401 or 403: print auth error to stderr, exit 3.
8. On HTTP 404: print not-found error to stderr, exit 3.
9. On any other HTTP code or network error: keep retrying (log to stderr when `--verbose`).
7. Between polls, sleep for `--interval` seconds, capped so sleep never overshoots the deadline.
8. When the deadline passes without success, print timeout error to stderr, exit 1.
## Dryrun Behaviour
When `--dryrun` is passed, no HTTP requests are made. Output (to stdout):
```
[dryrun] Server: https://gitea.oreillyit.nz
[dryrun] Repo: skynet/hugo-accelerator
[dryrun] Workflow: version
[dryrun] Timeout: 300s
[dryrun] Interval: 15s
[dryrun] Auth: yes
[dryrun] Actions API: https://gitea.oreillyit.nz/api/v1/repos/skynet/hugo-accelerator/actions/runs
```
`Auth: yes` when a token was supplied or present in the environment; otherwise `Auth: no`.
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | CI succeeded; latest release tag printed to stdout. |
| 1 | Timeout elapsed, CI concluded with a non-success result, or no release tag found. |
| 2 | Usage error (missing / invalid arguments). |
| 3 | Terminal API error (401, 403, 404, or failure resolving HEAD SHA). |
## Edge Cases
| Case | Handling |
|------|----------|
| REPO missing the slash | Error, exit 2. |
| No matching workflow run found | Treated as pending; continue polling until timeout. |
| CI run fails (non-success conclusion) | Print error including conclusion value, exit 1. |
| Latest release has no `tag_name` | Error to stderr, exit 1. |
| No token for a private repo | API returns 401/403 → exit 3. |
| Invalid token | HTTP 401 → exit 3. |
| Repo doesn't exist | HTTP 404 → exit 3. |
| Transient 5xx or network error | Retry each `--interval` seconds until timeout. |
| `--timeout 0` | Resolve SHA, poll once, exit 1 if not yet complete. |
| `--interval 0` | Error, exit 2. |
## Examples
```bash
# Wait for the version CI workflow to complete on HEAD of main
wait-for-version skynet/hugo-accelerator
# Capture version and update a consumer project
VERSION=$(wait-for-version skynet/hugo-accelerator)
sed -i "s/hugo-accelerator: .*/hugo-accelerator: $VERSION/" config.yaml
# With explicit token for a private repo
wait-for-version -t "$MY_TOKEN" skynet/private-lib
# Longer timeout for slow CI (10 minutes)
wait-for-version --timeout 600 skynet/hugo-accelerator
# Verbose polling progress
wait-for-version -v skynet/hugo-accelerator
# Custom workflow name
wait-for-version --workflow release skynet/hugo-accelerator
# Dryrun preview
wait-for-version --dryrun skynet/hugo-accelerator
```
### In a Claude Code session
Invoke via the Bash tool with `run_in_background=true`. The session is notified when the script exits. On success stdout holds the new version tag.
```bash
VERSION=$(wait-for-version skynet/hugo-accelerator) || exit 1
echo "Dependency released as $VERSION"
```

268
tests/test-bg-model-call.sh Executable file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env bash
# test-bg-model-call.sh — Verify bg-model-call dryrun behaviour against the spec.
#
# Strategy: all assertions use --dryrun (-n), so no CP calls are made and
# no output files are written. Prompt files are created in a tempdir.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BG_MODEL_CALL="$REPO_ROOT/scripts/bg-model-call"
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
PASS=0
FAIL=0
assert_contains() {
local label=$1 needle=$2 haystack=$3
if [[ "$haystack" == *"$needle"* ]]; then
printf " ${GREEN}PASS${NC} %s\n" "$label"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s\n" "$label"
printf " expected to contain: %s\n" "$needle"
printf " actual output:\n"
printf '%s\n' "$haystack" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
local label=$1 needle=$2 haystack=$3
if [[ "$haystack" != *"$needle"* ]]; then
printf " ${GREEN}PASS${NC} %s\n" "$label"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s\n" "$label"
printf " expected NOT to contain: %s\n" "$needle"
FAIL=$((FAIL + 1))
fi
}
assert_exit_code() {
local label=$1 expected=$2 actual=$3
if [[ "$expected" == "$actual" ]]; then
printf " ${GREEN}PASS${NC} %s (exit=%s)\n" "$label" "$actual"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s (expected exit=%s, got exit=%s)\n" "$label" "$expected" "$actual"
FAIL=$((FAIL + 1))
fi
}
# Create a temp directory for prompt and output files
TMPDIR_TEST="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_TEST"' EXIT
PROMPT_FILE="${TMPDIR_TEST}/prompt.txt"
OUTPUT_FILE="${TMPDIR_TEST}/result.txt"
echo "This is a test prompt for the spec review." > "$PROMPT_FILE"
run() {
"$BG_MODEL_CALL" "$@" 2>&1
}
# === Pre-flight ===
if [[ ! -x "$BG_MODEL_CALL" ]]; then
echo "bg-model-call not found or not executable at $BG_MODEL_CALL" >&2
exit 1
fi
# === Test 1: --help ===
echo "Test 1: --help"
out=$(run --help)
rc=$?
assert_exit_code "--help exits 0" 0 "$rc"
assert_contains "--help shows script name" "bg-model-call" "$out"
assert_contains "--help shows --model" "--model" "$out"
assert_contains "--help shows --prompt" "--prompt" "$out"
assert_contains "--help shows --output" "--output" "$out"
assert_contains "--help shows --dryrun" "--dryrun" "$out"
assert_contains "--help shows --template" "--template" "$out"
# === Test 2: basic minimax dryrun ===
echo
echo "Test 2: minimax dryrun"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "minimax dryrun exits 0" 0 "$rc"
assert_contains "shows dryrun header" "[dryrun]" "$out"
assert_contains "shows model" "minimax" "$out"
assert_contains "shows resolved template" "minimax-planning" "$out"
assert_contains "shows CP URL default" "http://localhost:8100" "$out"
assert_contains "shows project ID default" "bg-model-call" "$out"
assert_contains "shows task name" "bg-model-call-minimax" "$out"
assert_contains "shows prompt file path" "$PROMPT_FILE" "$out"
assert_contains "shows output file path" "$OUTPUT_FILE" "$out"
assert_contains "shows sentinel path" "${OUTPUT_FILE}.done" "$out"
assert_contains "shows prompt size" "bytes" "$out"
assert_not_contains "no CP calls in dryrun" "Submitting task" "$out"
# === Test 3: sonnet dryrun — template mapping ===
echo
echo "Test 3: sonnet dryrun — template mapping"
out=$(run --dryrun --model sonnet --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "sonnet dryrun exits 0" 0 "$rc"
assert_contains "sonnet maps to sonnet-code-repo" "sonnet-code-repo" "$out"
assert_contains "sonnet task name correct" "bg-model-call-sonnet" "$out"
# === Test 4: haiku dryrun — template mapping ===
echo
echo "Test 4: haiku dryrun — template mapping"
out=$(run --dryrun --model haiku --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "haiku dryrun exits 0" 0 "$rc"
assert_contains "haiku maps to haiku-code-repo" "haiku-code-repo" "$out"
# === Test 5: opus dryrun — template mapping ===
echo
echo "Test 5: opus dryrun — template mapping"
out=$(run --dryrun --model opus --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "opus dryrun exits 0" 0 "$rc"
assert_contains "opus maps to opus-code-repo" "opus-code-repo" "$out"
# === Test 6: --template override ===
echo
echo "Test 6: --template override"
out=$(run --dryrun --template minimax-planning --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "--template override exits 0" 0 "$rc"
assert_contains "template override shown" "minimax-planning" "$out"
# No model line since only --template was given, not --model
assert_not_contains "no model line when using --template only" "Model:" "$out"
# === Test 7: custom --project and --task-name ===
echo
echo "Test 7: custom project and task name"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE" \
--project my-project --task-name review-task)
rc=$?
assert_exit_code "custom project/task exits 0" 0 "$rc"
assert_contains "custom project shown" "my-project" "$out"
assert_contains "custom task name shown" "review-task" "$out"
# === Test 8: custom --url ===
echo
echo "Test 8: custom --url"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE" \
--url "http://10.111.1.11:31800")
rc=$?
assert_exit_code "custom url exits 0" 0 "$rc"
assert_contains "custom URL shown" "http://10.111.1.11:31800" "$out"
# === Test 9: CP_URL env var ===
echo
echo "Test 9: CP_URL env var"
out=$(CP_URL="http://my-cp:9000" run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "CP_URL env var exits 0" 0 "$rc"
assert_contains "CP_URL env var used" "http://my-cp:9000" "$out"
# === Test 10: -n shorthand ===
echo
echo "Test 10: -n shorthand for dryrun"
out=$(run -n --model sonnet --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "-n shorthand exits 0" 0 "$rc"
assert_contains "-n shows dryrun header" "[dryrun]" "$out"
# === Test 11: error — missing --model and --template ===
echo
echo "Test 11: error — missing --model and --template"
out=$(run --dryrun --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "missing model exits 1" 1 "$rc"
assert_contains "missing model error message" "--model is required" "$out"
# === Test 12: error — missing --prompt ===
echo
echo "Test 12: error — missing --prompt"
out=$(run --dryrun --model minimax --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "missing prompt exits 1" 1 "$rc"
assert_contains "missing prompt error" "--prompt is required" "$out"
# === Test 13: error — missing --output ===
echo
echo "Test 13: error — missing --output"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE")
rc=$?
assert_exit_code "missing output exits 1" 1 "$rc"
assert_contains "missing output error" "--output is required" "$out"
# === Test 14: error — unknown model alias ===
echo
echo "Test 14: error — unknown model alias"
out=$(run --dryrun --model bogusmodel --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "unknown model exits 1" 1 "$rc"
assert_contains "unknown model error" "Unknown model alias" "$out"
assert_contains "unknown model error lists valid values" "minimax" "$out"
# === Test 15: error — prompt file not found ===
echo
echo "Test 15: error — prompt file not found"
out=$(run --dryrun --model minimax --prompt "/tmp/definitely-does-not-exist-99999.txt" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "missing prompt file exits 1" 1 "$rc"
assert_contains "missing prompt file error" "Cannot read prompt file" "$out"
# === Test 16: error — output directory does not exist ===
echo
echo "Test 16: error — output directory does not exist"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "/nonexistent/dir/result.txt")
rc=$?
assert_exit_code "missing output dir exits 1" 1 "$rc"
assert_contains "missing output dir error" "Output directory does not exist" "$out"
# === Test 17: error — invalid timeout ===
echo
echo "Test 17: error — invalid --timeout"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE" --timeout abc)
rc=$?
assert_exit_code "invalid timeout exits 1" 1 "$rc"
assert_contains "invalid timeout error" "--timeout must be a positive integer" "$out"
# === Test 18: error — invalid poll-interval ===
echo
echo "Test 18: error — invalid --poll-interval"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE" --poll-interval 0)
rc=$?
assert_exit_code "invalid poll-interval exits 1" 1 "$rc"
assert_contains "invalid poll-interval error" "--poll-interval must be a positive integer" "$out"
# === Test 19: custom timeout and poll-interval shown in dryrun ===
echo
echo "Test 19: custom timeout and poll-interval in dryrun"
out=$(run --dryrun --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE" \
--timeout 3600 --poll-interval 30)
rc=$?
assert_exit_code "custom timeout/poll exits 0" 0 "$rc"
assert_contains "custom timeout shown" "3600s" "$out"
assert_contains "custom poll shown" "30s" "$out"
# === Test 20: unknown flag ===
echo
echo "Test 20: unknown flag"
out=$(run --dryrun --bogus-flag --model minimax --prompt "$PROMPT_FILE" --output "$OUTPUT_FILE")
rc=$?
assert_exit_code "unknown flag exits 1" 1 "$rc"
assert_contains "unknown flag error" "Unknown flag" "$out"
# === Summary ===
echo
echo "================================"
TOTAL=$((PASS + FAIL))
if [[ "$FAIL" -eq 0 ]]; then
printf "${GREEN}All %d tests passed${NC}\n" "$TOTAL"
exit 0
else
printf "${RED}%d of %d tests failed${NC}\n" "$FAIL" "$TOTAL"
exit 1
fi

View File

@@ -116,6 +116,29 @@ assert_contains "focus line present" "Only touch CI files" "$DRY_FOCUS_OUT"
echo echo
echo "== mm-worktree-task: --collect with no worktree errors ==" echo "== mm-worktree-task: --collect with no worktree errors =="
# --- context resolution (spec Behaviour §0) ---
FAKE_CPCONFIG="$(mktemp)"
cat > "$FAKE_CPCONFIG" <<'YAML'
current_context: other
contexts:
- name: other
project_id: something-else
- name: reponame-ctx
project_id: REPO_NAME_PLACEHOLDER
YAML
sed -i "s/REPO_NAME_PLACEHOLDER/$(basename "$(git rev-parse --show-toplevel)")/" "$FAKE_CPCONFIG"
CTX_OUT="$(MM_WORKTREE_TASK_CPCONFIG="$FAKE_CPCONFIG" "$SCRIPT" "$TEST_UUID" --slug testslug --dryrun 2>&1)"
assert_contains "context auto-resolves from cpconfig by repo name" "cp-cli ctx: reponame-ctx" "$CTX_OUT"
CTX_FLAG_OUT="$(MM_WORKTREE_TASK_CPCONFIG="$FAKE_CPCONFIG" "$SCRIPT" "$TEST_UUID" --slug testslug --dryrun --context explicit 2>&1)"
assert_contains "--context overrides cpconfig resolution" "cp-cli ctx: explicit" "$CTX_FLAG_OUT"
CTX_ENV_OUT="$(MM_WORKTREE_TASK_CPCONFIG="$FAKE_CPCONFIG" MM_WORKTREE_TASK_CP_CONTEXT=fromenv "$SCRIPT" "$TEST_UUID" --slug testslug --dryrun 2>&1)"
assert_contains "MM_WORKTREE_TASK_CP_CONTEXT overrides cpconfig resolution" "cp-cli ctx: fromenv" "$CTX_ENV_OUT"
CTX_NONE_OUT="$(MM_WORKTREE_TASK_CPCONFIG=/nonexistent "$SCRIPT" "$TEST_UUID" --slug testslug --dryrun 2>&1)"
assert_contains "no matching context falls back to cp-cli default" "cp-cli ctx: <cp-cli default>" "$CTX_NONE_OUT"
CLI_DIR_OUT="$(MM_WORKTREE_TASK_CP_CLI_DIR=/nonexistent "$SCRIPT" "$TEST_UUID" --slug testslug --dryrun 2>&1)"
assert_contains "cp-cli dir resolution is reported" "cp-cli from:" "$CLI_DIR_OUT"
rm -f "$FAKE_CPCONFIG"
unset MM_WORKTREE_TASK_FAKE_ITEM unset MM_WORKTREE_TASK_FAKE_ITEM
unset MM_WORKTREE_TASK_FAKE_DOCS_DIR unset MM_WORKTREE_TASK_FAKE_DOCS_DIR
COLLECT_OUT="$("$SCRIPT" "$TEST_UUID" --slug does-not-exist-slug --collect 2>&1)" COLLECT_OUT="$("$SCRIPT" "$TEST_UUID" --slug does-not-exist-slug --collect 2>&1)"

310
tests/test-wait-for-version.sh Executable file
View File

@@ -0,0 +1,310 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WAIT="$SCRIPT_DIR/scripts/wait-for-version"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass=0
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected: $expected"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_match() {
local desc="$1" pattern="$2" actual="$3"
if [[ "$actual" =~ $pattern ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " pattern: $pattern"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_exit() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" -eq "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected exit: $expected"
echo " actual exit: $actual"
((fail++)) || true
fi
}
assert_between() {
local desc="$1" lo="$2" hi="$3" val="$4"
if (( val >= lo && val <= hi )); then
echo -e "${GREEN}PASS${NC}: $desc (${val} in [${lo},${hi}])"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc (${val} not in [${lo},${hi}])"
((fail++)) || true
fi
}
# --- test env setup ---
tmp_root="$(mktemp -d)"
trap 'rm -rf "$tmp_root"' EXIT
MOCK_DIR="$tmp_root/bin"
mkdir -p "$MOCK_DIR"
# URL-aware mock curl: routes to different fixture files based on URL path.
# Environment variables:
# MOCK_BRANCH_BODY / MOCK_BRANCH_CODE — for /branches/main
# MOCK_RUNS_BODY / MOCK_RUNS_CODE — for /actions/runs
# MOCK_RELEASE_BODY / MOCK_RELEASE_CODE — for /releases/latest
# MOCK_GLOBAL_CODE — override all codes (for auth/404 tests)
# MOCK_CURL_FAIL — make curl exit non-zero
cat > "$MOCK_DIR/curl" <<'MOCK'
#!/usr/bin/env bash
url=""
while [[ $# -gt 0 ]]; do
case "$1" in
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
[[ -n "${MOCK_CURL_FAIL:-}" ]] && exit 7
if [[ -n "${MOCK_GLOBAL_CODE:-}" ]]; then
printf '\n%s' "$MOCK_GLOBAL_CODE"
exit 0
fi
if [[ "$url" == */branches/main ]]; then
[[ -n "${MOCK_BRANCH_BODY:-}" && -f "${MOCK_BRANCH_BODY}" ]] && cat "$MOCK_BRANCH_BODY"
printf '\n%s' "${MOCK_BRANCH_CODE:-200}"
elif [[ "$url" == */actions/runs* ]]; then
[[ -n "${MOCK_RUNS_BODY:-}" && -f "${MOCK_RUNS_BODY}" ]] && cat "$MOCK_RUNS_BODY"
printf '\n%s' "${MOCK_RUNS_CODE:-200}"
elif [[ "$url" == */releases/latest ]]; then
[[ -n "${MOCK_RELEASE_BODY:-}" && -f "${MOCK_RELEASE_BODY}" ]] && cat "$MOCK_RELEASE_BODY"
printf '\n%s' "${MOCK_RELEASE_CODE:-200}"
fi
MOCK
chmod +x "$MOCK_DIR/curl"
run_with_mock() {
PATH="$MOCK_DIR:$PATH" "$WAIT" "$@"
}
# --- Fixtures ---
FIX_BRANCH="$tmp_root/branch.json"
cat > "$FIX_BRANCH" <<'JSON'
{"name":"main","commit":{"id":"abc1234567890def1234567890def1234567890de"}}
JSON
FIX_RUNS_SUCCESS="$tmp_root/runs_success.json"
cat > "$FIX_RUNS_SUCCESS" <<'JSON'
{"workflow_runs":[
{"id":42,"name":"version","status":"completed","conclusion":"success"},
{"id":41,"name":"test", "status":"completed","conclusion":"success"}
]}
JSON
FIX_RUNS_FAILED="$tmp_root/runs_failed.json"
cat > "$FIX_RUNS_FAILED" <<'JSON'
{"workflow_runs":[
{"id":42,"name":"version","status":"completed","conclusion":"failure"}
]}
JSON
FIX_RUNS_INPROGRESS="$tmp_root/runs_inprogress.json"
cat > "$FIX_RUNS_INPROGRESS" <<'JSON'
{"workflow_runs":[
{"id":42,"name":"version","status":"in_progress","conclusion":null}
]}
JSON
FIX_RUNS_EMPTY="$tmp_root/runs_empty.json"
cat > "$FIX_RUNS_EMPTY" <<'JSON'
{"workflow_runs":[]}
JSON
FIX_RUNS_WRONG_WORKFLOW="$tmp_root/runs_wrong.json"
cat > "$FIX_RUNS_WRONG_WORKFLOW" <<'JSON'
{"workflow_runs":[
{"id":42,"name":"test","status":"completed","conclusion":"success"}
]}
JSON
FIX_RELEASE="$tmp_root/release.json"
cat > "$FIX_RELEASE" <<'JSON'
{"id":10,"tag_name":"v1.2.3.45","name":"v1.2.3.45"}
JSON
echo "=== wait-for-version tests ==="
echo
# --- Help ---
out=$("$WAIT" --help 2>&1); rc=$?
assert_exit "help exits 0" 0 "$rc"
assert_match "help mentions REPO" "REPO" "$out"
assert_match "help lists exit codes" "Exit codes" "$out"
assert_match "help mentions --workflow" "\-\-workflow" "$out"
# --- Usage errors ---
out=$("$WAIT" 2>&1); rc=$?
assert_exit "no args exits 2" 2 "$rc"
assert_match "no args error" "REPO is required" "$out"
out=$("$WAIT" noslash 2>&1); rc=$?
assert_exit "REPO without slash exits 2" 2 "$rc"
assert_match "REPO slash error" "owner/name" "$out"
out=$("$WAIT" --timeout abc owner/repo 2>&1); rc=$?
assert_exit "non-numeric timeout exits 2" 2 "$rc"
out=$("$WAIT" --interval 0 owner/repo 2>&1); rc=$?
assert_exit "interval 0 exits 2" 2 "$rc"
out=$("$WAIT" --unknown-flag owner/repo 2>&1); rc=$?
assert_exit "unknown flag exits 2" 2 "$rc"
out=$("$WAIT" owner/repo extra 2>&1); rc=$?
assert_exit "extra positional arg exits 2" 2 "$rc"
# --- Dryrun ---
out=$(env -u GITEA_TOKEN -u GITHUB_TOKEN "$WAIT" --dryrun myorg/myrepo 2>&1); rc=$?
assert_exit "dryrun exits 0" 0 "$rc"
assert_match "dryrun shows server" "Server: " "$out"
assert_match "dryrun shows repo" "Repo: myorg/myrepo" "$out"
assert_match "dryrun shows workflow" "Workflow: version" "$out"
assert_match "dryrun shows timeout" "Timeout: 300s" "$out"
assert_match "dryrun shows interval" "Interval: 15s" "$out"
assert_match "dryrun shows Auth: no" "Auth: no" "$out"
assert_match "dryrun shows Actions API" "api/v1/repos/myorg/myrepo/actions/runs" "$out"
# Auth: yes via --token
out=$(env -u GITEA_TOKEN -u GITHUB_TOKEN "$WAIT" --dryrun -t tok123 myorg/myrepo 2>&1)
assert_match "--token -> Auth: yes" "Auth: yes" "$out"
# Auth: yes via GITEA_TOKEN env
out=$(env -u GITHUB_TOKEN GITEA_TOKEN=xyz "$WAIT" --dryrun myorg/myrepo 2>&1)
assert_match "GITEA_TOKEN -> Auth: yes" "Auth: yes" "$out"
# Custom --server appears in Actions API URL
out=$("$WAIT" --dryrun --server https://example.test myorg/myrepo 2>&1)
assert_match "custom --server in Actions API" "https://example\.test/api/v1/repos/myorg/myrepo/actions/runs" "$out"
# Custom --workflow reflected in dryrun
out=$("$WAIT" --dryrun --workflow release myorg/myrepo 2>&1)
assert_match "custom --workflow shown" "Workflow: release" "$out"
# --- Successful flow via mock ---
export MOCK_BRANCH_BODY="$FIX_BRANCH"
export MOCK_RUNS_BODY="$FIX_RUNS_SUCCESS"
export MOCK_RELEASE_BODY="$FIX_RELEASE"
unset MOCK_GLOBAL_CODE MOCK_CURL_FAIL MOCK_BRANCH_CODE MOCK_RUNS_CODE MOCK_RELEASE_CODE
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1)
rc=$?
# stdout is the last line (tag); stderr has the "Waiting..." message
tag=$(run_with_mock --timeout 5 --interval 1 test/repo 2>/dev/null)
assert_exit "success exits 0" 0 "$rc"
assert_eq "success prints tag" "v1.2.3.45" "$tag"
# Only the version workflow is selected, not the test workflow
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>/dev/null)
assert_eq "selects correct workflow by name" "v1.2.3.45" "$out"
# -- Verbose output to stderr ---
stderr_out=$(run_with_mock --verbose --timeout 5 --interval 1 test/repo 2>&1 >/dev/null)
assert_match "verbose mentions Waiting" "Waiting for" "$stderr_out"
# --- CI failure -> exit 1 ---
export MOCK_RUNS_BODY="$FIX_RUNS_FAILED"
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1); rc=$?
assert_exit "CI failure exits 1" 1 "$rc"
assert_match "CI failure message includes conclusion" "failure" "$out"
# --- In-progress -> timeout (no polling loop, just immediate timeout) ---
export MOCK_RUNS_BODY="$FIX_RUNS_INPROGRESS"
start_t=$(date +%s)
out=$(run_with_mock --timeout 2 --interval 1 test/repo 2>&1); rc=$?
end_t=$(date +%s)
elapsed=$(( end_t - start_t ))
assert_exit "in_progress times out -> exit 1" 1 "$rc"
assert_match "timeout error message" "timeout after 2s" "$out"
assert_between "elapsed within tolerance" 2 4 "$elapsed"
# --- No matching workflow in runs -> timeout ---
export MOCK_RUNS_BODY="$FIX_RUNS_WRONG_WORKFLOW"
out=$(run_with_mock --timeout 2 --interval 1 test/repo 2>&1); rc=$?
assert_exit "no matching workflow times out -> exit 1" 1 "$rc"
# --- Empty runs list -> timeout ---
export MOCK_RUNS_BODY="$FIX_RUNS_EMPTY"
out=$(run_with_mock --timeout 1 --interval 1 test/repo 2>&1); rc=$?
assert_exit "empty runs times out -> exit 1" 1 "$rc"
# --- Branch API auth errors -> exit 3 ---
export MOCK_GLOBAL_CODE=401
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1); rc=$?
assert_exit "401 on branch lookup exits 3" 3 "$rc"
assert_match "401 error message" "authentication failed" "$out"
export MOCK_GLOBAL_CODE=403
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1); rc=$?
assert_exit "403 exits 3" 3 "$rc"
export MOCK_GLOBAL_CODE=404
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1); rc=$?
assert_exit "404 exits 3" 3 "$rc"
assert_match "404 error message" "not found" "$out"
# --- Runs API 401 -> exit 3 ---
unset MOCK_GLOBAL_CODE
export MOCK_BRANCH_BODY="$FIX_BRANCH"
export MOCK_BRANCH_CODE=200
export MOCK_RUNS_CODE=401
out=$(run_with_mock --timeout 5 --interval 1 test/repo 2>&1); rc=$?
assert_exit "runs 401 exits 3" 3 "$rc"
# --- Transient 500 on runs -> retry until timeout ---
export MOCK_RUNS_CODE=500
out=$(run_with_mock --timeout 2 --interval 1 test/repo 2>&1); rc=$?
assert_exit "persistent 500 on runs exits 1 (timeout)" 1 "$rc"
# --- Network error -> retry until timeout ---
unset MOCK_RUNS_CODE
export MOCK_CURL_FAIL=1
out=$(run_with_mock --timeout 2 --interval 1 test/repo 2>&1); rc=$?
assert_exit "network error exits 3 (curl fail on branch)" 3 "$rc"
unset MOCK_CURL_FAIL
# --- With explicit token ---
unset MOCK_BRANCH_CODE MOCK_RUNS_CODE MOCK_GLOBAL_CODE
export MOCK_RUNS_BODY="$FIX_RUNS_SUCCESS"
export MOCK_RELEASE_BODY="$FIX_RELEASE"
out=$(run_with_mock --token secret123 --timeout 5 --interval 1 test/repo 2>/dev/null)
assert_eq "polling with --token still works" "v1.2.3.45" "$out"
# --- Summary ---
echo
total=$(( pass + fail ))
echo -e "Results: ${GREEN}${pass}${NC}/${total} passed"
if (( fail > 0 )); then
echo -e "${RED}${fail} test(s) failed${NC}"
exit 1
fi