fix: chore: tear down old vault scripts — prepare for PR-based vault (#73)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
Agent 2026-03-31 20:38:05 +00:00
parent bfce7a9a06
commit aad21dc084
19 changed files with 31 additions and 907 deletions

View file

View file

@ -1,45 +0,0 @@
<!-- last-reviewed: f32707ba659de278a3af434e3549fb8a8dce9d3a -->
# Vault Agent
**Role**: Three-pipeline gate — action safety classification, resource procurement, and human-action drafting.
**Pipeline A — Action Gating (*.json)**: Actions enter a pending queue and are
classified by Claude via `vault-agent.sh`, which can auto-approve (call
`vault-fire.sh` directly), auto-reject (call `vault-reject.sh`), or escalate
to a human by writing `PHASE:escalate` to a phase file — using the same
unified escalation path as dev/action agents.
**Pipeline B — Procurement (*.md)**: The planner files resource requests as
markdown files in `$OPS_REPO_ROOT/vault/pending/`. `vault-poll.sh` notifies the human via
vault/forge. The human fulfills the request (creates accounts, provisions infra,
adds secrets to `.env`) and moves the file to `$OPS_REPO_ROOT/vault/approved/`.
`vault-fire.sh` then extracts the proposed entry and appends it to
`$OPS_REPO_ROOT/RESOURCES.md`.
**Pipeline C — Rent-a-Human (outreach drafts)**: Any agent can dispatch the
`run-rent-a-human` formula (via an `action` issue) when a task requires a human
touch — posting on Reddit, commenting on HN, signing up for a service, etc.
Claude drafts copy-paste-ready content to `vault/outreach/{platform}/drafts/`
and notifies the human via vault/forge for one-click execution. No vault approval
needed — the human reviews and publishes directly.
**Trigger**: `vault-poll.sh` runs every 30 min via cron.
**Key files**:
- `vault/vault-poll.sh` — Processes pending items: retry approved, auto-reject after 48h timeout, invoke vault-agent for JSON actions, notify human for procurement requests
- `vault/vault-agent.sh` — Classifies and routes pending JSON actions via `claude -p`: auto-approve, auto-reject, or escalate to human
- `vault/vault-env.sh` — Shared env setup for vault sub-scripts: sources `lib/env.sh`, overrides `FORGE_TOKEN` with `FORGE_VAULT_TOKEN`, sets `VAULT_TOKEN` for runner container
- `formulas/run-vault.toml` — Source-of-truth formula for the vault agent's classification and routing logic
- `vault/vault-fire.sh` — Executes an approved action (JSON) in an **ephemeral Docker container** with vault-only secrets injected (GITHUB_TOKEN, CLAWHUB_TOKEN — never exposed to agents). For deployment actions, calls `lib/ci-helpers.sh:ci_promote()` to gate production promotes via Woodpecker environments. Writes `$OPS_REPO_ROOT/RESOURCES.md` entry for procurement MD approvals.
- `vault/vault-reject.sh` — Marks a JSON action as rejected
- `formulas/run-rent-a-human.toml` — Formula for human-action drafts: Claude researches target platform norms, drafts copy-paste content, writes to `vault/outreach/{platform}/drafts/`, notifies human via vault/forge
**Procurement flow** (all vault items live in `$OPS_REPO_ROOT/vault/`):
1. Planner drops `$OPS_REPO_ROOT/vault/pending/<name>.md` with what/why/proposed RESOURCES.md entry
2. `vault-poll.sh` notifies human via vault/forge
3. Human fulfills: creates account, adds secrets to `.env`, moves file to `approved/`
4. `vault-fire.sh` extracts proposed entry, appends to `$OPS_REPO_ROOT/RESOURCES.md`, moves to `fired/`
5. Next planner run reads RESOURCES.md → new capability available → unblocks prerequisite tree
**Environment variables consumed**:
- All from `lib/env.sh`

View file

@ -1,137 +0,0 @@
#!/usr/bin/env bash
# run-action.sh — Execute an action inside the ephemeral runner container
#
# This script is the entrypoint for the runner container. It runs with
# vault secrets injected as environment variables (GITHUB_TOKEN, CLAWHUB_TOKEN,
# deploy keys, etc.) and dispatches to the appropriate action handler.
#
# The runner container is ephemeral: it starts, runs the action, and is
# destroyed. Secrets exist only in container memory, never on disk.
#
# Usage: run-action.sh <action-id>
set -euo pipefail
VAULT_SCRIPT_DIR="${DISINTO_VAULT_DIR:-/home/agent/disinto/vault}"
OPS_VAULT_DIR="${DISINTO_OPS_VAULT_DIR:-${VAULT_SCRIPT_DIR}}"
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
ACTION_ID="${1:?Usage: run-action.sh <action-id>}"
log() {
printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" 2>/dev/null || \
printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
}
# Find action file in approved/
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
if [ ! -f "$ACTION_FILE" ]; then
log "ERROR: action file not found: ${ACTION_FILE}"
echo "ERROR: action file not found: ${ACTION_FILE}" >&2
exit 1
fi
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
PAYLOAD=$(jq -c '.payload // {}' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: ${ACTION_ID} has no type field"
exit 1
fi
log "${ACTION_ID}: executing type=${ACTION_TYPE} source=${ACTION_SOURCE}"
FIRE_EXIT=0
case "$ACTION_TYPE" in
webhook-call)
# HTTP call to endpoint with optional method/headers/body
ENDPOINT=$(echo "$PAYLOAD" | jq -r '.endpoint // ""')
METHOD=$(echo "$PAYLOAD" | jq -r '.method // "POST"')
REQ_BODY=$(echo "$PAYLOAD" | jq -r '.body // ""')
if [ -z "$ENDPOINT" ]; then
log "ERROR: ${ACTION_ID} webhook-call missing endpoint"
exit 1
fi
CURL_ARGS=(-sf -X "$METHOD" -o /dev/null -w "%{http_code}")
while IFS= read -r header; do
[ -n "$header" ] && CURL_ARGS+=(-H "$header")
done < <(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "\(.key): \(.value)"' 2>/dev/null || true)
if [ -n "$REQ_BODY" ] && [ "$REQ_BODY" != "null" ]; then
CURL_ARGS+=(-d "$REQ_BODY")
fi
HTTP_CODE=$(curl "${CURL_ARGS[@]}" "$ENDPOINT" 2>/dev/null) || HTTP_CODE="000"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
log "${ACTION_ID}: webhook-call -> HTTP ${HTTP_CODE} OK"
else
log "ERROR: ${ACTION_ID} webhook-call -> HTTP ${HTTP_CODE}"
FIRE_EXIT=1
fi
;;
promote)
# Promote a Woodpecker pipeline to a deployment environment (staging/production).
# Payload: {"repo_id": N, "pipeline": N, "environment": "staging"|"production"}
PROMOTE_REPO_ID=$(echo "$PAYLOAD" | jq -r '.repo_id // ""')
PROMOTE_PIPELINE=$(echo "$PAYLOAD" | jq -r '.pipeline // ""')
PROMOTE_ENV=$(echo "$PAYLOAD" | jq -r '.environment // ""')
if [ -z "$PROMOTE_REPO_ID" ] || [ -z "$PROMOTE_PIPELINE" ] || [ -z "$PROMOTE_ENV" ]; then
log "ERROR: ${ACTION_ID} promote missing repo_id, pipeline, or environment"
FIRE_EXIT=1
else
# Validate environment is staging or production
case "$PROMOTE_ENV" in
staging|production) ;;
*)
log "ERROR: ${ACTION_ID} promote invalid environment '${PROMOTE_ENV}' (must be staging or production)"
FIRE_EXIT=1
;;
esac
if [ "$FIRE_EXIT" -eq 0 ]; then
WP_SERVER="${WOODPECKER_SERVER:-http://woodpecker:8000}"
WP_TOKEN="${WOODPECKER_TOKEN:-}"
if [ -z "$WP_TOKEN" ]; then
log "ERROR: ${ACTION_ID} promote requires WOODPECKER_TOKEN"
FIRE_EXIT=1
else
PROMOTE_RESP=$(curl -sf -X POST \
-H "Authorization: Bearer ${WP_TOKEN}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "event=deployment&deploy_to=${PROMOTE_ENV}" \
"${WP_SERVER}/api/repos/${PROMOTE_REPO_ID}/pipelines/${PROMOTE_PIPELINE}" 2>/dev/null) || PROMOTE_RESP=""
NEW_PIPELINE=$(printf '%s' "$PROMOTE_RESP" | jq -r '.number // empty' 2>/dev/null)
if [ -n "$NEW_PIPELINE" ]; then
log "${ACTION_ID}: promoted pipeline ${PROMOTE_PIPELINE} to ${PROMOTE_ENV} -> new pipeline #${NEW_PIPELINE}"
else
log "ERROR: ${ACTION_ID} promote API failed (repo_id=${PROMOTE_REPO_ID} pipeline=${PROMOTE_PIPELINE} env=${PROMOTE_ENV})"
FIRE_EXIT=1
fi
fi
fi
fi
;;
blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge)
HANDLER="${VAULT_SCRIPT_DIR}/handlers/${ACTION_TYPE}.sh"
if [ -x "$HANDLER" ]; then
bash "$HANDLER" "$ACTION_ID" "$PAYLOAD" 2>&1 || FIRE_EXIT=$?
else
log "ERROR: ${ACTION_ID} no handler for type '${ACTION_TYPE}' (${HANDLER} not found)"
FIRE_EXIT=1
fi
;;
*)
log "ERROR: ${ACTION_ID} unknown action type '${ACTION_TYPE}'"
FIRE_EXIT=1
;;
esac
exit "$FIRE_EXIT"

