feat: project-orchestrator — engine@v0.1.0 submodule, PO config, fleet.toml registry, mgmt scripts, docs, Nix
The PO is itself a project using the agent-orchestrator harness (engine/ submodule pinned at v0.1.0). Adds: agents.toml (one persistent fleet-management agent) + prompts/; fleet.toml (the sole project<->harness<->ref registry) + docs/fleet-registry.md; scripts/ (fleet.py + create/start/stop/update-project.sh); docs/manage-projects.md + docs/bootstrap.md; flake.nix/.lock devShell (python311+tmux+git); README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
19
scripts/_resolve.sh
Executable file
19
scripts/_resolve.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# _resolve.sh — shared helper: given a fleet project name, echo "<location>\t<config>\t<harness>".
|
||||
# Sourced by start/stop/update scripts. Reads the PO's fleet.toml (the only project↔location record).
|
||||
set -euo pipefail
|
||||
PO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
resolve_project() {
|
||||
local name="$1"
|
||||
python3 - "$PO_ROOT/fleet.toml" "$name" <<'PY'
|
||||
import sys, tomllib
|
||||
path, name = sys.argv[1], sys.argv[2]
|
||||
with open(path, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
for p in raw.get("project", []):
|
||||
if p.get("name") == name:
|
||||
print("\t".join([p.get("location", ""), p.get("config", "agents.toml"), p.get("harness", "")]))
|
||||
sys.exit(0)
|
||||
sys.exit(f"_resolve: no project named {name!r} in {path}")
|
||||
PY
|
||||
}
|
||||
117
scripts/create-project.sh
Executable file
117
scripts/create-project.sh
Executable file
@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-project.sh — scaffold a NEW project that uses a harness.
|
||||
#
|
||||
# Produces a self-contained project repo: the chosen harness vendored as the `engine/` submodule at
|
||||
# a pinned ref, plus a harness config scaffolded by the harness's own `init`. The project contains
|
||||
# NO project-orchestrator / fleet metadata — knowledge is one-directional (PO → project). Registering
|
||||
# the project in the PO's fleet.toml is a SEPARATE, PO-side step (use --register, or edit fleet.toml
|
||||
# by hand); nothing about the fleet ever lands inside the project repo.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/create-project.sh <name> [options]
|
||||
#
|
||||
# Options:
|
||||
# --dir <parent> parent directory to create the project under (default: ./projects)
|
||||
# --engine-url <url> harness repo to vendor as engine/ (default: the agent-orchestrator repo)
|
||||
# --ref <ref> harness ref to pin the submodule at (default: v0.1.0)
|
||||
# --prefix <prefix> session_prefix to write into the project's config (default: <name>-)
|
||||
# --register also append a [[project]] entry to this PO's fleet.toml (PO-side only)
|
||||
# --no-commit leave the project tree uncommitted (default: make an initial commit)
|
||||
#
|
||||
# Drive it by hand afterwards, exactly like any project:
|
||||
# cd <parent>/<name> && python3 engine/agents.py status
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "create-project: $*" >&2; exit 1; }
|
||||
|
||||
[ $# -ge 1 ] || die "usage: create-project.sh <name> [--dir P] [--engine-url U] [--ref R] [--prefix X] [--register] [--no-commit]"
|
||||
NAME="$1"; shift
|
||||
[[ "$NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "name must be kebab-case (got: $NAME)"
|
||||
|
||||
PO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PARENT="$PO_ROOT/projects"
|
||||
ENGINE_URL="https://git.autonomic.zone/recipe-maintainers/agent-orchestrator.git"
|
||||
REF="v0.1.0"
|
||||
PREFIX=""
|
||||
REGISTER=0
|
||||
COMMIT=1
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) PARENT="$2"; shift 2;;
|
||||
--engine-url) ENGINE_URL="$2"; shift 2;;
|
||||
--ref) REF="$2"; shift 2;;
|
||||
--prefix) PREFIX="$2"; shift 2;;
|
||||
--register) REGISTER=1; shift;;
|
||||
--no-commit) COMMIT=0; shift;;
|
||||
*) die "unknown option: $1";;
|
||||
esac
|
||||
done
|
||||
PREFIX="${PREFIX:-${NAME}-}"
|
||||
|
||||
command -v git >/dev/null || die "git not on PATH"
|
||||
command -v python3 >/dev/null || die "python3 not on PATH"
|
||||
|
||||
DEST="$PARENT/$NAME"
|
||||
[ -e "$DEST" ] && die "$DEST already exists — refusing to overwrite"
|
||||
mkdir -p "$PARENT"
|
||||
|
||||
echo "create-project: scaffolding '$NAME' at $DEST (engine $ENGINE_URL @ $REF)"
|
||||
git init -q -b main "$DEST"
|
||||
cd "$DEST"
|
||||
|
||||
# 1) vendor the harness as a pinned submodule under engine/
|
||||
git -c protocol.version=2 submodule add -q "$ENGINE_URL" engine
|
||||
( cd engine && git fetch -q --tags origin && git checkout -q "$REF" )
|
||||
git add .gitmodules engine
|
||||
|
||||
# 2) scaffold the harness config + prompts via the harness's OWN init (no PO/fleet metadata)
|
||||
python3 engine/agents.py init . >/dev/null
|
||||
# stamp the chosen session_prefix into the scaffolded config (keeps namespaces unique per project)
|
||||
python3 - "$PREFIX" <<'PY'
|
||||
import re, sys, pathlib
|
||||
prefix = sys.argv[1]
|
||||
p = pathlib.Path("agents.toml")
|
||||
txt = p.read_text()
|
||||
txt = re.sub(r'session_prefix\s*=\s*"[^"]*"', f'session_prefix = "{prefix}"', txt, count=1)
|
||||
p.write_text(txt)
|
||||
PY
|
||||
|
||||
# 3) ignore runtime state; the project knows nothing about any PO
|
||||
cat > .gitignore <<'EOF'
|
||||
# runtime state + logs (never committed)
|
||||
.ao-state/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.pyc
|
||||
result
|
||||
EOF
|
||||
|
||||
git add agents.toml prompts .gitignore 2>/dev/null || true
|
||||
|
||||
if [ "$COMMIT" -eq 1 ]; then
|
||||
git -c user.name="project-orchestrator" -c user.email="po@localhost" \
|
||||
commit -q -m "init: scaffold $NAME (engine @ $REF)"
|
||||
fi
|
||||
|
||||
echo "create-project: done — $DEST"
|
||||
echo " engine pinned at: $(cd engine && git rev-parse HEAD) ($REF)"
|
||||
echo " config: agents.toml (session_prefix = $PREFIX)"
|
||||
echo " verify it: ( cd $DEST && python3 engine/agents.py status )"
|
||||
|
||||
if [ "$REGISTER" -eq 1 ]; then
|
||||
echo "create-project: registering '$NAME' in $PO_ROOT/fleet.toml"
|
||||
cat >> "$PO_ROOT/fleet.toml" <<EOF
|
||||
|
||||
[[project]]
|
||||
name = "$NAME"
|
||||
location = "$DEST"
|
||||
harness = "agent-orchestrator"
|
||||
ref = "$REF"
|
||||
enabled = false
|
||||
secrets = ".env"
|
||||
config = "agents.toml"
|
||||
notes = "Created by scripts/create-project.sh"
|
||||
EOF
|
||||
python3 "$PO_ROOT/scripts/fleet.py" --file "$PO_ROOT/fleet.toml" validate
|
||||
fi
|
||||
117
scripts/fleet.py
Executable file
117
scripts/fleet.py
Executable file
@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""fleet.py — read, validate, and report the PO fleet registry (fleet.toml).
|
||||
|
||||
This is the PO's view of the fleet. It NEVER touches a project's repo and carries no project-side
|
||||
state — the registry here is the single source of truth for project ↔ harness ↔ ref ↔ location.
|
||||
|
||||
Usage:
|
||||
python3 scripts/fleet.py list # one line per project (also validates the file)
|
||||
python3 scripts/fleet.py status # same, plus a one-line summary count
|
||||
python3 scripts/fleet.py validate # parse + schema-check only; exit 1 on any error
|
||||
python3 scripts/fleet.py get <name> # dump one project's full entry
|
||||
python3 scripts/fleet.py --file PATH ... # use a non-default registry (default: ./fleet.toml)
|
||||
|
||||
Needs only the Python stdlib (tomllib → python >= 3.11).
|
||||
"""
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED = ["name", "location", "harness", "ref", "enabled", "secrets"]
|
||||
OPTIONAL = ["config", "notes"]
|
||||
|
||||
|
||||
def _registry_path(argv):
|
||||
path = Path("fleet.toml")
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == "--file" and i + 1 < len(argv):
|
||||
path = Path(argv[i + 1]); i += 2; continue
|
||||
out.append(argv[i]); i += 1
|
||||
return path, out
|
||||
|
||||
|
||||
def load(path):
|
||||
if not path.exists():
|
||||
sys.exit(f"fleet: registry not found: {path}")
|
||||
with open(path, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
return raw
|
||||
|
||||
|
||||
def validate(raw, path):
|
||||
errors = []
|
||||
projects = raw.get("project", [])
|
||||
if not isinstance(projects, list):
|
||||
errors.append("[[project]] must be an array of tables")
|
||||
projects = []
|
||||
seen = set()
|
||||
for i, p in enumerate(projects):
|
||||
tag = p.get("name", f"#{i}")
|
||||
for k in REQUIRED:
|
||||
if k not in p:
|
||||
errors.append(f"project {tag}: missing required field '{k}'")
|
||||
if not isinstance(p.get("enabled", False), bool):
|
||||
errors.append(f"project {tag}: 'enabled' must be a boolean")
|
||||
name = p.get("name")
|
||||
if name in seen:
|
||||
errors.append(f"duplicate project name: {name}")
|
||||
if name:
|
||||
seen.add(name)
|
||||
for k in p:
|
||||
if k not in REQUIRED + OPTIONAL:
|
||||
errors.append(f"project {tag}: unknown field '{k}'")
|
||||
return projects, errors
|
||||
|
||||
|
||||
def cmd_list(projects):
|
||||
if not projects:
|
||||
print("(no projects registered)")
|
||||
return
|
||||
w = max(len(p.get("name", "?")) for p in projects)
|
||||
for p in projects:
|
||||
flag = "enabled " if p.get("enabled") else "disabled"
|
||||
print(f" {p.get('name','?'):<{w}} [{flag}] {p.get('harness','?')}@{p.get('ref','?')}"
|
||||
f" {p.get('location','?')}")
|
||||
|
||||
|
||||
def main():
|
||||
path, argv = _registry_path(sys.argv[1:])
|
||||
cmd = argv[0] if argv else "list"
|
||||
raw = load(path)
|
||||
projects, errors = validate(raw, path)
|
||||
if cmd == "validate":
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f" ERROR: {e}")
|
||||
sys.exit(1)
|
||||
print(f"fleet: OK — {len(projects)} project(s), schema v{raw.get('fleet', {}).get('version', '?')}")
|
||||
return
|
||||
# for list/status/get we still surface errors but don't hard-fail the read
|
||||
for e in errors:
|
||||
print(f" ERROR: {e}", file=sys.stderr)
|
||||
if cmd == "list":
|
||||
cmd_list(projects)
|
||||
elif cmd == "status":
|
||||
cmd_list(projects)
|
||||
en = sum(1 for p in projects if p.get("enabled"))
|
||||
print(f"\n total={len(projects)} enabled={en} disabled={len(projects) - en}"
|
||||
f" (registry schema v{raw.get('fleet', {}).get('version', '?')})")
|
||||
elif cmd == "get" and len(argv) > 1:
|
||||
target = argv[1]
|
||||
for p in projects:
|
||||
if p.get("name") == target:
|
||||
for k in REQUIRED + OPTIONAL:
|
||||
if k in p:
|
||||
print(f" {k:<9} = {p[k]!r}")
|
||||
return
|
||||
sys.exit(f"fleet: no project named {target!r}")
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
scripts/start-project.sh
Executable file
19
scripts/start-project.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# start-project.sh <name> [agent...] — start a fleet project's agents via its harness.
|
||||
#
|
||||
# Resolves <name> in the PO's fleet.toml, then drives that project's harness. For an
|
||||
# agent-orchestrator project that is `engine/agents.py up`. For an unfamiliar harness, READ that
|
||||
# project's harness docs first — there is no rigid contract. This wrapper handles the common
|
||||
# agent-orchestrator case; extend it (or act by hand) for other harnesses.
|
||||
set -euo pipefail
|
||||
[ $# -ge 1 ] || { echo "usage: start-project.sh <name> [agent...]" >&2; exit 1; }
|
||||
NAME="$1"; shift || true
|
||||
source "$(dirname "$0")/_resolve.sh"
|
||||
IFS=$'\t' read -r LOCATION CONFIG HARNESS < <(resolve_project "$NAME")
|
||||
[ -d "$LOCATION" ] || { echo "start-project: location not a local dir: $LOCATION (clone it first)" >&2; exit 1; }
|
||||
case "$HARNESS" in
|
||||
agent-orchestrator)
|
||||
echo "start-project: $NAME → python3 engine/agents.py up $* (in $LOCATION)"
|
||||
( cd "$LOCATION" && python3 engine/agents.py --config "$CONFIG" up "$@" );;
|
||||
*) echo "start-project: harness '$HARNESS' is not agent-orchestrator — read its docs and drive it by hand." >&2; exit 2;;
|
||||
esac
|
||||
15
scripts/stop-project.sh
Executable file
15
scripts/stop-project.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# stop-project.sh <name> [agent...] — stop a fleet project's agents via its harness.
|
||||
# Mirror of start-project.sh; for an agent-orchestrator project that is `engine/agents.py down`.
|
||||
set -euo pipefail
|
||||
[ $# -ge 1 ] || { echo "usage: stop-project.sh <name> [agent...]" >&2; exit 1; }
|
||||
NAME="$1"; shift || true
|
||||
source "$(dirname "$0")/_resolve.sh"
|
||||
IFS=$'\t' read -r LOCATION CONFIG HARNESS < <(resolve_project "$NAME")
|
||||
[ -d "$LOCATION" ] || { echo "stop-project: location not a local dir: $LOCATION" >&2; exit 1; }
|
||||
case "$HARNESS" in
|
||||
agent-orchestrator)
|
||||
echo "stop-project: $NAME → python3 engine/agents.py down $* (in $LOCATION)"
|
||||
( cd "$LOCATION" && python3 engine/agents.py --config "$CONFIG" down "$@" );;
|
||||
*) echo "stop-project: harness '$HARNESS' is not agent-orchestrator — read its docs and drive it by hand." >&2; exit 2;;
|
||||
esac
|
||||
21
scripts/update-project.sh
Executable file
21
scripts/update-project.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-project.sh <name> <new-ref> — bump a fleet project's harness submodule to a new ref.
|
||||
#
|
||||
# Updating the engine for a project = checkout a new ref of its `engine/` submodule + commit, IN
|
||||
# THAT PROJECT'S REPO ONLY. It touches no other project (each pins its own copy). Afterwards, update
|
||||
# the project's `ref` in this PO's fleet.toml so the registry stays accurate.
|
||||
set -euo pipefail
|
||||
[ $# -ge 2 ] || { echo "usage: update-project.sh <name> <new-ref>" >&2; exit 1; }
|
||||
NAME="$1"; NEWREF="$2"
|
||||
source "$(dirname "$0")/_resolve.sh"
|
||||
IFS=$'\t' read -r LOCATION CONFIG HARNESS < <(resolve_project "$NAME")
|
||||
[ -d "$LOCATION" ] || { echo "update-project: location not a local dir: $LOCATION" >&2; exit 1; }
|
||||
[ "$HARNESS" = "agent-orchestrator" ] || { echo "update-project: harness '$HARNESS' not agent-orchestrator — update by hand per its docs." >&2; exit 2; }
|
||||
|
||||
echo "update-project: $NAME engine → $NEWREF (in $LOCATION)"
|
||||
( cd "$LOCATION/engine" && git fetch -q --tags origin && git checkout -q "$NEWREF" )
|
||||
( cd "$LOCATION" && git add engine \
|
||||
&& git -c user.name="project-orchestrator" -c user.email="po@localhost" \
|
||||
commit -q -m "chore: bump engine to $NEWREF" )
|
||||
echo "update-project: project committed. Now update fleet.toml: set ref = \"$NEWREF\" for '$NAME'."
|
||||
echo " (edit $(cd "$(dirname "$0")/.." && pwd)/fleet.toml, then: python3 scripts/fleet.py validate)"
|
||||
Reference in New Issue
Block a user