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:
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"last_run": "2026-03-24T10:36:46Z",
|
||||
"last_run": "2026-05-11T09:48:44Z",
|
||||
"processed": {
|
||||
"log/2026-03-17.095436.md": "253f75f7ae43fb2547cd3377afb8d3b2",
|
||||
"log/2026-03-17.105314.md": "9c81013e9ab545527a363f525eff93e3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
scripts/__pycache__/idle-draftcpython-312.pyc
Normal file
BIN
scripts/__pycache__/idle-draftcpython-312.pyc
Normal file
Binary file not shown.
@@ -31,11 +31,21 @@ Options:
|
||||
--docs all|recent|none Which item documents to bundle (default: recent)
|
||||
--tools "<list>" Extra --allowedTools entries (comma-separated)
|
||||
--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
|
||||
--collect Don't launch; print status for an already-run slug
|
||||
--dryrun, -n Assemble everything, print it, create nothing
|
||||
--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):
|
||||
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>
|
||||
@@ -52,6 +62,7 @@ TIMEOUT=1800
|
||||
UPLOAD=0
|
||||
COLLECT=0
|
||||
DRYRUN=0
|
||||
CP_CONTEXT="${MM_WORKTREE_TASK_CP_CONTEXT:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -61,6 +72,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--docs) DOCS="$2"; shift 2 ;;
|
||||
--tools) EXTRA_TOOLS="$2"; shift 2 ;;
|
||||
--timeout) TIMEOUT="$2"; shift 2 ;;
|
||||
--context) CP_CONTEXT="$2"; shift 2 ;;
|
||||
--upload) UPLOAD=1; shift ;;
|
||||
--collect) COLLECT=1; shift ;;
|
||||
--dryrun|-n) DRYRUN=1; shift ;;
|
||||
@@ -97,6 +109,50 @@ if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
fi
|
||||
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}"
|
||||
WORKTREE="${REPO_ROOT}/.claude/worktrees/mm-${SLUG}"
|
||||
BRANCH="mm/${SLUG}"
|
||||
@@ -140,9 +196,9 @@ if [[ $COLLECT -eq 1 ]]; then
|
||||
DATE="$(date +%Y-%m-%d)"
|
||||
DOC_NAME="mm-${SLUG}-${DATE}.md"
|
||||
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" )
|
||||
( cd "$REPO_ROOT" && python3 -m lib.cp_cli planner items patch "$UUID" --flow-state impl-complete \
|
||||
--description "mm-worktree-task: ${SLUG} committed on mm/${SLUG}" )
|
||||
cp_cli planner items documents put "$UUID" "$DOC_NAME" --file "$RESULT_FILE"
|
||||
cp_cli planner items patch "$UUID" --flow-state impl-complete \
|
||||
--description "mm-worktree-task: ${SLUG} committed on mm/${SLUG}"
|
||||
ok "uploaded and marked impl-complete"
|
||||
fi
|
||||
exit 0
|
||||
@@ -160,7 +216,7 @@ fetch_item() {
|
||||
if [[ -n "${MM_WORKTREE_TASK_FAKE_ITEM:-}" ]]; then
|
||||
ITEM_JSON="$(cat "$MM_WORKTREE_TASK_FAKE_ITEM")"
|
||||
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
|
||||
TITLE="$(echo "$ITEM_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("title",""))')"
|
||||
fi
|
||||
@@ -173,7 +229,7 @@ fetch_docs() {
|
||||
[[ -f "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json" ]] || return
|
||||
list_json="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/list.json")"
|
||||
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
|
||||
return
|
||||
fi
|
||||
@@ -201,7 +257,7 @@ fetch_docs() {
|
||||
if [[ -n "${MM_WORKTREE_TASK_FAKE_DOCS_DIR:-}" ]]; then
|
||||
DOC_BODY["$name"]="$(cat "${MM_WORKTREE_TASK_FAKE_DOCS_DIR}/${name}" 2>/dev/null || echo "")"
|
||||
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
|
||||
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 "")')"
|
||||
fi
|
||||
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
|
||||
|
||||
# --- build bundle content ---
|
||||
@@ -256,6 +312,7 @@ build_bundle() {
|
||||
fi
|
||||
echo
|
||||
for n in "${DOC_BUNDLED[@]:-}"; do
|
||||
[[ -z "$n" ]] && continue
|
||||
echo "## Document: ${n}"
|
||||
echo '```'
|
||||
echo "${DOC_BODY[$n]:-}"
|
||||
@@ -308,6 +365,8 @@ CMD="cd ${WORKTREE} && setsid nohup timeout ${TIMEOUT} ask-minimax-run --model \
|
||||
if [[ $DRYRUN -eq 1 ]]; then
|
||||
echo "[dryrun] mm-worktree-task — no changes will be made"
|
||||
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 " UUID: ${UUID:-(none, --brief only)}"
|
||||
echo " Worktree: ${WORKTREE}"
|
||||
|
||||
158
specs/bg-model-call.spec.md
Normal file
158
specs/bg-model-call.spec.md
Normal 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
|
||||
```
|
||||
@@ -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. |
|
||||
| `--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). |
|
||||
| `--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. |
|
||||
| `--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. |
|
||||
| `--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)
|
||||
|
||||
| Var | Effect |
|
||||
@@ -40,10 +49,14 @@ mm-worktree-task <item-uuid> [OPTIONS]
|
||||
|
||||
## 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.
|
||||
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).
|
||||
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 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>`).
|
||||
|
||||
125
specs/wait-for-version.spec.md
Normal file
125
specs/wait-for-version.spec.md
Normal 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
268
tests/test-bg-model-call.sh
Executable 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
|
||||
@@ -116,6 +116,29 @@ assert_contains "focus line present" "Only touch CI files" "$DRY_FOCUS_OUT"
|
||||
|
||||
echo
|
||||
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_DOCS_DIR
|
||||
COLLECT_OUT="$("$SCRIPT" "$TEST_UUID" --slug does-not-exist-slug --collect 2>&1)"
|
||||
|
||||
310
tests/test-wait-for-version.sh
Executable file
310
tests/test-wait-for-version.sh
Executable 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
|
||||
Reference in New Issue
Block a user