diff --git a/docker/edge/dispatcher.sh b/docker/edge/dispatcher.sh old mode 100644 new mode 100755 index adae33a..02718b6 --- a/docker/edge/dispatcher.sh +++ b/docker/edge/dispatcher.sh @@ -1,12 +1,164 @@ #!/usr/bin/env bash -# dispatcher.sh — Edge task dispatcher placeholder +# dispatcher.sh — Edge task dispatcher # -# TODO: Implement task polling and runner launching (#45) -# Currently a no-op loop for future expansion. +# Polls the ops repo for approved actions and launches task-runner containers. +# Part of #24. +# +# Action JSON schema: +# { +# "id": "publish-skill-20260328", +# "formula": "clawhub-publish", +# "secrets": ["CLAWHUB_TOKEN"], +# "tools": ["clawhub"], +# "context": "SKILL.md bumped to 0.3.0", +# "model": "sonnet" +# } set -euo pipefail -while true; do - # Placeholder: no-op loop, no logic yet - sleep 60 -done +# Resolve script root (parent of lib/) +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Source shared environment +source "${SCRIPT_ROOT}/../lib/env.sh" + +# Load vault secrets after env.sh (env.sh unsets them for agent security) +# Vault secrets must be available to the dispatcher +if [ -f "$FACTORY_ROOT/.env.vault.enc" ] && command -v sops &>/dev/null; then + set -a + eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.vault.enc" 2>/dev/null)" \ + || echo "Warning: failed to decrypt .env.vault.enc — vault secrets not loaded" >&2 + set +a +elif [ -f "$FACTORY_ROOT/.env.vault" ]; then + set -a + # shellcheck source=/dev/null + source "$FACTORY_ROOT/.env.vault" + set +a +fi + +# Ops repo location (vault/actions directory) +OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/debian/disinto-ops}" +VAULT_ACTIONS_DIR="${OPS_REPO_ROOT}/vault/actions" + +# Log function +log() { + printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" +} + +# Clone or pull the ops repo +ensure_ops_repo() { + if [ ! -d "${OPS_REPO_ROOT}/.git" ]; then + log "Cloning ops repo from ${FORGE_URL}/${FORGE_OPS_REPO}..." + git clone "${FORGE_URL}/${FORGE_OPS_REPO}" "${OPS_REPO_ROOT}" + else + log "Pulling latest ops repo changes..." + (cd "${OPS_REPO_ROOT}" && git pull --rebase) + fi +} + +# Check if an action has already been completed +is_action_completed() { + local id="$1" + [ -f "${VAULT_ACTIONS_DIR}/${id}.result.json" ] +} + +# Launch a runner for the given action ID +launch_runner() { + local id="$1" + log "Launching runner for action: ${id}" + + # Read action config + local action_file="${VAULT_ACTIONS_DIR}/${id}.json" + if [ ! -f "$action_file" ]; then + log "ERROR: Action file not found: ${action_file}" + return 1 + fi + + # Extract formula from action JSON + local formula + formula=$(jq -r '.formula // empty' "$action_file") + if [ -z "$formula" ]; then + log "ERROR: Action ${id} missing 'formula' field" + return 1 + fi + + # Extract secrets (array for safe handling) + local -a secrets=() + while IFS= read -r secret; do + [ -n "$secret" ] && secrets+=("$secret") + done < <(jq -r '.secrets[]? // empty' "$action_file" 2>/dev/null) + + # Build command array (safe from shell injection) + local -a cmd=(docker compose run --rm runner) + + # Add environment variables BEFORE service name + for secret in "${secrets[@]+"${secrets[@]}"}"; do + cmd+=(-e "${secret}") # Pass actual value to container (from env) + done + + # Add formula and id as arguments (after service name) + cmd+=("$formula" "$id") + + # Log command skeleton (hide all -e flags for security) + local -a log_cmd=() + local skip_next=0 + for arg in "${cmd[@]}"; do + if [[ $skip_next -eq 1 ]]; then + skip_next=0 + continue + fi + if [[ "$arg" == "-e" ]]; then + log_cmd+=("$arg" "") + skip_next=1 + else + log_cmd+=("$arg") + fi + done + log "Running: ${log_cmd[*]}" + + # Execute with array expansion (safe from shell injection) + "${cmd[@]}" + + log "Runner completed for action: ${id}" +} + +# Main dispatcher loop +main() { + log "Starting dispatcher..." + log "Polling ops repo: ${VAULT_ACTIONS_DIR}" + + while true; do + # Refresh ops repo at the start of each poll cycle + ensure_ops_repo + + # Check if actions directory exists + if [ ! -d "${VAULT_ACTIONS_DIR}" ]; then + log "Actions directory not found: ${VAULT_ACTIONS_DIR}" + sleep 60 + continue + fi + + # Process each action file + for action_file in "${VAULT_ACTIONS_DIR}"/*.json; do + # Handle case where no .json files exist + [ -e "$action_file" ] || continue + + local id + id=$(basename "$action_file" .json) + + # Skip if already completed + if is_action_completed "$id"; then + continue + fi + + # Launch runner for this action + launch_runner "$id" + done + + # Wait before next poll + sleep 60 + done +} + +# Run main +main "$@"