deploy-scenario.sh now single-sources the application backend: it copies the git-tracked files under [scenario].backend into the workspace root (where .pi/ sits beside them), preserving modes and skipping caches/venvs. It overwrites but never prunes, and never restarts -- it prints a restart reminder when backend files change. README's Application code section updated to match.
254 lines
9.6 KiB
Bash
Executable File
254 lines
9.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ---------------------------------------------------------------------------
|
|
# Install a scenario's tracked workspace into its live location, and render the
|
|
# launch contract from profile.toml.
|
|
#
|
|
# Dry run by default. Never restarts a service: restarting is a decision about
|
|
# when to interrupt a live conversation, and belongs to the operator.
|
|
#
|
|
# Usage:
|
|
# scripts/deploy-scenario.sh <scenario> # show what would change
|
|
# scripts/deploy-scenario.sh <scenario> --apply
|
|
# scripts/deploy-scenario.sh --list
|
|
# ---------------------------------------------------------------------------
|
|
# shellcheck source=lib/common.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
|
|
|
if [ "${1:-}" = "--list" ]; then
|
|
list_scenarios
|
|
exit 0
|
|
fi
|
|
|
|
NAME="${1:-}"
|
|
APPLY=0
|
|
[ "${2:-}" = "--apply" ] && APPLY=1
|
|
|
|
DIR="$(require_scenario "$NAME")"
|
|
PROFILE="$DIR/profile.toml"
|
|
[ -f "$PROFILE" ] || die "scenario '$NAME' has no profile.toml"
|
|
|
|
TRACKED="$(toml_get "$PROFILE" scenario tracked)"
|
|
if [ "$TRACKED" = "false" ]; then
|
|
die "scenario '$NAME' is registered but not tracked; nothing to deploy"
|
|
fi
|
|
|
|
# A scenario is either an authoritative source ("managed") or a record of what
|
|
# the live host already does ("mirror"). Applying a mirror would invert the
|
|
# direction of truth and, for a service in daily use, risks a behaviour change
|
|
# that nobody asked for. Mirrors are diffable but not deployable.
|
|
MODE="$(toml_get "$PROFILE" scenario deploy)"
|
|
MODE="${MODE:-managed}"
|
|
if [ "$MODE" = "mirror" ] && [ "$APPLY" -eq 1 ]; then
|
|
die "scenario '$NAME' is a mirror of the live configuration, not an authoritative source.
|
|
Refusing --apply. Use scripts/pi-diff.sh $NAME to compare, and promote the
|
|
scenario to deploy = \"managed\" in profile.toml once it is the source of truth."
|
|
fi
|
|
|
|
WORKSPACE="$(toml_get "$PROFILE" scenario workspace)"
|
|
[ -n "$WORKSPACE" ] || die "scenario '$NAME': [scenario].workspace is not set"
|
|
[ -d "$DIR/workspace" ] || die "scenario '$NAME' has no tracked workspace/"
|
|
|
|
head1 "deploy $NAME -> $WORKSPACE"
|
|
[ "$APPLY" -eq 1 ] || info "${C_DIM}(dry run; pass --apply to write)${C_OFF}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Workspace files
|
|
# ---------------------------------------------------------------------------
|
|
CHANGES=0
|
|
while IFS= read -r rel; do
|
|
src="$DIR/workspace/$rel"
|
|
dst="$WORKSPACE/$rel"
|
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
continue
|
|
fi
|
|
CHANGES=$((CHANGES + 1))
|
|
if [ -f "$dst" ]; then
|
|
info " update $rel"
|
|
else
|
|
info " create $rel"
|
|
fi
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$(dirname "$dst")"
|
|
install -m 600 "$src" "$dst"
|
|
fi
|
|
done < <(cd "$DIR/workspace" && find . -type f -printf '%P\n' | sort)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1b. Vendored shared extensions
|
|
#
|
|
# A tracked extension that imports from shared/extensions/ cannot resolve that
|
|
# path once installed outside the repository. The listed modules are copied into
|
|
# .pi/extensions/_shared/ so the deployed tree is self-contained, while the
|
|
# repository stays the single source of truth: this overwrites, never merges.
|
|
# ---------------------------------------------------------------------------
|
|
SHARED="$(toml_list "$PROFILE" resources shared_extensions)"
|
|
if [ -n "$SHARED" ]; then
|
|
while IFS= read -r mod; do
|
|
[ -n "$mod" ] || continue
|
|
src="$REPO_ROOT/shared/extensions/$mod"
|
|
[ -f "$src" ] || die "profile lists shared extension '$mod', which does not exist"
|
|
dst="$WORKSPACE/.pi/extensions/_shared/$mod"
|
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
continue
|
|
fi
|
|
CHANGES=$((CHANGES + 1))
|
|
if [ -f "$dst" ]; then info " update .pi/extensions/_shared/$mod"
|
|
else info " vendor .pi/extensions/_shared/$mod"; fi
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$(dirname "$dst")"
|
|
install -m 600 "$src" "$dst"
|
|
fi
|
|
done < <(printf '%s\n' "$SHARED")
|
|
fi
|
|
|
|
# Executables under bin/ need the execute bit back.
|
|
if [ "$APPLY" -eq 1 ] && [ -d "$DIR/workspace/bin" ]; then
|
|
while IFS= read -r rel; do
|
|
[ -x "$DIR/workspace/$rel" ] && chmod 700 "$WORKSPACE/$rel"
|
|
done < <(cd "$DIR/workspace" && find bin -type f -printf '%P\n' 2>/dev/null | sed 's|^|bin/|')
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1c. Application backend
|
|
#
|
|
# A scenario that owns a service keeps its source under backend/ (set by
|
|
# [scenario].backend). It installs into the workspace root, where .pi/ sits
|
|
# beside it, so the repository is the single source of truth. Only git-tracked
|
|
# files are copied -- build caches and virtualenvs never leak -- and file modes
|
|
# (notably the execute bit) are preserved. This overwrites but never prunes:
|
|
# removing a file the repo no longer tracks from a live tree is too blunt to do
|
|
# unattended. Like every other step it never restarts the service; it prints a
|
|
# reminder, because restarting decides when to interrupt a live conversation.
|
|
# ---------------------------------------------------------------------------
|
|
BACKEND="$(toml_get "$PROFILE" scenario backend)"
|
|
if [ -n "$BACKEND" ]; then
|
|
case "$BACKEND" in /*) ;; *) BACKEND="$REPO_ROOT/$BACKEND" ;; esac
|
|
[ -d "$BACKEND" ] || die "profile sets [scenario].backend to '$BACKEND', which does not exist"
|
|
BACKEND_CHANGES=0
|
|
while IFS= read -r rel; do
|
|
src="$BACKEND/$rel"
|
|
dst="$WORKSPACE/$rel"
|
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
continue
|
|
fi
|
|
BACKEND_CHANGES=$((BACKEND_CHANGES + 1))
|
|
if [ -f "$dst" ]; then info " update $rel"; else info " create $rel"; fi
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 755 "$(dirname "$dst")"
|
|
install -m "$(stat -c '%a' "$src")" "$src" "$dst"
|
|
fi
|
|
done < <(git -C "$BACKEND" ls-files)
|
|
CHANGES=$((CHANGES + BACKEND_CHANGES))
|
|
if [ "$BACKEND_CHANGES" -gt 0 ]; then
|
|
warn "backend: $BACKEND_CHANGES file(s) changed; restart $(toml_get "$PROFILE" scenario service) to apply"
|
|
else
|
|
ok "backend already matches"
|
|
fi
|
|
fi
|
|
|
|
[ "$CHANGES" -eq 0 ] && ok "workspace already matches"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Launch contract
|
|
#
|
|
# profile.toml is the single source of truth for launch flags. Rendering it to
|
|
# JSON lets the gateway read the contract instead of hard-coding flags, and lets
|
|
# the gateway fail closed when the file is missing -- silently running without
|
|
# --no-extensions would widen the agent's reach.
|
|
# ---------------------------------------------------------------------------
|
|
if [ "$MODE" = "mirror" ]; then
|
|
head1 "launch contract"
|
|
info " ${C_DIM}skipped: '$NAME' is a mirror; its launch flags live in its own"
|
|
info " gateway, not in profile.toml${C_OFF}"
|
|
printf '\n'
|
|
ok "dry run complete (mirror scenario; nothing to apply)"
|
|
exit 0
|
|
fi
|
|
|
|
LAUNCH="$WORKSPACE/.pi/launch.json"
|
|
head1 "launch contract -> $LAUNCH"
|
|
|
|
RENDERED="$(python3 - "$PROFILE" "$WORKSPACE" <<'PY'
|
|
import json, sys, tomllib, pathlib
|
|
|
|
profile = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
workspace = pathlib.Path(sys.argv[2])
|
|
|
|
def rel(paths):
|
|
return [str(workspace / p) for p in paths or []]
|
|
|
|
iso = profile.get("isolation", {})
|
|
res = profile.get("resources", {})
|
|
pers = profile.get("personality", {})
|
|
out = {
|
|
"_generated_from": "profile.toml",
|
|
"_note": "Do not edit. Regenerate with scripts/deploy-scenario.sh.",
|
|
"scenario": profile.get("scenario", {}).get("name"),
|
|
"workspace": str(workspace),
|
|
"session_dir": profile.get("scenario", {}).get("session_dir"),
|
|
"model": profile.get("model", {}),
|
|
"session": profile.get("session", {}),
|
|
"isolation": iso,
|
|
"personality": {
|
|
"system_prompt": str(workspace / pers["system_prompt"]) if pers.get("system_prompt") else "",
|
|
"append_system_prompt": str(workspace / pers["append_system_prompt"]) if pers.get("append_system_prompt") else "",
|
|
"context_files": pers.get("context_files", []),
|
|
},
|
|
"resources": {
|
|
"extensions": rel(res.get("extensions")),
|
|
"skills": rel(res.get("skills")),
|
|
},
|
|
"tools": profile.get("tools", {}),
|
|
"budget": profile.get("budget", {}),
|
|
"env": profile.get("env", {}),
|
|
"bridge": profile.get("bridge", {}),
|
|
}
|
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
PY
|
|
)" || die "cannot render profile.toml"
|
|
|
|
if [ -f "$LAUNCH" ] && [ "$RENDERED" = "$(cat "$LAUNCH")" ]; then
|
|
ok "launch.json already current"
|
|
else
|
|
info " write .pi/launch.json"
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
install -d -m 700 "$WORKSPACE/.pi"
|
|
printf '%s\n' "$RENDERED" > "$LAUNCH"
|
|
chmod 600 "$LAUNCH"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Referenced paths must exist
|
|
# ---------------------------------------------------------------------------
|
|
head1 "referenced resources"
|
|
MISSING=0
|
|
while IFS= read -r p; do
|
|
[ -n "$p" ] || continue
|
|
if [ -e "$p" ]; then ok "$p"; else warn "missing: $p"; MISSING=$((MISSING + 1)); fi
|
|
done < <(printf '%s\n' "$RENDERED" | python3 -c '
|
|
import json, sys
|
|
d = json.load(sys.stdin)
|
|
for p in d["resources"]["extensions"] + d["resources"]["skills"]:
|
|
print(p)
|
|
for key in ("system_prompt", "append_system_prompt"):
|
|
if d["personality"].get(key):
|
|
print(d["personality"][key])
|
|
')
|
|
|
|
printf '\n'
|
|
if [ "$APPLY" -eq 1 ]; then
|
|
ok "applied"
|
|
info ""
|
|
SERVICE="$(toml_get "$PROFILE" scenario service)"
|
|
if [ -n "$SERVICE" ]; then
|
|
info "The service was NOT restarted. When you are ready:"
|
|
info " systemctl --user restart $SERVICE"
|
|
info " systemctl --user status $SERVICE"
|
|
fi
|
|
else
|
|
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
|
|
fi
|
|
[ "$MISSING" -eq 0 ] || warn "$MISSING referenced path(s) missing; the agent will not start correctly"
|
|
exit 0
|