View file

@ -1,97 +0,0 @@
#!/usr/bin/env bash
# vault-agent.sh — Invoke claude -p to classify and route pending vault actions
#
# Called by vault-poll.sh when pending actions exist. Reads all pending/*.json,
# builds a prompt with action summaries, and lets the LLM decide routing.
#
# The LLM can call vault-fire.sh (auto-approve) or vault-reject.sh (reject)
# directly. For escalations, it writes a PHASE:escalate file and marks the
# action as "escalated" in pending/ so vault-poll skips it on future runs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
PROMPT_FILE="${FACTORY_ROOT}/formulas/run-vault.toml"
LOGFILE="${VAULT_SCRIPT_DIR}/vault.log"
CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-3600}"
log() {
printf '[%s] vault-agent: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# Collect all pending actions (skip already-escalated)
ACTIONS_BATCH=""
ACTION_COUNT=0
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
[ "$ACTION_STATUS" = "escalated" ] && continue
# Validate JSON
if ! jq empty < "$action_file" 2>/dev/null; then
ACTION_ID=$(basename "$action_file" .json)
log "malformed JSON: $action_file — rejecting"
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "malformed JSON" 2>/dev/null || true
continue
fi
ACTION_JSON=$(cat "$action_file")
ACTIONS_BATCH="${ACTIONS_BATCH}
--- ACTION ---
$(echo "$ACTION_JSON" | jq '.')
--- END ACTION ---
"
ACTION_COUNT=$((ACTION_COUNT + 1))
done
if [ "$ACTION_COUNT" -eq 0 ]; then
log "no actionable pending items"
exit 0
fi
log "processing $ACTION_COUNT pending action(s) via claude -p"
# Build the prompt
SYSTEM_PROMPT=$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a vault agent. Classify and route actions.")
PROMPT="${SYSTEM_PROMPT}
## Pending Actions (${ACTION_COUNT} total)
${ACTIONS_BATCH}
## Environment
- FACTORY_ROOT=${FACTORY_ROOT}
- OPS_REPO_ROOT=${OPS_REPO_ROOT}
- Vault data: ${OPS_VAULT_DIR}
- vault-fire.sh: bash ${VAULT_SCRIPT_DIR}/vault-fire.sh <action-id>
- vault-reject.sh: bash ${VAULT_SCRIPT_DIR}/vault-reject.sh <action-id> \"<reason>\"
Process each action now. For auto-approve, fire immediately. For reject, call vault-reject.sh.
For actions that need human approval (escalate), write a PHASE:escalate file
to signal the unified escalation path:
printf 'PHASE:escalate\nReason: vault procurement — %s\n' '<action summary>' \\
> /tmp/vault-escalate-<action-id>.phase
Then STOP and wait — a human will review via the forge."
CLAUDE_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PROMPT" \
--model sonnet \
--dangerously-skip-permissions \
--max-turns 20 \
2>/dev/null) || true
log "claude finished ($(echo "$CLAUDE_OUTPUT" | wc -c) bytes)"
# Log routing decisions
ROUTES=$(echo "$CLAUDE_OUTPUT" | grep "^ROUTE:" || true)
if [ -n "$ROUTES" ]; then
echo "$ROUTES" | while read -r line; do
log " $line"
done
fi

View file

@ -8,5 +8,5 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/env.sh"
# Use vault-bot's own Forgejo identity
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
# Set entrypoint for runner container
export VAULT_RUNNER_ENTRYPOINT="run-action.sh"
# Vault redesign in progress (PR-based approval workflow)
# This file is kept for shared env setup; scripts being replaced by #73

View file

@ -1,141 +0,0 @@
#!/usr/bin/env bash
# vault-fire.sh — Execute an approved vault item by ID
#
# Handles two pipelines:
# A. Action gating (*.json): pending/ → approved/ → fired/
# Execution delegated to ephemeral runner container via disinto run.
# The runner gets vault secrets (.env.vault.enc); this script does NOT.
# B. Procurement (*.md): approved/ → fired/ (writes RESOURCES.md entry)
#
# If item is in pending/, moves to approved/ first.
# If item is already in approved/, fires directly (crash recovery).
#
# Usage: bash vault-fire.sh <item-id>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
RESOURCES_FILE="${OPS_REPO_ROOT}/RESOURCES.md"
log() {
printf '[%s] vault-fire: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
ACTION_ID="${1:?Usage: vault-fire.sh <item-id>}"
# =============================================================================
# Detect pipeline: procurement (.md) or action gating (.json)
# =============================================================================
IS_PROCUREMENT=false
ACTION_FILE=""
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md" ]; then
IS_PROCUREMENT=true
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" ]; then
IS_PROCUREMENT=true
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.md" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.md"
log "$ACTION_ID: pending → approved (procurement)"
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
elif [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
mv "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
TMP=$(mktemp)
jq '.status = "approved"' "$ACTION_FILE" > "$TMP" && mv "$TMP" "$ACTION_FILE"
log "$ACTION_ID: pending → approved"
else
log "ERROR: item $ACTION_ID not found in pending/ or approved/"
exit 1
fi
# Acquire lock
mkdir -p "$LOCKS_DIR"
LOCKFILE="${LOCKS_DIR}/${ACTION_ID}.lock"
if [ -f "$LOCKFILE" ]; then
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || true)
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
log "$ACTION_ID: already being fired by PID $LOCK_PID"
exit 0
fi
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# =============================================================================
# Pipeline A: Procurement — extract RESOURCES.md entry and append
# =============================================================================
if [ "$IS_PROCUREMENT" = true ]; then
log "$ACTION_ID: firing procurement request"
# Extract the proposed RESOURCES.md entry from the markdown file.
# Everything after the "## Proposed RESOURCES.md Entry" heading to EOF.
# Uses awk because the entry itself contains ## headings (## <resource-id>).
ENTRY=""
ENTRY=$(awk '/^## Proposed RESOURCES\.md Entry/{found=1; next} found{print}' "$ACTION_FILE" 2>/dev/null || true)
# Strip leading/trailing blank lines and markdown code fences
ENTRY=$(echo "$ENTRY" | sed '/^```/d' | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba;}')
if [ -z "$ENTRY" ]; then
log "ERROR: $ACTION_ID has no '## Proposed RESOURCES.md Entry' section"
exit 1
fi
# Append entry to RESOURCES.md
printf '\n%s\n' "$ENTRY" >> "$RESOURCES_FILE"
log "$ACTION_ID: wrote RESOURCES.md entry"
# Move to fired/
mv "$ACTION_FILE" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.md"
rm -f "${LOCKS_DIR}/${ACTION_ID}.notified"
log "$ACTION_ID: approved → fired (procurement)"
exit 0
fi
# =============================================================================
# Pipeline B: Action gating — delegate to ephemeral runner container
# =============================================================================
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: $ACTION_ID has no type field"
exit 1
fi
log "$ACTION_ID: firing type=$ACTION_TYPE source=$ACTION_SOURCE via runner"
FIRE_EXIT=0
# Delegate execution to the ephemeral runner container.
# The runner gets vault secrets (.env.vault.enc) injected at runtime;
# this host process never sees those secrets.
if [ -f "${FACTORY_ROOT}/.env.vault.enc" ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then
bash "${FACTORY_ROOT}/bin/disinto" run "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
else
# Fallback for bare-metal or pre-migration setups: run action handler directly
log "$ACTION_ID: no .env.vault.enc or docker-compose.yml — running action directly"
bash "${SCRIPT_DIR}/run-action.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
fi
# =============================================================================
# Move to fired/ or leave in approved/ on failure
# =============================================================================
if [ "$FIRE_EXIT" -eq 0 ]; then
# Update with fired timestamp and move to fired/
TMP=$(mktemp)
jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "fired" | .fired_at = $ts' "$ACTION_FILE" > "$TMP" \
&& mv "$TMP" "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json"
rm -f "$ACTION_FILE"
log "$ACTION_ID: approved → fired"
else
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
exit "$FIRE_EXIT"
fi

View file

@ -1,301 +0,0 @@
#!/usr/bin/env bash
# vault-poll.sh — Vault: process pending actions + procurement requests
#
# Runs every 30min via cron. Two pipelines:
# A. Action gating (*.json): auto-approve/escalate/reject via vault-agent.sh
# B. Procurement (*.md): notify human, fire approved requests via vault-fire.sh
#
# Phases:
# 1. Retry any approved/ items that weren't fired (crash recovery)
# 2. Auto-reject escalations with no reply for 48h
# 3. Invoke vault-agent.sh for new pending JSON actions
# 4. Notify human about new pending procurement requests (.md)
#
# Cron: */30 * * * * /path/to/disinto/vault/vault-poll.sh
#
# Peek: cat /tmp/vault-status
# Log: tail -f /path/to/disinto/vault/vault.log
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/../lib/env.sh"
# Use vault-bot's own Forgejo identity (#747)
FORGE_TOKEN="${FORGE_VAULT_TOKEN:-${FORGE_TOKEN}}"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
STATUSFILE="/tmp/vault-status"
LOCKFILE="/tmp/vault-poll.lock"
VAULT_SCRIPT_DIR="${FACTORY_ROOT}/vault"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
TIMEOUT_HOURS=48
# Prevent overlapping runs
if [ -f "$LOCKFILE" ]; then
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null)
if kill -0 "$LOCK_PID" 2>/dev/null; then
exit 0
fi
rm -f "$LOCKFILE"
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE" "$STATUSFILE"' EXIT
log() {
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
status() {
printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" > "$STATUSFILE"
log "$*"
}
# Acquire per-action lock (returns 0 if acquired, 1 if already locked)
lock_action() {
local action_id="$1"
local lockfile="${LOCKS_DIR}/${action_id}.lock"
mkdir -p "$LOCKS_DIR"
if [ -f "$lockfile" ]; then
local lock_pid
lock_pid=$(cat "$lockfile" 2>/dev/null || true)
if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
return 1
fi
rm -f "$lockfile"
fi
echo $$ > "$lockfile"
return 0
}
unlock_action() {
local action_id="$1"
rm -f "${LOCKS_DIR}/${action_id}.lock"
}
# =============================================================================
# PHASE 1: Retry approved items (crash recovery — JSON actions + MD procurement)
# =============================================================================
status "phase 1: retrying approved items"
for action_file in "${OPS_VAULT_DIR}/approved/"*.json; do
[ -f "$action_file" ] || continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
[ -z "$ACTION_ID" ] && continue
if ! lock_action "$ACTION_ID"; then
log "skip $ACTION_ID — locked by another process"
continue
fi
log "retrying approved action: $ACTION_ID"
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
log "fired $ACTION_ID (retry)"
else
log "ERROR: fire failed for $ACTION_ID (retry)"
fi
unlock_action "$ACTION_ID"
done
# Retry approved procurement requests (.md)
for req_file in "${OPS_VAULT_DIR}/approved/"*.md; do
[ -f "$req_file" ] || continue
REQ_ID=$(basename "$req_file" .md)
if ! lock_action "$REQ_ID"; then
log "skip procurement $REQ_ID — locked by another process"
continue
fi
log "retrying approved procurement: $REQ_ID"
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$REQ_ID" >> "$LOGFILE" 2>&1; then
log "fired procurement $REQ_ID (retry)"
else
log "ERROR: fire failed for procurement $REQ_ID (retry)"
fi
unlock_action "$REQ_ID"
done
# =============================================================================
# PHASE 2: Timeout escalations (48h no reply → auto-reject)
# =============================================================================
status "phase 2: checking escalation timeouts"
NOW_EPOCH=$(date +%s)
TIMEOUT_SECS=$((TIMEOUT_HOURS * 3600))
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
[ "$ACTION_STATUS" != "escalated" ] && continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
ESCALATED_AT=$(jq -r '.escalated_at // ""' < "$action_file" 2>/dev/null)
[ -z "$ESCALATED_AT" ] && continue
ESCALATED_EPOCH=$(date -d "$ESCALATED_AT" +%s 2>/dev/null || echo 0)
AGE_SECS=$((NOW_EPOCH - ESCALATED_EPOCH))
if [ "$AGE_SECS" -gt "$TIMEOUT_SECS" ]; then
AGE_HOURS=$((AGE_SECS / 3600))
log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting"
bash "${VAULT_SCRIPT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true
fi
done
# =============================================================================
# PHASE 3: Process new pending actions (JSON — action gating)
# =============================================================================
status "phase 3: processing pending actions"
PENDING_COUNT=0
PENDING_SUMMARY=""
for action_file in "${OPS_VAULT_DIR}/pending/"*.json; do
[ -f "$action_file" ] || continue
ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null)
# Skip already-escalated actions (waiting for human reply)
[ "$ACTION_STATUS" = "escalated" ] && continue
ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null)
[ -z "$ACTION_ID" ] && continue
if ! lock_action "$ACTION_ID"; then
log "skip $ACTION_ID — locked"
continue
fi
PENDING_COUNT=$((PENDING_COUNT + 1))
ACTION_TYPE=$(jq -r '.type // "unknown"' < "$action_file" 2>/dev/null)
ACTION_SOURCE=$(jq -r '.source // "unknown"' < "$action_file" 2>/dev/null)
PENDING_SUMMARY="${PENDING_SUMMARY} ${ACTION_ID} [${ACTION_TYPE}] from ${ACTION_SOURCE}\n"
unlock_action "$ACTION_ID"
done
if [ "$PENDING_COUNT" -gt 0 ]; then
log "found $PENDING_COUNT pending action(s), invoking vault-agent"
status "invoking vault-agent for $PENDING_COUNT action(s)"
bash "${VAULT_SCRIPT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || {
log "ERROR: vault-agent failed"
}
fi
# =============================================================================
# PHASE 4: Notify human about new pending procurement requests (.md)
# =============================================================================
status "phase 4: processing pending procurement requests"
PROCURE_COUNT=0
for req_file in "${OPS_VAULT_DIR}/pending/"*.md; do
[ -f "$req_file" ] || continue
REQ_ID=$(basename "$req_file" .md)
# Check if already notified (marker file)
if [ -f "${LOCKS_DIR}/${REQ_ID}.notified" ]; then
continue
fi
if ! lock_action "$REQ_ID"; then
log "skip procurement $REQ_ID — locked"
continue
fi
PROCURE_COUNT=$((PROCURE_COUNT + 1))
# Extract title from first heading
REQ_TITLE=$(grep -m1 '^# ' "$req_file" | sed 's/^# //' || echo "$REQ_ID")
log "new procurement request: $REQ_ID$REQ_TITLE"
# Mark as notified so we don't re-send
mkdir -p "${LOCKS_DIR}"
touch "${LOCKS_DIR}/${REQ_ID}.notified"
unlock_action "$REQ_ID"
done
# =============================================================================
# PHASE 5: Detect vault-bot authorized comments on issues
# =============================================================================
status "phase 5: scanning for vault-bot authorized comments"
COMMENT_COUNT=0
if [ -n "${FORGE_REPO:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
# Get open issues with action label
ACTION_ISSUES=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues?state=open&labels=action&limit=50" 2>/dev/null) || ACTION_ISSUES="[]"
ISSUE_COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length')
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$idx].number")
# Skip if already processed
if [ -f "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired" ]; then
continue
fi
# Get comments on this issue
COMMENTS=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues/${ISSUE_NUM}/comments?limit=50" 2>/dev/null) || continue
# Look for vault-bot comments containing VAULT:APPROVED with a JSON action spec
APPROVED_BODY=$(printf '%s' "$COMMENTS" | jq -r '
[.[] | select(.user.login == "vault-bot") | select(.body | test("VAULT:APPROVED"))] | last | .body // empty
' 2>/dev/null) || continue
[ -z "$APPROVED_BODY" ] && continue
# Extract JSON action spec from fenced code block in the comment
ACTION_JSON=$(printf '%s' "$APPROVED_BODY" | sed -n '/^```json$/,/^```$/p' | sed '1d;$d')
[ -z "$ACTION_JSON" ] && continue
# Validate JSON
if ! printf '%s' "$ACTION_JSON" | jq empty 2>/dev/null; then
log "malformed action JSON in vault-bot comment on issue #${ISSUE_NUM}"
continue
fi
ACTION_ID=$(printf '%s' "$ACTION_JSON" | jq -r '.id // empty')
if [ -z "$ACTION_ID" ]; then
ACTION_ID="issue-${ISSUE_NUM}-$(date +%s)"
ACTION_JSON=$(printf '%s' "$ACTION_JSON" | jq --arg id "$ACTION_ID" '.id = $id')
fi
# Skip if this action already exists in any stage
if [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ] || \
[ -f "${OPS_VAULT_DIR}/fired/${ACTION_ID}.json" ] || \
[ -f "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json" ]; then
continue
fi
log "vault-bot authorized action on issue #${ISSUE_NUM}: ${ACTION_ID}"
printf '%s' "$ACTION_JSON" | jq '.status = "approved"' > "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
COMMENT_COUNT=$((COMMENT_COUNT + 1))
# Fire the action
if bash "${VAULT_SCRIPT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
log "fired ${ACTION_ID} from issue #${ISSUE_NUM}"
# Mark issue as processed
touch "${LOCKS_DIR}/issue-${ISSUE_NUM}.vault-fired"
else
log "ERROR: fire failed for ${ACTION_ID} from issue #${ISSUE_NUM}"
fi
done
fi
if [ "$PENDING_COUNT" -eq 0 ] && [ "$PROCURE_COUNT" -eq 0 ] && [ "$COMMENT_COUNT" -eq 0 ]; then
status "all clear — no pending items"
else
status "poll complete — ${PENDING_COUNT} action(s), ${PROCURE_COUNT} procurement(s), ${COMMENT_COUNT} comment-authorized"
fi

View file

@ -1,43 +0,0 @@
#!/usr/bin/env bash
# vault-reject.sh — Move a vault action to rejected/ with reason
#
# Usage: bash vault-reject.sh <action-id> "<reason>"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/vault-env.sh"
OPS_VAULT_DIR="${OPS_REPO_ROOT}/vault"
LOGFILE="${DISINTO_LOG_DIR}/vault/vault.log"
LOCKS_DIR="${DISINTO_LOG_DIR}/vault/.locks"
log() {
printf '[%s] vault-reject: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
ACTION_ID="${1:?Usage: vault-reject.sh <action-id> \"<reason>\"}"
REASON="${2:-unspecified}"
# Find the action file
ACTION_FILE=""
if [ -f "${OPS_VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/pending/${ACTION_ID}.json"
elif [ -f "${OPS_VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
ACTION_FILE="${OPS_VAULT_DIR}/approved/${ACTION_ID}.json"
else
log "ERROR: action $ACTION_ID not found in pending/ or approved/"
exit 1
fi
# Update with rejection metadata and move to rejected/
TMP=$(mktemp)
jq --arg reason "$REASON" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.status = "rejected" | .rejected_at = $ts | .reject_reason = $reason' \
"$ACTION_FILE" > "$TMP" && mv "$TMP" "${OPS_VAULT_DIR}/rejected/${ACTION_ID}.json"
rm -f "$ACTION_FILE"
# Clean up lock if present
rm -f "${LOCKS_DIR}/${ACTION_ID}.lock"
log "$ACTION_ID: rejected — $REASON"