Spaces:
Paused
Paused
| set -eu | |
| log() { | |
| printf '%s\n' "[workspace-spaces-sync] $*" | |
| } | |
| fail() { | |
| log "ERROR: $*" >&2 | |
| exit 1 | |
| } | |
| DATA_ROOT="${DATA_ROOT:-/data}" | |
| S3CMD_BIN="${S3CMD_BIN:-s3cmd}" | |
| SPACES_BUCKET="${SPACES_BUCKET-}" | |
| SPACES_ENDPOINT="${SPACES_ENDPOINT-}" | |
| SPACES_SYNC_INTERVAL_SECONDS="${SPACES_SYNC_INTERVAL_SECONDS:-60}" | |
| # An unset bucket disables synchronization on platforms that provide their own | |
| # persistent volume, including HF Spaces and suitably configured Cloud Run. | |
| if [ -z "${SPACES_BUCKET}" ]; then | |
| exit 0 | |
| fi | |
| [ -n "${SPACES_ENDPOINT}" ] || fail "SPACES_ENDPOINT is required" | |
| [ -n "${AWS_ACCESS_KEY_ID-}" ] || fail "AWS_ACCESS_KEY_ID is required" | |
| [ -n "${AWS_SECRET_ACCESS_KEY-}" ] || fail "AWS_SECRET_ACCESS_KEY is required" | |
| command -v "${S3CMD_BIN}" >/dev/null 2>&1 || fail "s3cmd is not installed" | |
| endpoint="${SPACES_ENDPOINT#https://}" | |
| endpoint="${endpoint#http://}" | |
| endpoint="${endpoint%/}" | |
| case "${endpoint}" in | |
| ''|*[!A-Za-z0-9.-]*) fail "SPACES_ENDPOINT must be a regional hostname" ;; | |
| esac | |
| case "${SPACES_BUCKET}" in | |
| *[!a-z0-9.-]*|'') fail "SPACES_BUCKET contains unsupported characters" ;; | |
| esac | |
| case "${SPACES_SYNC_INTERVAL_SECONDS}" in | |
| ''|*[!0-9]*) fail "SPACES_SYNC_INTERVAL_SECONDS must be an integer" ;; | |
| esac | |
| if [ "${SPACES_SYNC_INTERVAL_SECONDS}" -lt 30 ]; then | |
| fail "SPACES_SYNC_INTERVAL_SECONDS must be at least 30" | |
| fi | |
| umask 077 | |
| config_file="$(mktemp /tmp/workspace-spaces-config.XXXXXX)" | |
| temporary_file='' | |
| cleanup() { | |
| rm -f "${config_file}" | |
| if [ -n "${temporary_file}" ]; then | |
| rm -f "${temporary_file}" | |
| fi | |
| } | |
| trap cleanup EXIT | |
| { | |
| printf '%s\n' '[default]' | |
| printf '%s\n' 'access_key =' | |
| printf '%s\n' 'secret_key =' | |
| printf '%s\n' 'bucket_location = US' | |
| printf '%s\n' "host_base = ${endpoint}" | |
| printf '%s\n' "host_bucket = %(bucket)s.${endpoint}" | |
| printf '%s\n' 'use_https = True' | |
| printf '%s\n' 'check_ssl_certificate = True' | |
| printf '%s\n' 'check_ssl_hostname = True' | |
| } > "${config_file}" | |
| s3cmd_run() { | |
| "${S3CMD_BIN}" --config="${config_file}" --no-progress "$@" | |
| } | |
| workspace_dir="${DATA_ROOT}/workspace" | |
| claude_dir="${DATA_ROOT}/claude" | |
| remote_uri="s3://${SPACES_BUCKET}/latest.tar.gz" | |
| restore_snapshot() { | |
| listing="$(s3cmd_run ls "${remote_uri}")" \ | |
| || fail "Unable to check ${remote_uri}" | |
| if [ -z "${listing}" ]; then | |
| log "No existing snapshot found in ${SPACES_BUCKET}" | |
| return 0 | |
| fi | |
| temporary_file="$(mktemp /tmp/workspace-restore.XXXXXX)" | |
| s3cmd_run get --force "${remote_uri}" "${temporary_file}" \ | |
| || fail "Unable to download ${remote_uri}" | |
| tar -tzf "${temporary_file}" >/dev/null \ | |
| || fail "Downloaded workspace snapshot is not a valid gzip archive" | |
| invalid_member="$( | |
| tar -tzf "${temporary_file}" \ | |
| | awk ' | |
| /(^|\/)\.\.($|\/)/ { | |
| exit | |
| } | |
| $0 == "workspace" || $0 ~ /^workspace\// { | |
| next | |
| } | |
| $0 == "claude/projects" || $0 ~ /^claude\/projects\// { | |
| next | |
| } | |
| $0 == "claude/history.jsonl" { | |
| next | |
| } | |
| { | |
| exit | |
| } | |
| ' | |
| )" | |
| [ -z "${invalid_member}" ] \ | |
| || fail "Workspace snapshot contains an invalid path: ${invalid_member}" | |
| tar --touch -C "${DATA_ROOT}" -xzf "${temporary_file}" | |
| rm -f "${temporary_file}" | |
| temporary_file='' | |
| log "Restored candidate snapshot from ${SPACES_BUCKET}" | |
| } | |
| upload_snapshot() { | |
| [ -d "${workspace_dir}" ] || return 0 | |
| temporary_file="$(mktemp /tmp/workspace-snapshot.XXXXXX)" | |
| # Preserve the candidate's code and the minimum Claude Code state needed to | |
| # inspect/resume conversations. Do not archive Claude credentials, settings, | |
| # plugins, caches, or other reusable user configuration. | |
| set -- workspace | |
| if [ -d "${claude_dir}/projects" ]; then | |
| set -- "$@" claude/projects | |
| fi | |
| if [ -f "${claude_dir}/history.jsonl" ]; then | |
| set -- "$@" claude/history.jsonl | |
| fi | |
| if ! tar -C "${DATA_ROOT}" -czf "${temporary_file}" \ | |
| --exclude='workspace/.venv' \ | |
| --exclude='workspace/__pycache__' \ | |
| --exclude='workspace/.pytest_cache' \ | |
| --exclude='workspace/.mypy_cache' \ | |
| --exclude='workspace/.ruff_cache' \ | |
| --exclude='workspace/.env' \ | |
| --exclude='workspace/.env.*' \ | |
| --exclude='workspace/.claude' \ | |
| --exclude='workspace/.claude.json' \ | |
| --exclude='workspace/.openrouter' \ | |
| --exclude='workspace/openrouter.env' \ | |
| --exclude='workspace/.git/index.lock' \ | |
| "$@" | |
| then | |
| log "WARNING: Workspace changed during snapshot; retrying later" >&2 | |
| rm -f "${temporary_file}" | |
| temporary_file='' | |
| return 1 | |
| fi | |
| if ! s3cmd_run put "${temporary_file}" "${remote_uri}"; then | |
| log "WARNING: Unable to upload workspace snapshot; retrying later" >&2 | |
| rm -f "${temporary_file}" | |
| temporary_file='' | |
| return 1 | |
| fi | |
| rm -f "${temporary_file}" | |
| temporary_file='' | |
| log "Uploaded candidate workspace and Claude transcript snapshot to ${SPACES_BUCKET}" | |
| } | |
| watch_snapshots() { | |
| stop_requested=0 | |
| trap 'stop_requested=1' INT TERM HUP | |
| while [ "${stop_requested}" -eq 0 ]; do | |
| upload_snapshot || true | |
| if [ "${stop_requested}" -eq 0 ]; then | |
| sleep "${SPACES_SYNC_INTERVAL_SECONDS}" & | |
| wait "$!" || true | |
| fi | |
| done | |
| log "Shutdown requested; uploading final workspace snapshot" | |
| upload_snapshot || true | |
| } | |
| case "${1:-}" in | |
| validate) | |
| log "Spaces synchronization configured for candidate bucket ${SPACES_BUCKET}" | |
| ;; | |
| restore) | |
| restore_snapshot | |
| ;; | |
| snapshot) | |
| upload_snapshot | |
| ;; | |
| watch) | |
| watch_snapshots | |
| ;; | |
| *) | |
| fail "Usage: $0 {validate|restore|snapshot|watch}" | |
| ;; | |
| esac | |