diff --git a/sync/sync-agent.ps1 b/sync/sync-agent.ps1 index 5299dd0..b4cc09f 100644 --- a/sync/sync-agent.ps1 +++ b/sync/sync-agent.ps1 @@ -1,13 +1,16 @@ param( [int]$IntervalSeconds = 30, + [int]$HeartbeatEverySeconds = 60, [switch]$Once ) $ErrorActionPreference = "Stop" $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$StateDir = Join-Path $RepoRoot ".sync-state" -$ErrorFile = Join-Path $StateDir "sync-error-pc.md" +$LocalStateDir = Join-Path $RepoRoot ".sync-state" +$PublicStateDir = Join-Path $RepoRoot "sync-state" +$HeartbeatFile = Join-Path $PublicStateDir "heartbeat-pc.json" +$ErrorFile = Join-Path $PublicStateDir "error-pc.md" $AllowedPaths = @( "AGENT_BOARD.md", "README.md", @@ -17,12 +20,16 @@ $AllowedPaths = @( "rollback", "tasks", "sync", + "sync-state", + ".gitattributes", ".gitignore" ) function Ensure-StateDir { - if (-not (Test-Path $StateDir)) { - New-Item -ItemType Directory -Path $StateDir | Out-Null + foreach ($path in @($LocalStateDir, $PublicStateDir)) { + if (-not (Test-Path $path)) { + New-Item -ItemType Directory -Path $path | Out-Null + } } } @@ -42,6 +49,15 @@ Manual action required. Do not continue automatic sync until the repository is c "@ | Set-Content -Path $ErrorFile -Encoding UTF8 } +function Get-GitOutput { + param([string[]]$GitArgs) + $output = & git @GitArgs + if ($LASTEXITCODE -ne 0) { + throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE" + } + return $output +} + function Invoke-Git { param([string[]]$GitArgs) & git @GitArgs @@ -79,6 +95,48 @@ function Test-ConflictMarkers { return -not [string]::IsNullOrWhiteSpace(($conflicts -join "")) } +function Get-RepoCleanStatus { + $changes = & git -C $RepoRoot status --porcelain + if ($LASTEXITCODE -ne 0) { + throw "git status failed with exit code $LASTEXITCODE" + } + if ([string]::IsNullOrWhiteSpace(($changes -join ""))) { + return "clean" + } + return "dirty" +} + +function Test-HeartbeatDue { + if ($Once -or -not (Test-Path $HeartbeatFile)) { + return $true + } + + $age = (Get-Date) - (Get-Item $HeartbeatFile).LastWriteTime + return $age.TotalSeconds -ge $HeartbeatEverySeconds +} + +function Write-Heartbeat { + Ensure-StateDir + if (-not (Test-HeartbeatDue)) { + return + } + + $head = (Get-GitOutput @("-C", $RepoRoot, "rev-parse", "--short", "HEAD")) -join "" + $branch = (Get-GitOutput @("-C", $RepoRoot, "branch", "--show-current")) -join "" + $status = Get-RepoCleanStatus + $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz" + + [ordered]@{ + host = "pc" + time = $timestamp + branch = $branch + head = $head + status = $status + intervalSeconds = $IntervalSeconds + heartbeatEverySeconds = $HeartbeatEverySeconds + } | ConvertTo-Json | Set-Content -Path $HeartbeatFile -Encoding UTF8 +} + function Sync-Once { Set-Location $RepoRoot Ensure-StateDir @@ -89,6 +147,8 @@ function Sync-Once { throw "merge or rebase conflicts detected" } + Write-Heartbeat + $allowedChanges = @(Get-AllowedChanges) if ($allowedChanges.Count -eq 0) { return diff --git a/sync/sync-agent.sh b/sync/sync-agent.sh index ea36d34..a0e8877 100644 --- a/sync/sync-agent.sh +++ b/sync/sync-agent.sh @@ -2,12 +2,15 @@ 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)" -STATE_DIR="$REPO_ROOT/.sync-state" -ERROR_FILE="$STATE_DIR/sync-error-vps.md" +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" @@ -18,11 +21,13 @@ ALLOWED_PATHS=( "rollback" "tasks" "sync" + "sync-state" + ".gitattributes" ".gitignore" ) ensure_state_dir() { - mkdir -p "$STATE_DIR" + mkdir -p "$LOCAL_STATE_DIR" "$PUBLIC_STATE_DIR" } write_sync_error() { @@ -41,6 +46,51 @@ Manual action required. Do not continue automatic sync until the repository is c 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" <