544 lines
16 KiB
Bash
544 lines
16 KiB
Bash
#!/usr/bin/env bash
|
||
# ==============================================================================
|
||
# install.sh — Multi-Agent Mux (MAM) Orchestration Installer (Rev.2)
|
||
# ==============================================================================
|
||
set -euo pipefail
|
||
|
||
# --- Configuration & Defaults ---
|
||
TARGET_DIR=""
|
||
REFRESH="${MAM_REFRESH:-1}"
|
||
OVERWRITE_CUSTOM="${MAM_OVERWRITE_CUSTOM:-0}"
|
||
VENV_NAME=".venv"
|
||
MIN_PYTHON_VERSION="3.9"
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
-f|--force|--refresh-skills)
|
||
REFRESH=1
|
||
shift
|
||
;;
|
||
--no-refresh|--offline)
|
||
REFRESH=0
|
||
shift
|
||
;;
|
||
--overwrite-custom)
|
||
OVERWRITE_CUSTOM=1
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
cat <<EOF
|
||
Usage: $0 [options] [target_dir]
|
||
|
||
Options:
|
||
-f, --force, --refresh-skills Fetch and refresh skills (now default)
|
||
--no-refresh, --offline Skip fetching latest assets if assets exist
|
||
--overwrite-custom Force overwrite user-modified skill files (backup still created)
|
||
-h, --help Show this help message
|
||
EOF
|
||
exit 0
|
||
;;
|
||
*)
|
||
if [ -z "$TARGET_DIR" ]; then
|
||
TARGET_DIR="$1"
|
||
else
|
||
echo "❌ Error: Unknown argument: $1" >&2
|
||
exit 1
|
||
fi
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [ -z "$TARGET_DIR" ]; then
|
||
TARGET_DIR="$(pwd)"
|
||
fi
|
||
|
||
echo "===================================================================="
|
||
echo "⚡ Starting Multi-Agent Mux (MAM) Installation"
|
||
echo "📂 Target Workspace: $TARGET_DIR"
|
||
echo "===================================================================="
|
||
|
||
# --- 1. System Requirements Validation ---
|
||
echo "🔍 Checking system dependencies..."
|
||
|
||
check_cmd() {
|
||
local cmd="$1"
|
||
if ! command -v "$cmd" &>/dev/null; then
|
||
echo "❌ Error: '$cmd' is not installed. Please install it first." >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
check_cmd herdr
|
||
check_cmd python3
|
||
|
||
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||
PYTHON_MAJOR="${MIN_PYTHON_VERSION%%.*}"
|
||
PYTHON_MINOR="${MIN_PYTHON_VERSION##*.}"
|
||
if python3 -c "import sys; exit(0 if sys.version_info >= ($PYTHON_MAJOR, $PYTHON_MINOR) else 1)"; then
|
||
echo "✅ Python $PYTHON_VERSION detected."
|
||
else
|
||
echo "❌ Error: Python version must be $MIN_PYTHON_VERSION or higher. Detected: $PYTHON_VERSION" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! python3 -c "import yaml" &>/dev/null; then
|
||
echo "❌ Error: 'PyYAML' is not installed in system python3." >&2
|
||
exit 1
|
||
fi
|
||
echo "✅ PyYAML (system dependency) detected."
|
||
|
||
# --- 2. Workspace Setup ---
|
||
mkdir -p "$TARGET_DIR"
|
||
cd "$TARGET_DIR"
|
||
|
||
REPO_URL="${MAM_REPO_URL:-https://git.godopu.com/tmpl/multi-agent-mux.git}"
|
||
ARCHIVE_URL="${MAM_ARCHIVE_URL:-https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz}"
|
||
|
||
check_assets_present() {
|
||
local dir="${1:-.}"
|
||
local core_files=(
|
||
".agents/skills/lib.sh"
|
||
".agents/skills/multi-agent-mux-create/scripts/create_session.sh"
|
||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||
".agents/skills/multi-agent-mux-status/scripts/status.sh"
|
||
".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh"
|
||
)
|
||
for f in "${core_files[@]}"; do
|
||
if [ ! -f "$dir/$f" ]; then
|
||
return 1
|
||
fi
|
||
done
|
||
return 0
|
||
}
|
||
|
||
is_framework_owned() {
|
||
case "$1" in
|
||
.agents/skills/*) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
# Fetch orchestration assets if REFRESH=1 or if core assets are missing.
|
||
if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_present "."; then
|
||
echo "📥 Staging orchestration assets from Gitea repository..."
|
||
STAGE_DIR="$(mktemp -d)"
|
||
trap 'rm -rf "$STAGE_DIR"' EXIT
|
||
|
||
FETCH_METHOD="archive"
|
||
if [ -d "$REPO_URL" ]; then
|
||
echo "🌐 Copying local working tree into a staging area..."
|
||
cp -R "$REPO_URL/." "$STAGE_DIR/"
|
||
FETCH_METHOD="local"
|
||
elif command -v git &>/dev/null; then
|
||
echo "🌐 Cloning repository (shallow) into a staging area..."
|
||
git clone --depth 1 "$REPO_URL" "$STAGE_DIR"
|
||
FETCH_METHOD="git"
|
||
elif command -v curl &>/dev/null; then
|
||
echo "🌐 Downloading and extracting archive into a staging area..."
|
||
curl -fsSL "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$STAGE_DIR" \
|
||
--exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' \
|
||
--exclude='*/MESSAGING.md' --exclude='*/BOOTSTRAP.md' --exclude='*/BOOTSTRAP.ko.md' 2>/dev/null || true
|
||
FETCH_METHOD="archive"
|
||
else
|
||
echo "❌ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2
|
||
exit 1
|
||
fi
|
||
|
||
if ! check_assets_present "$STAGE_DIR"; then
|
||
echo "❌ Error: fetched source is missing core runtime assets. Aborting." >&2
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p .mam
|
||
MANIFEST_FILE=".mam/install_manifest.txt"
|
||
touch "$MANIFEST_FILE"
|
||
|
||
# Migrate legacy root layout for remove.sh and update.sh into .mam_deploy/ if owned
|
||
mkdir -p .mam_deploy
|
||
for legacy_script in remove.sh update.sh; do
|
||
if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then
|
||
mv -f "$legacy_script" ".mam_deploy/$legacy_script"
|
||
python3 -c '
|
||
import sys
|
||
path = sys.argv[1]
|
||
old_s = sys.argv[2]
|
||
new_s = sys.argv[3]
|
||
with open(path, "r") as f:
|
||
lines = f.readlines()
|
||
with open(path, "w") as f:
|
||
for line in lines:
|
||
if line.strip() == old_s:
|
||
f.write(new_s + "\n")
|
||
else:
|
||
f.write(line)
|
||
' "$MANIFEST_FILE" "$legacy_script" ".mam_deploy/$legacy_script" 2>/dev/null || true
|
||
fi
|
||
done
|
||
|
||
# Safe refresh & fingerprint checking logic
|
||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||
PRESERVED_COUNT=0
|
||
MODIFIED_FILES=()
|
||
|
||
mkdir -p .agents
|
||
( cd "$STAGE_DIR/.agents" && find . -type f -print ) | while IFS= read -r rel; do
|
||
case "$rel" in
|
||
./reports/*|./references/*) continue ;;
|
||
*.tmp|*.log|*.pyc|*/__pycache__/*) continue ;;
|
||
esac
|
||
|
||
dest=".agents/${rel#./}"
|
||
mkdir -p "$(dirname "$dest")"
|
||
|
||
if is_framework_owned "$dest"; then
|
||
# 3-way check using python3 inline
|
||
STAGING_FILE="$STAGE_DIR/.agents/$rel"
|
||
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" <<'PY'
|
||
import sys, hashlib, os
|
||
|
||
target_path, staging_path, hash_db_path, force_overwrite = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
|
||
|
||
def file_sha(path):
|
||
if not os.path.exists(path):
|
||
return None
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
while chunk := f.read(65536):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
target_sha = file_sha(target_path)
|
||
staging_sha = file_sha(staging_path)
|
||
|
||
if target_sha is None:
|
||
print("COPY_NEW")
|
||
sys.exit(0)
|
||
|
||
if target_sha == staging_sha:
|
||
print("NO_OP")
|
||
sys.exit(0)
|
||
|
||
db_sha = None
|
||
if os.path.exists(hash_db_path):
|
||
with open(hash_db_path, "r") as f:
|
||
for line in f:
|
||
parts = line.strip().split(None, 1)
|
||
if len(parts) == 2 and parts[1] == target_path:
|
||
db_sha = parts[0]
|
||
break
|
||
|
||
if db_sha is None:
|
||
print("BOOTSTRAP_OVERWRITE")
|
||
elif target_sha == db_sha:
|
||
print("UPDATE_UNMODIFIED")
|
||
else:
|
||
if force_overwrite:
|
||
print("FORCE_OVERWRITE_CUSTOM")
|
||
else:
|
||
print("PRESERVE_CUSTOM")
|
||
PY
|
||
)
|
||
case "$ACTION" in
|
||
COPY_NEW|UPDATE_UNMODIFIED|BOOTSTRAP_OVERWRITE|FORCE_OVERWRITE_CUSTOM)
|
||
if [ "$ACTION" = "BOOTSTRAP_OVERWRITE" ] || [ "$ACTION" = "FORCE_OVERWRITE_CUSTOM" ]; then
|
||
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
|
||
mkdir -p "$BACKUP_DIR"
|
||
cp "$dest" "$BACKUP_DIR/"
|
||
fi
|
||
cp -f "$STAGING_FILE" "$dest"
|
||
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
|
||
echo "$dest" >> "$MANIFEST_FILE"
|
||
fi
|
||
;;
|
||
PRESERVE_CUSTOM)
|
||
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
|
||
mkdir -p "$BACKUP_DIR"
|
||
cp "$dest" "$BACKUP_DIR/"
|
||
echo "PRESERVED:$dest"
|
||
;;
|
||
NO_OP)
|
||
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
|
||
echo "$dest" >> "$MANIFEST_FILE"
|
||
fi
|
||
;;
|
||
esac
|
||
elif [ ! -e "$dest" ]; then
|
||
cp "$STAGE_DIR/.agents/$rel" "$dest"
|
||
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
|
||
echo "$dest" >> "$MANIFEST_FILE"
|
||
fi
|
||
fi
|
||
done | while IFS= read -r line; do
|
||
if [[ "$line" == PRESERVED:* ]]; then
|
||
echo "ℹ️ Local modification detected: ${line#PRESERVED:}" >&2
|
||
fi
|
||
done
|
||
|
||
# Re-build asset_hashes.txt for all framework owned files
|
||
python3 - .mam/asset_hashes.txt <<'PY'
|
||
import os, hashlib, sys
|
||
|
||
hash_db_path = sys.argv[1]
|
||
hashes = []
|
||
|
||
for root, _, files in os.walk(".agents/skills"):
|
||
for file in files:
|
||
path = os.path.join(root, file)
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
while chunk := f.read(65536):
|
||
h.update(chunk)
|
||
hashes.append(f"{h.hexdigest()} {path}\n")
|
||
|
||
with open(hash_db_path, "w") as f:
|
||
f.writelines(sorted(hashes))
|
||
PY
|
||
|
||
# Copy root docs (R-2 essential set)
|
||
ROOT_DOCS="AGENTS.md"
|
||
if [ "${MAM_INSTALL_DOCS:-minimal}" = "full" ]; then
|
||
ROOT_DOCS="AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md"
|
||
fi
|
||
for doc in $ROOT_DOCS; do
|
||
if [ -f "$STAGE_DIR/$doc" ] && [ ! -e "$doc" ]; then
|
||
cp "$STAGE_DIR/$doc" .
|
||
echo "$doc" >> "$MANIFEST_FILE"
|
||
fi
|
||
done
|
||
|
||
# Install remove.sh and update.sh into .mam_deploy/
|
||
mkdir -p .mam_deploy
|
||
if [ -f "$STAGE_DIR/deploy/remove.sh" ]; then
|
||
cp "$STAGE_DIR/deploy/remove.sh" .mam_deploy/remove.sh
|
||
chmod 0755 .mam_deploy/remove.sh
|
||
if ! grep -Fqx ".mam_deploy/remove.sh" "$MANIFEST_FILE" 2>/dev/null; then
|
||
echo ".mam_deploy/remove.sh" >> "$MANIFEST_FILE"
|
||
fi
|
||
fi
|
||
|
||
if [ -f "$STAGE_DIR/deploy/update.sh" ]; then
|
||
cp "$STAGE_DIR/deploy/update.sh" .mam_deploy/update.sh
|
||
chmod 0755 .mam_deploy/update.sh
|
||
if ! grep -Fqx ".mam_deploy/update.sh" "$MANIFEST_FILE" 2>/dev/null; then
|
||
echo ".mam_deploy/update.sh" >> "$MANIFEST_FILE"
|
||
fi
|
||
fi
|
||
|
||
if [ -f "$STAGE_DIR/.mam.env.example" ] && [ ! -e ".mam.env.example" ]; then
|
||
cp "$STAGE_DIR/.mam.env.example" .
|
||
echo ".mam.env.example" >> "$MANIFEST_FILE"
|
||
fi
|
||
|
||
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ] && [ ! -e ".agents/INSTALL.md" ]; then
|
||
mkdir -p .agents
|
||
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md
|
||
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
|
||
fi
|
||
|
||
# Record version stamp
|
||
STAGE_COMMIT="unknown"
|
||
if [ -d "$STAGE_DIR/.git" ]; then
|
||
STAGE_COMMIT=$(git -C "$STAGE_DIR" rev-parse HEAD 2>/dev/null || echo "unknown")
|
||
fi
|
||
cat <<EOF > .mam/version.txt
|
||
source=$REPO_URL
|
||
commit=$STAGE_COMMIT
|
||
fetched_at=$TS
|
||
method=$FETCH_METHOD
|
||
EOF
|
||
|
||
rm -rf "$STAGE_DIR"
|
||
trap - EXIT
|
||
echo "✅ Skills staged into workspace (user documents and custom configs preserved)."
|
||
fi
|
||
|
||
if ! check_assets_present "."; then
|
||
echo "❌ Error: Core runtime assets missing after setup." >&2
|
||
exit 1
|
||
fi
|
||
echo "✅ Orchestration skills present."
|
||
|
||
echo "📂 Ensuring metadata directory structure (.mam/)..."
|
||
mkdir -p .mam/jobs .mam/delegate_job_logs
|
||
|
||
if [ -O .mam ]; then
|
||
chmod 0700 .mam
|
||
fi
|
||
|
||
# R-4: Manage .gitignore block (never put .gitignore in manifest)
|
||
MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>"
|
||
MAM_GI_END="# <<< MAM managed block <<<"
|
||
|
||
if [ "${MAM_SKIP_GITIGNORE:-0}" != "1" ]; then
|
||
GI_CREATED=0
|
||
[ -e .gitignore ] || { touch .gitignore; GI_CREATED=1; }
|
||
|
||
if ! grep -q '^gitignore_created=' .mam/install_state 2>/dev/null; then
|
||
echo "gitignore_created=$GI_CREATED" >> .mam/install_state
|
||
fi
|
||
|
||
python3 - .gitignore "$MAM_GI_START" "$MAM_GI_END" <<'PY'
|
||
import sys, os
|
||
|
||
gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3]
|
||
|
||
block_lines = [
|
||
start_marker + "\n",
|
||
"/.venv/\n",
|
||
"/.mam/\n",
|
||
"/.mam_deploy/\n",
|
||
"/.mam.env\n",
|
||
"/.mam.env.*\n",
|
||
"!/.mam.env.example\n",
|
||
"/.cache/multi-agent-mux-monitor/\n",
|
||
"/.mam-skill-backup.*/\n",
|
||
"CURRENT_JOB.md\n",
|
||
end_marker + "\n"
|
||
]
|
||
|
||
lines = []
|
||
if os.path.exists(gi_path):
|
||
with open(gi_path, "r") as f:
|
||
lines = f.readlines()
|
||
|
||
new_lines = []
|
||
in_block = False
|
||
block_inserted = False
|
||
|
||
for line in lines:
|
||
if line.strip() == start_marker:
|
||
in_block = True
|
||
if not block_inserted:
|
||
new_lines.extend(block_lines)
|
||
block_inserted = True
|
||
continue
|
||
if line.strip() == end_marker:
|
||
in_block = False
|
||
continue
|
||
if not in_block:
|
||
new_lines.append(line)
|
||
|
||
if not block_inserted:
|
||
if new_lines and not new_lines[-1].endswith("\n"):
|
||
new_lines[-1] += "\n"
|
||
new_lines.extend(block_lines)
|
||
|
||
with open(gi_path, "w") as f:
|
||
f.writelines(new_lines)
|
||
PY
|
||
fi
|
||
|
||
# --- 3. Check Network File System (NFS) Warnings ---
|
||
echo "💾 Detecting file system mount type..."
|
||
if command -v df &>/dev/null && command -v mount &>/dev/null; then
|
||
MOUNTPOINT="$(df --output=target . 2>/dev/null | tail -1 || echo "")"
|
||
if [ -n "$MOUNTPOINT" ]; then
|
||
if mount | grep -q "$MOUNTPOINT.*nfs\|$MOUNTPOINT.*cifs\|$MOUNTPOINT.*fuse.sshfs"; then
|
||
echo "⚠️ WARNING: Target directory is on a network filesystem."
|
||
else
|
||
echo "✅ File system supports WAL (Local storage detected)."
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# --- 4. Python Virtual Environment Setup ---
|
||
if [ "${MAM_SKIP_VENV:-0}" != "1" ]; then
|
||
echo "🐍 Bootstrapping Python virtual environment (.venv)..."
|
||
if [ ! -d "$VENV_NAME" ]; then
|
||
python3 -m venv "$VENV_NAME"
|
||
echo "✅ Virtual environment created."
|
||
else
|
||
echo "ℹ️ Virtual environment (.venv) already exists. Skipping creation."
|
||
fi
|
||
|
||
source "$VENV_NAME"/bin/activate
|
||
pip install --upgrade pip
|
||
|
||
REQ_FILE=".agents/skills/multi-agent-mux-delegate-job/requirements.txt"
|
||
if [ -f "$REQ_FILE" ]; then
|
||
echo "📦 Installing backplane dependencies from $REQ_FILE..."
|
||
pip install -r "$REQ_FILE"
|
||
echo "✅ Dependencies installed successfully."
|
||
else
|
||
pip install "paho-mqtt>=2.0.0" pyyaml
|
||
fi
|
||
fi
|
||
|
||
# --- 5. Generate Environment Template ---
|
||
ENV_FILE=".mam.env"
|
||
ENV_EXAMPLE=".mam.env.example"
|
||
|
||
migrate_legacy_env() {
|
||
local manifest=".mam/install_manifest.txt"
|
||
[ -f ".mam.env" ] && return 0
|
||
[ -f ".env" ] || return 0
|
||
if [ "${MAM_LEGACY_ENV_OWNED:-0}" = "1" ] || { [ -f "$manifest" ] && grep -Fqx ".env" "$manifest" 2>/dev/null; }; then
|
||
mv -f ".env" ".mam.env"
|
||
chmod 0600 ".mam.env" 2>/dev/null || true
|
||
if [ -f "$manifest" ]; then
|
||
if grep -Fqx ".env" "$manifest" 2>/dev/null; then
|
||
python3 -c '
|
||
import sys
|
||
path = sys.argv[1]
|
||
with open(path, "r") as f:
|
||
lines = f.readlines()
|
||
with open(path, "w") as f:
|
||
for line in lines:
|
||
if line.strip() == ".env":
|
||
f.write(".mam.env\n")
|
||
else:
|
||
f.write(line)
|
||
' "$manifest" 2>/dev/null || true
|
||
else
|
||
echo ".mam.env" >> "$manifest"
|
||
fi
|
||
fi
|
||
echo "ℹ️ Legacy MAM config migrated: .env -> .mam.env"
|
||
else
|
||
echo "ℹ️ Existing .env left untouched (ownership unproven)."
|
||
echo " MAM will read it via the deprecated fallback."
|
||
echo " To migrate explicitly: deploy/generate-env.sh --migrate-legacy"
|
||
fi
|
||
}
|
||
|
||
migrate_legacy_env
|
||
|
||
if [ ! -f "$ENV_FILE" ] && [ ! -f ".env" ] && [ ! -f ".env.update-tmp" ]; then
|
||
if [ -f "$ENV_EXAMPLE" ]; then
|
||
echo "📝 Creating configuration from $ENV_EXAMPLE..."
|
||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||
else
|
||
echo "📝 Creating default $ENV_FILE..."
|
||
touch "$ENV_FILE"
|
||
fi
|
||
|
||
cat <<EOF >> "$ENV_FILE"
|
||
|
||
# === Installer-applied active defaults ===
|
||
MQTT_BROKER=broker.hivemq.com
|
||
MQTT_PORT=1883
|
||
MQTT_TLS=0
|
||
MQTT_CLIENT_ID_PREFIX=mam-agent
|
||
HERDR_SERVER_NAME=default
|
||
EOF
|
||
chmod 0600 "$ENV_FILE"
|
||
echo "✅ Config file .mam.env initialized with chmod 0600."
|
||
|
||
mkdir -p .mam
|
||
touch .mam/install_manifest.txt
|
||
echo "$ENV_FILE" >> .mam/install_manifest.txt
|
||
else
|
||
if [ -f "$ENV_FILE" ]; then
|
||
echo "ℹ️ $ENV_FILE already exists. Skipping config override."
|
||
else
|
||
echo "ℹ️ Legacy environment detected. Preserved without shadowing."
|
||
fi
|
||
fi
|
||
|
||
echo "===================================================================="
|
||
echo "🎉 Installation complete!"
|
||
echo "✨ You can now run the status or monitor skills."
|
||
echo "💡 Hint: Try executing: .venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py list"
|
||
echo "===================================================================="
|