chore: track ask-minimax-run, bg-model-call, wait-for-version, auto-draft (+spec/data) — were untracked
Claude-Session: https://claude.ai/code/session_015nXvGNNNixJPFwZKZHNyha
This commit is contained in:
3
data/auto-draft/example/dossier-a/01-empty.overview.md
Normal file
3
data/auto-draft/example/dossier-a/01-empty.overview.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 01 — The amplifier
|
||||
|
||||
A topic brief that has not yet been generated. Will appear in the generation queue.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 02 — Has generated
|
||||
|
||||
This is a generated draft for the topic. It has not yet been scored.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 02 — Has generated, no score
|
||||
|
||||
Source topic. The generated file exists but no score file yet. Will appear in the scoring queue.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 03 — All paragraphs pass
|
||||
|
||||
Generated draft.
|
||||
@@ -0,0 +1,5 @@
|
||||
This is a polished paragraph. <!-- score: 9 -->
|
||||
|
||||
This is also a polished paragraph. <!-- score: 8 -->
|
||||
|
||||
And this one is fine too. <!-- score: 9 -->
|
||||
3
data/auto-draft/example/dossier-a/03-clean.overview.md
Normal file
3
data/auto-draft/example/dossier-a/03-clean.overview.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 03 — All paragraphs pass
|
||||
|
||||
Source topic. The score file exists and every paragraph passes the polish score. Will not appear in any queue.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 04 — Needs polish
|
||||
|
||||
Generated draft that needs further work.
|
||||
@@ -0,0 +1,7 @@
|
||||
This is a polished paragraph. <!-- score: 9 -->
|
||||
|
||||
This is a middling paragraph that needs work. <!-- score: 5 -->
|
||||
|
||||
This is a polished paragraph. <!-- score: 8 -->
|
||||
|
||||
This is a poor paragraph that needs work. <!-- score: 4 -->
|
||||
@@ -0,0 +1,3 @@
|
||||
# 04 — Needs polish
|
||||
|
||||
Source topic. The score file has paragraphs below the polish score. Will appear in the polish queue with 2 below.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 05 — Exempt only
|
||||
|
||||
Generated draft.
|
||||
@@ -0,0 +1,3 @@
|
||||
This is a paragraph that is below the polish threshold but is already accepted. <!-- score: 5 --><!-- exempt: true -->
|
||||
|
||||
This is a paragraph that is below the polish threshold but is already accepted. <!-- score: 4 --><!-- exempt: true -->
|
||||
@@ -0,0 +1,3 @@
|
||||
# 05 — Below score but exempt
|
||||
|
||||
Source topic. The score file has only paragraphs that are below the polish score but marked exempt. Will not appear in any queue.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 06 — Cross folder
|
||||
|
||||
Generated draft.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 06 — Cross folder
|
||||
|
||||
Source topic in a different subfolder. Exercises recursive scan.
|
||||
29
scripts/ask-minimax-run
Executable file
29
scripts/ask-minimax-run
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Invoke claude with the oreillyit-minimax provider environment.
|
||||
# Replicates what claude-profile does in Phase 3 for non-interactive (-p) use.
|
||||
# Used by the /ask-minimax skill.
|
||||
set -euo pipefail
|
||||
|
||||
MINIMAX_DIR="${HOME}/.claude-oreillyit-minimax"
|
||||
PROVIDER_ENV="${MINIMAX_DIR}/provider.env"
|
||||
|
||||
[[ -f "$PROVIDER_ENV" ]] || { echo "error: $PROVIDER_ENV not found" >&2; exit 1; }
|
||||
|
||||
# Parse provider.env — handle ANTHROPIC_API_KEY_FILE specially; export the rest
|
||||
while IFS='=' read -r key value; do
|
||||
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
|
||||
if [[ "$key" == "ANTHROPIC_API_KEY_FILE" ]]; then
|
||||
expanded="${value/#\~/$HOME}"
|
||||
[[ -f "$expanded" ]] || { echo "error: API key file '$expanded' not found" >&2; exit 1; }
|
||||
ANTHROPIC_API_KEY=$(cat "$expanded")
|
||||
export ANTHROPIC_API_KEY
|
||||
else
|
||||
export "$key=$value"
|
||||
fi
|
||||
done < "$PROVIDER_ENV"
|
||||
|
||||
export CLAUDE_CONFIG_DIR="$MINIMAX_DIR"
|
||||
export CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1
|
||||
export COLORTERM=truecolor
|
||||
|
||||
exec claude "$@"
|
||||
309
scripts/auto-draft
Executable file
309
scripts/auto-draft
Executable file
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""auto-draft — schedule ai-pipeline work based on spare subscription capacity.
|
||||
|
||||
OUTLINE REVISION. This revision is dryrun-only: it probes the subscription, computes
|
||||
the spare capacity, and prints the three priority queues. Job execution is not yet
|
||||
implemented. See specs/auto-draft.spec.md for the full design.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# --- Constants --------------------------------------------------------------
|
||||
|
||||
VALID_PROVIDERS = ("anthropic", "minimax")
|
||||
WEEKLY_PERIOD_DAYS = 7
|
||||
WEEKLY_PERIOD_SECONDS = WEEKLY_PERIOD_DAYS * 86400
|
||||
|
||||
# Inline HTML-comment scoring convention (see spec).
|
||||
SCORE_RE = re.compile(r"<!--\s*score:\s*(\d+)\s*-->")
|
||||
EXEMPT_RE = re.compile(r"<!--\s*exempt:\s*true\s*-->")
|
||||
TITLE_RE = re.compile(r"^(\d+)-(.+)$")
|
||||
|
||||
# --- ANSI colour ------------------------------------------------------------
|
||||
|
||||
_use_colour = sys.stdout.isatty()
|
||||
_BOLD = "\033[1m" if _use_colour else ""
|
||||
_DIM = "\033[2m" if _use_colour else ""
|
||||
_GREEN = "\033[32m" if _use_colour else ""
|
||||
_YELLOW = "\033[33m" if _use_colour else ""
|
||||
_RED = "\033[31m" if _use_colour else ""
|
||||
_CYAN = "\033[36m" if _use_colour else ""
|
||||
_RESET = "\033[0m" if _use_colour else ""
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _fmt_duration(seconds: float) -> str:
|
||||
seconds = int(seconds)
|
||||
if seconds < 0:
|
||||
seconds = 0
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
if days > 0:
|
||||
return f"{days}d {hours}h"
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def _fmt_pct(pct: float) -> str:
|
||||
return f"{pct:.1f}%"
|
||||
|
||||
|
||||
def _fmt_pct_signed(pct: float) -> str:
|
||||
sign = "+" if pct >= 0 else ""
|
||||
return f"{sign}{pct:.1f}%"
|
||||
|
||||
|
||||
# --- Provider probe ---------------------------------------------------------
|
||||
|
||||
def probe_provider(provider: str) -> dict | None:
|
||||
"""Run agent-subscriptions --output json and return the matching provider entry."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["agent-subscriptions", "--output", "json"],
|
||||
capture_output=True, text=True, check=True, timeout=30,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f"{_YELLOW}Warning: agent-subscriptions probe failed: {e}{_RESET}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"{_YELLOW}Warning: agent-subscriptions returned invalid JSON: {e}{_RESET}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
for p in data.get("providers", []):
|
||||
if p.get("provider", "").lower() == provider.lower():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# --- Spare capacity ---------------------------------------------------------
|
||||
|
||||
def compute_spare(provider_record: dict | None, threshold_pct: float) -> dict | None:
|
||||
"""Compute the linear limit and spare capacity for the seven-day window."""
|
||||
if not provider_record or not provider_record.get("available"):
|
||||
return None
|
||||
seven_day = provider_record.get("windows", {}).get("seven_day", {})
|
||||
reset_at_str = seven_day.get("reset_at")
|
||||
usage_pct = seven_day.get("utilization_pct")
|
||||
if not reset_at_str or usage_pct is None:
|
||||
return None
|
||||
|
||||
reset_at = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00"))
|
||||
renewed_at = reset_at - timedelta(seconds=WEEKLY_PERIOD_SECONDS)
|
||||
now = _now()
|
||||
elapsed_seconds = max(0.0, (now - renewed_at).total_seconds())
|
||||
elapsed_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * 100.0
|
||||
limit_pct = (elapsed_seconds / WEEKLY_PERIOD_SECONDS) * threshold_pct
|
||||
spare_pct = limit_pct - usage_pct
|
||||
|
||||
return {
|
||||
"reset_at": reset_at,
|
||||
"renewed_at": renewed_at,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"elapsed_pct": elapsed_pct,
|
||||
"threshold_pct": threshold_pct,
|
||||
"limit_pct": limit_pct,
|
||||
"usage_pct": usage_pct,
|
||||
"spare_pct": spare_pct,
|
||||
}
|
||||
|
||||
|
||||
def print_spare_capacity(provider: str, spare: dict | None) -> None:
|
||||
if spare is None:
|
||||
print(f"{_YELLOW}[auto-draft] No spare capacity data for {provider} (provider unavailable or reset_at missing){_RESET}")
|
||||
return
|
||||
|
||||
print(f"{_BOLD}[auto-draft] Spare capacity for {provider}{_RESET}")
|
||||
print(f" Reset at: {spare['reset_at'].isoformat()}")
|
||||
print(f" Renewed at: {spare['renewed_at'].isoformat()}")
|
||||
print(f" Elapsed: {_fmt_duration(spare['elapsed_seconds'])} of {WEEKLY_PERIOD_DAYS}d ({spare['elapsed_pct']:.1f}%)")
|
||||
print(f" Threshold: {_fmt_pct(spare['threshold_pct'])}")
|
||||
print(f" Linear limit: {_fmt_pct(spare['limit_pct'])}")
|
||||
print(f" Current usage: {_fmt_pct(spare['usage_pct'])}")
|
||||
|
||||
spare_pct = spare["spare_pct"]
|
||||
if spare_pct >= 0:
|
||||
colour = _GREEN
|
||||
summary = "under budget"
|
||||
else:
|
||||
colour = _RED
|
||||
summary = "over budget; do not queue any work"
|
||||
print(f" Spare capacity: {colour}{_fmt_pct_signed(spare_pct)}{_RESET} ({summary})")
|
||||
print()
|
||||
|
||||
|
||||
# --- Title discovery --------------------------------------------------------
|
||||
|
||||
def discover_titles(base_dir: Path) -> list[tuple[int, str, str, Path]]:
|
||||
"""Return list of (title_id, title_slug, original_stem, parent_dir) sorted by title_id."""
|
||||
titles: list[tuple[int, str, str, Path]] = []
|
||||
seen: set[tuple[str, Path]] = set()
|
||||
for f in sorted(base_dir.rglob("*.overview.md")):
|
||||
stem = f.name.removesuffix(".overview.md")
|
||||
key = (stem, f.parent)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
m = TITLE_RE.match(stem)
|
||||
if m:
|
||||
title_id = int(m.group(1))
|
||||
title_slug = m.group(2)
|
||||
else:
|
||||
title_id = float("inf")
|
||||
title_slug = stem
|
||||
titles.append((title_id, title_slug, stem, f.parent))
|
||||
titles.sort(key=lambda x: (x[0], x[1]))
|
||||
return titles
|
||||
|
||||
|
||||
def _files_for(parent: Path, stem: str, provider: str) -> tuple[Path, Path]:
|
||||
score = parent / f"{stem}.ai_score.{provider}.md"
|
||||
generated = parent / f"{stem}.ai_generated.{provider}.md"
|
||||
return score, generated
|
||||
|
||||
|
||||
# --- Score parsing ----------------------------------------------------------
|
||||
|
||||
def parse_score_file(score_path: Path, polish_score: int) -> tuple[int, int]:
|
||||
"""Return (paragraphs_below, total_scored)."""
|
||||
try:
|
||||
content = score_path.read_text()
|
||||
except OSError:
|
||||
return 0, 0
|
||||
paragraphs = re.split(r"\n\s*\n", content)
|
||||
below = 0
|
||||
scored = 0
|
||||
for para in paragraphs:
|
||||
if not para.strip():
|
||||
continue
|
||||
score_m = SCORE_RE.search(para)
|
||||
if not score_m:
|
||||
continue
|
||||
scored += 1
|
||||
score = int(score_m.group(1))
|
||||
is_exempt = bool(EXEMPT_RE.search(para))
|
||||
if score < polish_score and not is_exempt:
|
||||
below += 1
|
||||
return below, scored
|
||||
|
||||
|
||||
# --- Queue building ---------------------------------------------------------
|
||||
|
||||
def build_queues(base_dir: Path, provider: str, polish_score: int):
|
||||
"""Return (polish_queue, scoring_queue, generation_queue). Each queue is a list of dicts."""
|
||||
polish_q: list[dict] = []
|
||||
scoring_q: list[dict] = []
|
||||
generation_q: list[dict] = []
|
||||
|
||||
for title_id, title_slug, stem, parent in discover_titles(base_dir):
|
||||
score_file, generated_file = _files_for(parent, stem, provider)
|
||||
if score_file.exists():
|
||||
below, total = parse_score_file(score_file, polish_score)
|
||||
if below > 0:
|
||||
polish_q.append({
|
||||
"title_id": title_id,
|
||||
"title_slug": title_slug,
|
||||
"stem": stem,
|
||||
"parent": parent,
|
||||
"below": below,
|
||||
"total_scored": total,
|
||||
})
|
||||
elif generated_file.exists():
|
||||
scoring_q.append({
|
||||
"title_id": title_id,
|
||||
"title_slug": title_slug,
|
||||
"stem": stem,
|
||||
"parent": parent,
|
||||
})
|
||||
else:
|
||||
generation_q.append({
|
||||
"title_id": title_id,
|
||||
"title_slug": title_slug,
|
||||
"stem": stem,
|
||||
"parent": parent,
|
||||
})
|
||||
|
||||
return polish_q, scoring_q, generation_q
|
||||
|
||||
|
||||
def _sort_queue(q: list[dict]) -> list[dict]:
|
||||
return sorted(q, key=lambda x: (x["title_id"], x["title_slug"]))
|
||||
|
||||
|
||||
def print_queue(label: str, kind: str, queue: list[dict]) -> None:
|
||||
queue = _sort_queue(queue)
|
||||
plural = "s" if len(queue) != 1 else ""
|
||||
print(f"{_BOLD}[auto-draft] {label} ({kind}) — {len(queue)} item{plural}{_RESET}")
|
||||
if not queue:
|
||||
print(f" {_DIM}(empty){_RESET}")
|
||||
else:
|
||||
for item in queue:
|
||||
stem = item["stem"]
|
||||
if kind == "polish":
|
||||
print(f" {stem} {item['below']} paragraph(s) below score (of {item['total_scored']} scored)")
|
||||
else:
|
||||
print(f" {stem}")
|
||||
print()
|
||||
|
||||
|
||||
# --- Main -------------------------------------------------------------------
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="auto-draft",
|
||||
description="Schedule ai-pipeline work based on spare subscription capacity (outline revision).",
|
||||
)
|
||||
p.add_argument("--provider", required=True, choices=VALID_PROVIDERS,
|
||||
help="Subscription provider to draw capacity from")
|
||||
p.add_argument("--threshold", required=True, type=float,
|
||||
help="Maximum subscription utilization over the period, as a percentage")
|
||||
p.add_argument("--polish-score", required=True, type=int,
|
||||
help="Paragraph score below which a paragraph is queued for polishing")
|
||||
p.add_argument("--base-dir", default=".", type=Path,
|
||||
help="Root directory to scan for dossier files (default: current directory)")
|
||||
p.add_argument("-n", "--dryrun", action="store_true", default=True,
|
||||
help="Show capacity and queues without executing (default: dryrun)")
|
||||
args = p.parse_args(argv)
|
||||
if not (0 < args.threshold <= 100):
|
||||
p.error("--threshold must be between 0 and 100 (exclusive of 0)")
|
||||
if not (0 <= args.polish_score <= 10):
|
||||
p.error("--polish-score must be between 0 and 10")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv if argv is not None else sys.argv[1:])
|
||||
base_dir = args.base_dir.expanduser().resolve()
|
||||
if not base_dir.is_dir():
|
||||
print(f"Error: --base-dir {base_dir} is not a directory", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
provider_record = probe_provider(args.provider)
|
||||
spare = compute_spare(provider_record, args.threshold)
|
||||
print_spare_capacity(args.provider, spare)
|
||||
|
||||
polish_q, scoring_q, generation_q = build_queues(base_dir, args.provider, args.polish_score)
|
||||
print_queue("Polish queue", "polish", polish_q)
|
||||
print_queue("Scoring queue", "scoring", scoring_q)
|
||||
print_queue("Generation queue", "generation", generation_q)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
435
scripts/bg-model-call
Executable file
435
scripts/bg-model-call
Executable file
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env bash
|
||||
# bg-model-call — Submit a prompt to a third-party model via the agent-runtimes
|
||||
# control plane, wait for completion, and write the result to a file.
|
||||
#
|
||||
# Designed for use with the ScheduleWakeup polling pattern: run this script in
|
||||
# the background; your wakeup handler checks for the <output>.done sentinel.
|
||||
#
|
||||
# See specs/bg-model-call.spec.md for the full spec.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model → template mapping (override with --template)
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -A MODEL_TEMPLATES=(
|
||||
[minimax]="minimax-planning"
|
||||
[sonnet]="sonnet-code-repo"
|
||||
[haiku]="haiku-code-repo"
|
||||
[opus]="opus-code-repo"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour output helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { printf "${CYAN}[bg-model-call]${NC} %s\n" "$*" >&2; }
|
||||
warn() { printf "${YELLOW}[bg-model-call] WARNING:${NC} %s\n" "$*" >&2; }
|
||||
error() { printf "${RED}[bg-model-call] ERROR:${NC} %s\n" "$*" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
bg-model-call — Submit a prompt to a model via the agent-runtimes control plane
|
||||
|
||||
Usage:
|
||||
bg-model-call --model <alias> --prompt <file> --output <file> [OPTIONS]
|
||||
|
||||
Required:
|
||||
--model <alias> Model alias: minimax, sonnet, haiku, opus
|
||||
--prompt <file> Path to file containing the prompt text
|
||||
--output <file> Path where the result will be written
|
||||
|
||||
Optional:
|
||||
--template <name> Override the task template (without .yaml)
|
||||
--project <id> Project ID for CP grouping (default: bg-model-call)
|
||||
--task-name <name> Task name shown in agent-monitor
|
||||
(default: bg-model-call-<model>)
|
||||
--url <cp-url> Control plane URL (default: $CP_URL or http://localhost:8100)
|
||||
--timeout <seconds> Max seconds to wait for task (default: 1800)
|
||||
--poll-interval <s> Seconds between polls (default: 10)
|
||||
--dryrun, -n Print what would be submitted without calling the CP
|
||||
--help, -h Show this help and exit
|
||||
|
||||
Model-to-template mapping:
|
||||
minimax → minimax-planning
|
||||
sonnet → sonnet-code-repo
|
||||
haiku → haiku-code-repo
|
||||
opus → opus-code-repo
|
||||
|
||||
The .done sentinel (<output>.done) is written on success — poll for this file
|
||||
from a ScheduleWakeup handler.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
ARG_MODEL=""
|
||||
ARG_TEMPLATE=""
|
||||
ARG_PROMPT=""
|
||||
ARG_OUTPUT=""
|
||||
ARG_PROJECT="bg-model-call"
|
||||
ARG_TASK_NAME=""
|
||||
ARG_URL=""
|
||||
ARG_TIMEOUT="1800"
|
||||
ARG_POLL_INTERVAL="10"
|
||||
DRYRUN=0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--model)
|
||||
ARG_MODEL="${2:?--model requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--template)
|
||||
ARG_TEMPLATE="${2:?--template requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--prompt)
|
||||
ARG_PROMPT="${2:?--prompt requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
ARG_OUTPUT="${2:?--output requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--project)
|
||||
ARG_PROJECT="${2:?--project requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--task-name)
|
||||
ARG_TASK_NAME="${2:?--task-name requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--url)
|
||||
ARG_URL="${2:?--url requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--timeout)
|
||||
ARG_TIMEOUT="${2:?--timeout requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--poll-interval)
|
||||
ARG_POLL_INTERVAL="${2:?--poll-interval requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--dryrun|-n)
|
||||
DRYRUN=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown flag: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate required args
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -z "$ARG_PROMPT" ]]; then
|
||||
error "--prompt is required"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$ARG_OUTPUT" ]]; then
|
||||
error "--output is required"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$ARG_MODEL" && -z "$ARG_TEMPLATE" ]]; then
|
||||
error "--model is required (or use --template to specify a template directly)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate numeric args
|
||||
if ! [[ "$ARG_TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then
|
||||
error "--timeout must be a positive integer, got: $ARG_TIMEOUT"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$ARG_POLL_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
||||
error "--poll-interval must be a positive integer, got: $ARG_POLL_INTERVAL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolve values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve CP URL: --url flag → $CP_URL → default
|
||||
if [[ -n "$ARG_URL" ]]; then
|
||||
CP_URL_RESOLVED="$ARG_URL"
|
||||
elif [[ -n "${CP_URL:-}" ]]; then
|
||||
CP_URL_RESOLVED="$CP_URL"
|
||||
else
|
||||
CP_URL_RESOLVED="http://localhost:8100"
|
||||
fi
|
||||
|
||||
# Resolve template
|
||||
if [[ -n "$ARG_TEMPLATE" ]]; then
|
||||
RESOLVED_TEMPLATE="$ARG_TEMPLATE"
|
||||
elif [[ -n "$ARG_MODEL" ]]; then
|
||||
if [[ -z "${MODEL_TEMPLATES[$ARG_MODEL]:-}" ]]; then
|
||||
error "Unknown model alias '$ARG_MODEL'. Valid values: ${!MODEL_TEMPLATES[*]}"
|
||||
exit 1
|
||||
fi
|
||||
RESOLVED_TEMPLATE="${MODEL_TEMPLATES[$ARG_MODEL]}"
|
||||
fi
|
||||
|
||||
# Resolve task name
|
||||
if [[ -n "$ARG_TASK_NAME" ]]; then
|
||||
RESOLVED_TASK_NAME="$ARG_TASK_NAME"
|
||||
elif [[ -n "$ARG_MODEL" ]]; then
|
||||
RESOLVED_TASK_NAME="bg-model-call-${ARG_MODEL}"
|
||||
else
|
||||
RESOLVED_TASK_NAME="bg-model-call-${RESOLVED_TEMPLATE}"
|
||||
fi
|
||||
|
||||
# Validate prompt file (must be readable even in dryrun)
|
||||
if [[ ! -f "$ARG_PROMPT" ]]; then
|
||||
error "Cannot read prompt file: $ARG_PROMPT"
|
||||
exit 1
|
||||
fi
|
||||
PROMPT_SIZE=$(wc -c < "$ARG_PROMPT")
|
||||
|
||||
# Validate output directory exists
|
||||
OUTPUT_DIR="$(dirname "$ARG_OUTPUT")"
|
||||
if [[ ! -d "$OUTPUT_DIR" ]]; then
|
||||
error "Output directory does not exist: $OUTPUT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate dispatch-task and wait-for-tasks CLIs
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AR_SCRIPTS="${SCRIPT_DIR}/../../../projects/agent-runtimes/scripts"
|
||||
|
||||
find_cli() {
|
||||
local name="$1"
|
||||
if command -v "$name" &>/dev/null; then
|
||||
command -v "$name"
|
||||
elif [[ -x "${AR_SCRIPTS}/${name}" ]]; then
|
||||
echo "${AR_SCRIPTS}/${name}"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
DISPATCH_TASK_BIN="$(find_cli dispatch-task)"
|
||||
WAIT_FOR_TASKS_BIN="$(find_cli wait-for-tasks)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dryrun: print resolved values and exit
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$DRYRUN" -eq 1 ]]; then
|
||||
echo "[dryrun] bg-model-call — no changes will be made"
|
||||
echo " CP URL: ${CP_URL_RESOLVED}"
|
||||
if [[ -n "$ARG_MODEL" ]]; then
|
||||
echo " Model: ${ARG_MODEL}"
|
||||
fi
|
||||
echo " Template: ${RESOLVED_TEMPLATE}"
|
||||
echo " Task name: ${RESOLVED_TASK_NAME}"
|
||||
echo " Project ID: ${ARG_PROJECT}"
|
||||
echo " Prompt file: ${ARG_PROMPT}"
|
||||
echo " Prompt size: ${PROMPT_SIZE} bytes"
|
||||
echo " Output file: ${ARG_OUTPUT}"
|
||||
echo " Sentinel: ${ARG_OUTPUT}.done"
|
||||
echo " Timeout: ${ARG_TIMEOUT}s"
|
||||
echo " Poll interval: ${ARG_POLL_INTERVAL}s"
|
||||
if [[ -n "$DISPATCH_TASK_BIN" ]]; then
|
||||
echo " dispatch-task: ${DISPATCH_TASK_BIN}"
|
||||
else
|
||||
echo " dispatch-task: not found (will use curl fallback)"
|
||||
fi
|
||||
if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then
|
||||
echo " wait-for-tasks: ${WAIT_FOR_TASKS_BIN}"
|
||||
else
|
||||
echo " wait-for-tasks: not found (will use curl polling fallback)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: write failure sentinel and exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
write_failure() {
|
||||
local task_id="${1:-unknown}" state="${2:-failed}" detail="${3:-}"
|
||||
{
|
||||
echo "task_id=${task_id}"
|
||||
echo "state=${state}"
|
||||
if [[ -n "$detail" ]]; then
|
||||
echo "error=${detail}"
|
||||
fi
|
||||
} > "${ARG_OUTPUT}.failed"
|
||||
|
||||
# Remove tracking file if it exists
|
||||
if [[ -n "${task_id}" && "${task_id}" != "unknown" ]]; then
|
||||
rm -f "${OUTPUT_DIR}/.bg-model-call-${task_id}"
|
||||
fi
|
||||
|
||||
error "Task ${task_id} ${state}. See ${ARG_OUTPUT}.failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit task
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Submitting task (model=${ARG_MODEL:-via-template}, template=${RESOLVED_TEMPLATE})"
|
||||
|
||||
submit_via_dispatch_task() {
|
||||
"$DISPATCH_TASK_BIN" \
|
||||
--name "$RESOLVED_TASK_NAME" \
|
||||
--project-id "$ARG_PROJECT" \
|
||||
--prompt-file "$ARG_PROMPT" \
|
||||
--template "$RESOLVED_TEMPLATE" \
|
||||
--cp-url "$CP_URL_RESOLVED" \
|
||||
--format json \
|
||||
--skip-tag-check 2>/dev/null
|
||||
}
|
||||
|
||||
submit_via_curl() {
|
||||
local prompt_text
|
||||
prompt_text="$(cat "$ARG_PROMPT")"
|
||||
local payload
|
||||
payload="$(printf '{"name":"%s","project_id":"%s","prompt":%s,"runtime":{"model":"%s","timeout":%s}}' \
|
||||
"$RESOLVED_TASK_NAME" \
|
||||
"$ARG_PROJECT" \
|
||||
"$(printf '%s' "$prompt_text" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
|
||||
"${ARG_MODEL:-${RESOLVED_TEMPLATE}}" \
|
||||
"$ARG_TIMEOUT")"
|
||||
curl -s -X POST "${CP_URL_RESOLVED}/tasks" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload"
|
||||
}
|
||||
|
||||
SUBMIT_RESPONSE=""
|
||||
if [[ -n "$DISPATCH_TASK_BIN" ]]; then
|
||||
SUBMIT_RESPONSE="$(submit_via_dispatch_task 2>&1)" || true
|
||||
else
|
||||
warn "dispatch-task not found; using curl fallback"
|
||||
SUBMIT_RESPONSE="$(submit_via_curl 2>&1)" || true
|
||||
fi
|
||||
|
||||
# Extract task_id from JSON response
|
||||
TASK_ID="$(printf '%s' "$SUBMIT_RESPONSE" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("task_id",""))' 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$TASK_ID" ]]; then
|
||||
error "Failed to submit task. Response: ${SUBMIT_RESPONSE}"
|
||||
write_failure "unknown" "failed" "submission failed: ${SUBMIT_RESPONSE}"
|
||||
fi
|
||||
|
||||
info "Task submitted: ${TASK_ID}"
|
||||
|
||||
# Write tracking file
|
||||
{
|
||||
echo "task_id=${TASK_ID}"
|
||||
echo "model=${ARG_MODEL:-}"
|
||||
echo "template=${RESOLVED_TEMPLATE}"
|
||||
echo "submitted_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
} > "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for task completion
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Waiting for task ${TASK_ID} (timeout=${ARG_TIMEOUT}s, poll=${ARG_POLL_INTERVAL}s)"
|
||||
|
||||
FINAL_STATE=""
|
||||
|
||||
wait_via_wait_for_tasks() {
|
||||
"$WAIT_FOR_TASKS_BIN" \
|
||||
--url "$CP_URL_RESOLVED" \
|
||||
--timeout "$ARG_TIMEOUT" \
|
||||
--interval "$ARG_POLL_INTERVAL" \
|
||||
--format text \
|
||||
"$TASK_ID" >/dev/null 2>&1
|
||||
# After wait-for-tasks exits, fetch the final state
|
||||
local state_json
|
||||
state_json="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}")"
|
||||
printf '%s' "$state_json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
wait_via_curl_poll() {
|
||||
local elapsed=0
|
||||
local state="pending"
|
||||
local terminal_states="succeeded failed timed_out cancelled"
|
||||
|
||||
while true; do
|
||||
local response
|
||||
response="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}" 2>/dev/null)" || true
|
||||
state="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","unknown"))' 2>/dev/null || echo "unknown")"
|
||||
|
||||
info " state=${state} (${elapsed}s elapsed)"
|
||||
|
||||
# Check if terminal
|
||||
for ts in $terminal_states; do
|
||||
if [[ "$state" == "$ts" ]]; then
|
||||
echo "$state"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Check timeout
|
||||
if [[ "$elapsed" -ge "$ARG_TIMEOUT" ]]; then
|
||||
echo "timed_out"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep "$ARG_POLL_INTERVAL"
|
||||
elapsed=$((elapsed + ARG_POLL_INTERVAL))
|
||||
done
|
||||
}
|
||||
|
||||
if [[ -n "$WAIT_FOR_TASKS_BIN" ]]; then
|
||||
FINAL_STATE="$(wait_via_wait_for_tasks)"
|
||||
else
|
||||
warn "wait-for-tasks not found; using curl polling fallback"
|
||||
FINAL_STATE="$(wait_via_curl_poll)"
|
||||
fi
|
||||
|
||||
info "Task ${TASK_ID} reached state: ${FINAL_STATE}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handle terminal state
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$FINAL_STATE" != "succeeded" ]]; then
|
||||
write_failure "$TASK_ID" "$FINAL_STATE" "task did not succeed"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch logs and write output
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Fetching task logs..."
|
||||
LOGS_RESPONSE="$(curl -s "${CP_URL_RESOLVED}/tasks/${TASK_ID}/logs" 2>/dev/null)" || true
|
||||
|
||||
if [[ -z "$LOGS_RESPONSE" ]]; then
|
||||
warn "Empty logs response from CP; output file will be empty"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$LOGS_RESPONSE" > "$ARG_OUTPUT"
|
||||
|
||||
# Write .done sentinel
|
||||
{
|
||||
echo "task_id=${TASK_ID}"
|
||||
echo "completed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
} > "${ARG_OUTPUT}.done"
|
||||
|
||||
# Remove tracking file
|
||||
rm -f "${OUTPUT_DIR}/.bg-model-call-${TASK_ID}"
|
||||
|
||||
info "Done. Result written to ${ARG_OUTPUT}"
|
||||
info "Sentinel written to ${ARG_OUTPUT}.done"
|
||||
exit 0
|
||||
209
scripts/wait-for-version
Executable file
209
scripts/wait-for-version
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
DEFAULT_SERVER="https://gitea.oreillyit.nz"
|
||||
DEFAULT_TIMEOUT=300
|
||||
DEFAULT_INTERVAL=15
|
||||
DEFAULT_WORKFLOW="version"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: wait-for-version [OPTIONS] REPO
|
||||
|
||||
Poll a Gitea repo's Actions runs until the CI workflow for HEAD of main
|
||||
completes successfully, then print the latest release tag.
|
||||
|
||||
Arguments:
|
||||
REPO owner/name (e.g. skynet/hugo-accelerator)
|
||||
|
||||
Options:
|
||||
-t, --token TOKEN Gitea access token (else $GITEA_TOKEN / $GITHUB_TOKEN)
|
||||
--workflow NAME Workflow name to wait for (default: version)
|
||||
--timeout SECONDS Max total wait time (default: 300; 0 = poll once)
|
||||
--interval SECONDS Polling interval (default: 15; min: 1)
|
||||
--server URL Gitea base URL (else $GITHUB_SERVER_URL, else
|
||||
https://gitea.oreillyit.nz)
|
||||
-v, --verbose Print polling progress to stderr
|
||||
-n, --dryrun Show resolved configuration without making API calls
|
||||
-h, --help Show this help
|
||||
|
||||
Exit codes:
|
||||
0 CI succeeded; latest release tag printed to stdout
|
||||
1 timeout expired, CI failed, or no release tag found
|
||||
2 usage error
|
||||
3 terminal API error (401, 403, 404)
|
||||
EOF
|
||||
}
|
||||
|
||||
timeout=$DEFAULT_TIMEOUT
|
||||
interval=$DEFAULT_INTERVAL
|
||||
server="${GITHUB_SERVER_URL:-$DEFAULT_SERVER}"
|
||||
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
workflow="$DEFAULT_WORKFLOW"
|
||||
verbose=false
|
||||
dryrun=false
|
||||
repo=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-n|--dryrun) dryrun=true; shift ;;
|
||||
-v|--verbose) verbose=true; shift ;;
|
||||
-t|--token)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --token requires a value" >&2; exit 2; }
|
||||
token="$2"; shift 2 ;;
|
||||
--workflow)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --workflow requires a value" >&2; exit 2; }
|
||||
workflow="$2"; shift 2 ;;
|
||||
--timeout)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --timeout requires a value" >&2; exit 2; }
|
||||
timeout="$2"; shift 2 ;;
|
||||
--interval)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --interval requires a value" >&2; exit 2; }
|
||||
interval="$2"; shift 2 ;;
|
||||
--server)
|
||||
[[ $# -lt 2 ]] && { echo "Error: --server requires a value" >&2; exit 2; }
|
||||
server="$2"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
-*) echo "Error: Unknown option: $1" >&2; exit 2 ;;
|
||||
*)
|
||||
if [[ -z "$repo" ]]; then repo="$1"
|
||||
else echo "Error: Unexpected argument: $1" >&2; exit 2
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$repo" ]] && { echo "Error: REPO is required" >&2; echo >&2; usage >&2; exit 2; }
|
||||
[[ "$repo" != */* ]] && { echo "Error: REPO must be in 'owner/name' form (got: $repo)" >&2; exit 2; }
|
||||
[[ ! "$timeout" =~ ^[0-9]+$ ]] && { echo "Error: --timeout must be a non-negative integer (got: $timeout)" >&2; exit 2; }
|
||||
[[ ! "$interval" =~ ^[0-9]+$ ]] && { echo "Error: --interval must be a positive integer (got: $interval)" >&2; exit 2; }
|
||||
(( interval < 1 )) && { echo "Error: --interval must be >= 1" >&2; exit 2; }
|
||||
|
||||
api_base="${server%/}/api/v1/repos/${repo}"
|
||||
|
||||
if $dryrun; then
|
||||
echo "[dryrun] Server: ${server}"
|
||||
echo "[dryrun] Repo: ${repo}"
|
||||
echo "[dryrun] Workflow: ${workflow}"
|
||||
echo "[dryrun] Timeout: ${timeout}s"
|
||||
echo "[dryrun] Interval: ${interval}s"
|
||||
if [[ -n "$token" ]]; then
|
||||
echo "[dryrun] Auth: yes"
|
||||
else
|
||||
echo "[dryrun] Auth: no"
|
||||
fi
|
||||
echo "[dryrun] Actions API: ${api_base}/actions/runs"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl_args=(-sS -m 30 -w $'\n%{http_code}')
|
||||
[[ -n "$token" ]] && curl_args+=(-H "Authorization: token ${token}")
|
||||
|
||||
sha_response="$(curl "${curl_args[@]}" "${api_base}/branches/main" 2>/dev/null)"
|
||||
sha_rc=$?
|
||||
sha_http="${sha_response##*$'\n'}"
|
||||
sha_body="${sha_response%$'\n'*}"
|
||||
|
||||
if (( sha_rc != 0 )); then
|
||||
echo "Error: curl failed resolving HEAD SHA for ${repo} (rc=${sha_rc})" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
case "$sha_http" in
|
||||
200)
|
||||
sha="$(printf '%s' "$sha_body" | jq -r '.commit.id // empty' 2>/dev/null)"
|
||||
[[ -z "$sha" ]] && { echo "Error: could not extract HEAD SHA from branch response" >&2; exit 3; }
|
||||
;;
|
||||
401|403) echo "Error: Gitea API authentication failed (HTTP ${sha_http}) for ${repo}" >&2; exit 3 ;;
|
||||
404) echo "Error: repo not found: ${repo} (HTTP 404)" >&2; exit 3 ;;
|
||||
*) echo "Error: unexpected HTTP ${sha_http} resolving HEAD SHA for ${repo}" >&2; exit 3 ;;
|
||||
esac
|
||||
|
||||
echo "Waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}..." >&2
|
||||
|
||||
start=$(date +%s)
|
||||
deadline=$(( start + timeout ))
|
||||
poll=0
|
||||
|
||||
while true; do
|
||||
poll=$(( poll + 1 ))
|
||||
|
||||
runs_response="$(curl "${curl_args[@]}" \
|
||||
"${api_base}/actions/runs?head_sha=${sha}&limit=50" 2>/dev/null)"
|
||||
runs_rc=$?
|
||||
|
||||
if (( runs_rc != 0 )); then
|
||||
$verbose && echo "wait-for-version: poll ${poll}: curl error (rc=${runs_rc})" >&2
|
||||
else
|
||||
http_code="${runs_response##*$'\n'}"
|
||||
body="${runs_response%$'\n'*}"
|
||||
|
||||
case "$http_code" in
|
||||
200)
|
||||
status="$(printf '%s' "$body" | jq -r \
|
||||
--arg w "$workflow" \
|
||||
'[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .status // "pending"' \
|
||||
2>/dev/null)"
|
||||
conclusion="$(printf '%s' "$body" | jq -r \
|
||||
--arg w "$workflow" \
|
||||
'[.workflow_runs[] | select(.path | test($w + "\\.ya?ml"))] | first | .conclusion // ""' \
|
||||
2>/dev/null)"
|
||||
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" ]]; then
|
||||
# Resolve tag by SHA match — avoids races with release creation
|
||||
# and works regardless of whether the repo uses Gitea releases.
|
||||
# When multiple tags share the SHA (e.g. v0.0.8 and v0.0.8.14),
|
||||
# prefer the 3-component semver root (fewest dot-components).
|
||||
tag_response="$(curl "${curl_args[@]}" "${api_base}/tags?limit=50" 2>/dev/null)"
|
||||
tag_http="${tag_response##*$'\n'}"
|
||||
tag_body="${tag_response%$'\n'*}"
|
||||
if [[ "$tag_http" != "200" ]]; then
|
||||
echo "Error: could not fetch tags for ${repo} (HTTP ${tag_http})" >&2
|
||||
exit 1
|
||||
fi
|
||||
tag="$(printf '%s' "$tag_body" | jq -r --arg sha "$sha" \
|
||||
'[.[] | select(.commit.sha == $sha)] |
|
||||
sort_by(.name | ltrimstr("v") | split(".") | length) |
|
||||
first | .name // empty' 2>/dev/null)"
|
||||
if [[ -n "$tag" ]]; then
|
||||
$verbose && echo "wait-for-version: poll ${poll}: success, tag=${tag}" >&2
|
||||
echo "$tag"
|
||||
exit 0
|
||||
else
|
||||
echo "Error: no tag found for ${repo} @ ${sha:0:8}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: CI failed for ${repo} @ ${sha:0:8} (conclusion: ${conclusion})" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
$verbose && echo "wait-for-version: poll ${poll}: status=${status:-pending}, retrying" >&2
|
||||
;;
|
||||
401|403)
|
||||
echo "Error: Gitea API authentication failed (HTTP ${http_code}) for ${repo}" >&2
|
||||
exit 3 ;;
|
||||
404)
|
||||
echo "Error: repo not found: ${repo} (HTTP 404)" >&2
|
||||
exit 3 ;;
|
||||
*)
|
||||
$verbose && echo "wait-for-version: poll ${poll}: HTTP ${http_code}, retrying" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
now=$(date +%s)
|
||||
if (( now >= deadline )); then
|
||||
break
|
||||
fi
|
||||
remaining=$(( deadline - now ))
|
||||
sleep_time=$interval
|
||||
(( sleep_time > remaining )) && sleep_time=$remaining
|
||||
$verbose && echo "wait-for-version: poll ${poll}: sleeping ${sleep_time}s (${remaining}s remaining)" >&2
|
||||
sleep "$sleep_time"
|
||||
done
|
||||
|
||||
echo "Error: timeout after ${timeout}s waiting for '${workflow}' CI on ${repo} @ ${sha:0:8}" >&2
|
||||
exit 1
|
||||
122
specs/auto-draft.spec.md
Normal file
122
specs/auto-draft.spec.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# auto-draft
|
||||
|
||||
## Purpose
|
||||
|
||||
Scan the writing dossiers for AI-pipeline work and schedule jobs in priority order, using only the "spare" capacity of the current subscription window. Designed to be run from a cron job (hourly) so that it picks up work as spare capacity appears.
|
||||
|
||||
The script is **dryrun-only** in this revision: it shows the spare capacity and the three priority queues, but does not execute any jobs. Job execution is a later stage.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
auto-draft --provider <provider> --threshold <pct> --polish-score <0-10> [OPTIONS]
|
||||
|
||||
Required:
|
||||
--provider PROVIDER Subscription provider to draw capacity from: anthropic or minimax
|
||||
--threshold PERCENT Maximum subscription utilisation allowed over the period, as a
|
||||
percentage (e.g. 50 = use up to 50% of the weekly allowance).
|
||||
The script computes the linear limit at the current point in the
|
||||
period and reports the spare capacity.
|
||||
--polish-score N Paragraph score below which a paragraph is queued for polishing
|
||||
(e.g. 7 means "polish any paragraph with score < 7").
|
||||
|
||||
Options:
|
||||
--base-dir PATH Root directory to scan for dossier files. Defaults to ".".
|
||||
Recurses into subdirectories.
|
||||
-n, --dryrun Show capacity and queues without executing. This is the default
|
||||
behaviour for this revision; the flag is explicit so the cron
|
||||
command line is self-documenting.
|
||||
-h, --help Show this help message and exit.
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
1. Probe the live subscription for the given provider. The script shells out to `agent-subscriptions --output json` and reads the relevant provider's `seven_day` window.
|
||||
2. Compute the spare capacity against the supplied threshold:
|
||||
- `last_renewed = reset_at - 7 days` (the weekly window is treated as a rolling 7-day period)
|
||||
- `period_seconds = 7 * 86400`
|
||||
- `elapsed_seconds = max(0, now - last_renewed)`
|
||||
- `limit_pct = elapsed_seconds / period_seconds * threshold` (the linear allowance at this point in the period)
|
||||
- `spare_pct = limit_pct - utilization_pct` (positive = under budget, negative = over budget)
|
||||
3. Discover titles by scanning for `*.overview.md` source files under `--base-dir` (recursively). Each title is the stem of the source file (e.g. `01-ai-is-an-amplifier`); the numeric ID is the leading digits before the first `-`.
|
||||
4. Build three priority queues, in priority order:
|
||||
- **Polish queue (highest priority)** — for every title whose `*.ai_score.<provider>.md` exists, parse the file and find paragraphs with `score < polish_score` and not marked exempt. If any such paragraph exists, the whole title is queued for polish.
|
||||
- **Scoring queue (second priority)** — for every title whose `*.ai_generated.<provider>.md` exists but whose `*.ai_score.<provider>.md` does not. The job is to score the generated file.
|
||||
- **Generation queue (lowest priority)** — for every title whose `*.ai_generated.<provider>.md` does not exist. The job is to generate the draft from the source brief.
|
||||
5. Sort each queue by numeric ID ascending (titles without a numeric ID sort last).
|
||||
6. Print the spare capacity and the three sorted queues.
|
||||
|
||||
## Score file format
|
||||
|
||||
The `*.ai_score.<provider>.md` files embed per-paragraph scores and an exempt marker as inline HTML comments after each paragraph:
|
||||
|
||||
```markdown
|
||||
This is a great paragraph with polish. <!-- score: 9 -->
|
||||
|
||||
This is a middling paragraph. <!-- score: 6 -->
|
||||
|
||||
This is already reviewed and acceptable. <!-- score: 5 --><!-- exempt: true -->
|
||||
```
|
||||
|
||||
Regex patterns:
|
||||
- Score: `<!--\s*score:\s*(\d+)\s*-->`
|
||||
- Exempt: `<!--\s*exempt:\s*true\s*-->`
|
||||
|
||||
A paragraph is queued for polish if it has a score comment with a value below `polish-score` AND does not have an exempt comment. Paragraphs without a score comment are not eligible for polishing (they are skipped), even if the file contains other scored paragraphs.
|
||||
|
||||
## Spare capacity output
|
||||
|
||||
```
|
||||
[auto-draft] Spare capacity for Anthropic
|
||||
Reset at: 2026-08-07T23:00:00+00:00
|
||||
Renewed at: 2026-07-31T23:00:00+00:00
|
||||
Elapsed: 16h 18m of 7d (9.7%)
|
||||
Threshold: 50.0%
|
||||
Linear limit: 4.9%
|
||||
Current usage: 41.2%
|
||||
Spare capacity: -36.4% (over budget; do not queue any work)
|
||||
```
|
||||
|
||||
For a healthy budget:
|
||||
```
|
||||
Spare capacity: 23.5% (under budget)
|
||||
```
|
||||
|
||||
## Queue output
|
||||
|
||||
```
|
||||
[auto-draft] Polish queue (priority 1) — 3 items
|
||||
01-ai-is-an-amplifier 2 paragraphs below 7 (of 4 scored)
|
||||
02-five-layers-of-an-agent-harness 1 paragraph below 7
|
||||
05-the-compound-engineering-loop 3 paragraphs below 7
|
||||
|
||||
[auto-draft] Scoring queue (priority 2) — 1 item
|
||||
03-shadow-agents-are-the-new-shadow-it
|
||||
|
||||
[auto-draft] Generation queue (priority 3) — 8 items
|
||||
04-risk-proportionate-delivery
|
||||
06-backpressure-is-coming-for-your-ci
|
||||
07-tests-are-law-llms-are-not-the-judge
|
||||
...
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- If the provider probe fails (`available: false`), print a warning and skip the capacity section. Still build and print the queues.
|
||||
- If the seven-day `reset_at` is missing, skip the capacity section (cannot compute `last_renewed`). Still build and print the queues.
|
||||
- If a title has no numeric ID prefix, sort it last in every queue.
|
||||
- If the base directory contains no source files, print all three queues as empty.
|
||||
- If a source file's directory also contains the `ai_score` and `ai_generated` files, the title is in the polish queue (or none, if all paragraphs pass). Otherwise scoring or generation.
|
||||
- Exit 0 unless the CLI args are invalid. The script is informational; one bad provider probe should not fail the cron.
|
||||
|
||||
## Dryrun behaviour
|
||||
|
||||
This revision is dryrun-only. The script always prints the capacity and queues. Job execution is out of scope until the design is approved.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
auto-draft --provider anthropic --threshold 50 --polish-score 7
|
||||
auto-draft --provider anthropic --threshold 50 --polish-score 7 --base-dir /home/paul/dev/claude/writing/oreillyconsulting
|
||||
auto-draft --provider minimax --threshold 70 --polish-score 8 --dryrun
|
||||
```
|
||||
Reference in New Issue
Block a user