179 lines
3.4 KiB
Bash
179 lines
3.4 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
INTERVAL_SECONDS="${INTERVAL_SECONDS:-30}"
|
|
HEARTBEAT_EVERY_SECONDS="${HEARTBEAT_EVERY_SECONDS:-60}"
|
|
ONCE="${1:-}"
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
LOCAL_STATE_DIR="$REPO_ROOT/.sync-state"
|
|
PUBLIC_STATE_DIR="$REPO_ROOT/sync-state"
|
|
HEARTBEAT_FILE="$PUBLIC_STATE_DIR/heartbeat-vps.json"
|
|
ERROR_FILE="$PUBLIC_STATE_DIR/error-vps.md"
|
|
|
|
ALLOWED_PATHS=(
|
|
"AGENT_BOARD.md"
|
|
"README.md"
|
|
"USER_STATUS.md"
|
|
".cursor/rules"
|
|
"handoff"
|
|
"evidence"
|
|
"rollback"
|
|
"tasks"
|
|
"sync"
|
|
"sync-state"
|
|
"docs"
|
|
"knowledge"
|
|
"archive"
|
|
".gitattributes"
|
|
".gitignore"
|
|
)
|
|
|
|
ensure_state_dir() {
|
|
mkdir -p "$LOCAL_STATE_DIR" "$PUBLIC_STATE_DIR"
|
|
}
|
|
|
|
write_sync_error() {
|
|
ensure_state_dir
|
|
local message="$1"
|
|
local timestamp
|
|
timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
|
|
cat > "$ERROR_FILE" <<EOF
|
|
# VPS Sync Error
|
|
|
|
- Time: $timestamp
|
|
- Repository: $REPO_ROOT
|
|
- Message: $message
|
|
|
|
Manual action required. Do not continue automatic sync until the repository is clean.
|
|
EOF
|
|
}
|
|
|
|
repo_clean_status() {
|
|
if [[ -z "$(git status --porcelain)" ]]; then
|
|
echo "clean"
|
|
else
|
|
echo "dirty"
|
|
fi
|
|
}
|
|
|
|
heartbeat_due() {
|
|
if [[ "$ONCE" == "--once" || ! -f "$HEARTBEAT_FILE" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local now last age
|
|
now="$(date +%s)"
|
|
last="$(stat -c %Y "$HEARTBEAT_FILE")"
|
|
age=$((now - last))
|
|
[[ "$age" -ge "$HEARTBEAT_EVERY_SECONDS" ]]
|
|
}
|
|
|
|
write_heartbeat() {
|
|
ensure_state_dir
|
|
if ! heartbeat_due; then
|
|
return 0
|
|
fi
|
|
|
|
local head branch status timestamp
|
|
head="$(git rev-parse --short HEAD)"
|
|
branch="$(git branch --show-current)"
|
|
status="$(repo_clean_status)"
|
|
timestamp="$(date '+%Y-%m-%dT%H:%M:%S%z')"
|
|
|
|
cat > "$HEARTBEAT_FILE" <<EOF
|
|
{
|
|
"host": "vps",
|
|
"time": "$timestamp",
|
|
"branch": "$branch",
|
|
"head": "$head",
|
|
"status": "$status",
|
|
"intervalSeconds": $INTERVAL_SECONDS,
|
|
"heartbeatEverySeconds": $HEARTBEAT_EVERY_SECONDS
|
|
}
|
|
EOF
|
|
}
|
|
|
|
has_allowed_changes() {
|
|
local line path allowed
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
path="${line:3}"
|
|
for allowed in "${ALLOWED_PATHS[@]}"; do
|
|
if [[ "$path" == "$allowed" || "$path" == "$allowed/"* ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
done < <(git status --porcelain)
|
|
return 1
|
|
}
|
|
|
|
has_conflicts() {
|
|
[[ -n "$(git diff --name-only --diff-filter=U)" ]]
|
|
}
|
|
|
|
remove_stale_sync_error() {
|
|
rm -f "$ERROR_FILE"
|
|
}
|
|
|
|
push_with_retry() {
|
|
if git push; then
|
|
return 0
|
|
fi
|
|
|
|
# Another agent may have pushed between our pull and push. Rebase once and retry.
|
|
git pull --rebase --autostash
|
|
if has_conflicts; then
|
|
return 20
|
|
fi
|
|
git push
|
|
}
|
|
|
|
sync_once() {
|
|
cd "$REPO_ROOT"
|
|
ensure_state_dir
|
|
|
|
git pull --rebase --autostash
|
|
|
|
if has_conflicts; then
|
|
return 20
|
|
fi
|
|
|
|
remove_stale_sync_error
|
|
write_heartbeat
|
|
|
|
if ! has_allowed_changes; then
|
|
return 0
|
|
fi
|
|
|
|
local path
|
|
for path in "${ALLOWED_PATHS[@]}"; do
|
|
if [[ -e "$path" ]]; then
|
|
git add -- "$path"
|
|
fi
|
|
done
|
|
|
|
if [[ -z "$(git diff --cached --name-only)" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
git commit -m "Sync agent state from VPS at $(date '+%Y-%m-%d %H:%M:%S')"
|
|
push_with_retry
|
|
}
|
|
|
|
while true; do
|
|
if ! sync_once; then
|
|
exit_code="$?"
|
|
write_sync_error "sync failed with exit code $exit_code"
|
|
echo "sync failed with exit code $exit_code" >&2
|
|
exit "$exit_code"
|
|
fi
|
|
|
|
if [[ "$ONCE" == "--once" ]]; then
|
|
break
|
|
fi
|
|
|
|
sleep "$INTERVAL_SECONDS"
|
|
done
|