@matt82198/aesop 0.1.0-beta.5 → 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
package/daemons/backup-fleet.sh
CHANGED
|
@@ -74,6 +74,69 @@ get_untracked_files() {
|
|
|
74
74
|
) | sort -u
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
scan_unpushed_commits() {
|
|
78
|
+
local repo="$1"
|
|
79
|
+
local unpushed_files
|
|
80
|
+
local range_expr
|
|
81
|
+
|
|
82
|
+
# Detect if upstream exists (no-upstream or detached HEAD scenario)
|
|
83
|
+
local upstream
|
|
84
|
+
upstream=$(cd "$repo" 2>/dev/null && git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "")
|
|
85
|
+
|
|
86
|
+
if [ -z "$upstream" ] || [ "$upstream" = "@{u}" ]; then
|
|
87
|
+
# No upstream or detached HEAD: fallback to scanning recent commits (last 20)
|
|
88
|
+
# Use git rev-list to get the base commit 20 commits back, then use git diff
|
|
89
|
+
log "WARN: Repository has no upstream or detached HEAD; falling back to bounded commit range scan for $repo"
|
|
90
|
+
local base
|
|
91
|
+
base=$(cd "$repo" && git rev-list --max-count=20 HEAD 2>/dev/null | tail -1)
|
|
92
|
+
if [ -n "$base" ] && [ "$base" != "$(cd "$repo" && git rev-parse HEAD 2>/dev/null)" ]; then
|
|
93
|
+
# Multiple commits exist: scan from base to HEAD
|
|
94
|
+
range_expr="$base..HEAD"
|
|
95
|
+
unpushed_files=$(cd "$repo" && git diff --name-only "$range_expr" 2>/dev/null | sort -u)
|
|
96
|
+
elif [ -n "$base" ]; then
|
|
97
|
+
# Single commit (or base == HEAD): diff against the empty tree so the
|
|
98
|
+
# range form below (empty_tree..HEAD) still works for a root commit
|
|
99
|
+
# that has no parent to diff against.
|
|
100
|
+
local empty_tree
|
|
101
|
+
empty_tree=$(cd "$repo" && git hash-object -t tree /dev/null 2>/dev/null || echo "4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
|
102
|
+
range_expr="$empty_tree..HEAD"
|
|
103
|
+
unpushed_files=$(cd "$repo" && git diff --name-only "$range_expr" 2>/dev/null | sort -u)
|
|
104
|
+
else
|
|
105
|
+
unpushed_files=""
|
|
106
|
+
fi
|
|
107
|
+
else
|
|
108
|
+
# Normal case: scan commits not yet pushed to upstream
|
|
109
|
+
range_expr="@{u}..HEAD"
|
|
110
|
+
unpushed_files=$(cd "$repo" && git diff --name-only @{u}..HEAD 2>/dev/null | sort -u)
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [ -z "$unpushed_files" ]; then
|
|
114
|
+
return 0
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# P2 wave-25 fix: scan the COMMITTED BLOB content of the unpushed range
|
|
118
|
+
# (via secret_scan.py --range, which reads each changed path's git object
|
|
119
|
+
# at the range's tip commit), NOT the current working-tree copy of these
|
|
120
|
+
# files. A secret committed and then cleaned up in a LATER commit -- but
|
|
121
|
+
# still present in the unpushed range being force-pushed to the backup
|
|
122
|
+
# ref -- was previously invisible here, because the old code read
|
|
123
|
+
# "$repo/$file" straight off disk (which no longer had the secret) instead
|
|
124
|
+
# of the actual blob(s) the backup push carries. --range is also robust to
|
|
125
|
+
# deleted files (git diff --diff-filter=d inside secret_scan.py already
|
|
126
|
+
# excludes them from the file list it scans).
|
|
127
|
+
if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
|
|
128
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
129
|
+
python3 "$AESOP_ROOT/tools/secret_scan.py" --range "$range_expr" --repo "$repo" >/dev/null 2>&1
|
|
130
|
+
return $?
|
|
131
|
+
elif command -v python >/dev/null 2>&1; then
|
|
132
|
+
python "$AESOP_ROOT/tools/secret_scan.py" --range "$range_expr" --repo "$repo" >/dev/null 2>&1
|
|
133
|
+
return $?
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
|
|
77
140
|
scan_tracked_files() {
|
|
78
141
|
local repo="$1"
|
|
79
142
|
local tracked_files
|
|
@@ -81,8 +144,8 @@ scan_tracked_files() {
|
|
|
81
144
|
local -a file_paths=()
|
|
82
145
|
local repo_win
|
|
83
146
|
|
|
84
|
-
#
|
|
85
|
-
repo_win=$(cd "$repo" 2>/dev/null &&
|
|
147
|
+
# Portable path derivation (not Git-Bash pwd -W only)
|
|
148
|
+
repo_win=$(cd "$repo" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || echo "$repo")
|
|
86
149
|
|
|
87
150
|
tracked_files=$(get_tracked_modifications "$repo")
|
|
88
151
|
untracked_files=$(get_untracked_files "$repo")
|
|
@@ -115,8 +178,14 @@ EOF
|
|
|
115
178
|
|
|
116
179
|
if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
|
|
117
180
|
# ITEM 2 fix: use "${file_paths[@]}" to properly quote array elements
|
|
118
|
-
python
|
|
119
|
-
|
|
181
|
+
# Defect (a) fix: use python3||python probe instead of bare python (portable to python3-only systems)
|
|
182
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
183
|
+
python3 "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
|
|
184
|
+
return $?
|
|
185
|
+
elif command -v python >/dev/null 2>&1; then
|
|
186
|
+
python "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
|
|
187
|
+
return $?
|
|
188
|
+
fi
|
|
120
189
|
fi
|
|
121
190
|
|
|
122
191
|
return 0
|
|
@@ -160,14 +229,27 @@ process_repo() {
|
|
|
160
229
|
fi
|
|
161
230
|
fi
|
|
162
231
|
|
|
163
|
-
|
|
232
|
+
# Detect if upstream exists before trying to count unpushed commits
|
|
233
|
+
local upstream
|
|
234
|
+
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "")
|
|
235
|
+
|
|
236
|
+
local unpushed
|
|
237
|
+
if [ -z "$upstream" ] || [ "$upstream" = "@{u}" ]; then
|
|
238
|
+
# No upstream or detached HEAD: count recent commits instead (last 20)
|
|
239
|
+
log "WARN: Repository has no upstream or detached HEAD; falling back to recent commits count for $name"
|
|
240
|
+
unpushed=$(git log --oneline -20 2>/dev/null | wc -l | tr -d ' ')
|
|
241
|
+
else
|
|
242
|
+
# Normal case: count commits not yet pushed to upstream
|
|
243
|
+
unpushed=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
|
|
244
|
+
fi
|
|
245
|
+
|
|
164
246
|
if [ "$unpushed" -gt 0 ]; then
|
|
165
247
|
local branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
166
248
|
[ "$branch" = "HEAD" ] && branch="$default"
|
|
167
249
|
|
|
168
250
|
if [ "$branch" = "$default" ]; then
|
|
169
251
|
WIPREF="backup/master-wip-$(date +%Y%m%d)"
|
|
170
|
-
if
|
|
252
|
+
if scan_unpushed_commits "$repo"; then
|
|
171
253
|
if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
|
|
172
254
|
printf 'SNAPSHOTTED\0%s\n' "$name"
|
|
173
255
|
exit 0
|
|
@@ -177,7 +259,7 @@ process_repo() {
|
|
|
177
259
|
exit 0
|
|
178
260
|
fi
|
|
179
261
|
else
|
|
180
|
-
if
|
|
262
|
+
if scan_unpushed_commits "$repo"; then
|
|
181
263
|
if git push -q origin "$branch" 2>/dev/null; then
|
|
182
264
|
printf 'PUSHED\0%s\n' "$name"
|
|
183
265
|
exit 0
|
|
@@ -193,52 +275,128 @@ process_repo() {
|
|
|
193
275
|
)
|
|
194
276
|
}
|
|
195
277
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
[
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
278
|
+
main() {
|
|
279
|
+
log "=== cycle start ==="
|
|
280
|
+
temp_json=$(mktemp)
|
|
281
|
+
echo "[" > "$temp_json"
|
|
282
|
+
first=1
|
|
283
|
+
processed_paths=""
|
|
284
|
+
|
|
285
|
+
# AUDIT FIX 1: Check if aesop.config.json exists and has repos array
|
|
286
|
+
repos_to_scan=""
|
|
287
|
+
config_file="$AESOP_ROOT/aesop.config.json"
|
|
288
|
+
if [ -f "$config_file" ]; then
|
|
289
|
+
# Find Python interpreter (portable: prefer python3, fallback to python)
|
|
290
|
+
python_exe=""
|
|
291
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
292
|
+
python_exe="python3"
|
|
293
|
+
elif command -v python >/dev/null 2>&1; then
|
|
294
|
+
python_exe="python"
|
|
295
|
+
fi
|
|
296
|
+
|
|
297
|
+
if [ -n "$python_exe" ]; then
|
|
298
|
+
# Parse repos array from config using Python (not grep)
|
|
299
|
+
# Note: Strip trailing \r (CRLF compatibility) and empty lines
|
|
300
|
+
repos_to_scan=$($python_exe -c "
|
|
301
|
+
import json, sys
|
|
302
|
+
try:
|
|
303
|
+
with open(sys.argv[1], 'r') as f:
|
|
304
|
+
config = json.load(f)
|
|
305
|
+
repos = config.get('repos', [])
|
|
306
|
+
if isinstance(repos, list) and len(repos) > 0:
|
|
307
|
+
for repo in repos:
|
|
308
|
+
if isinstance(repo, dict) and 'path' in repo:
|
|
309
|
+
print(repo['path'])
|
|
310
|
+
except Exception as e:
|
|
311
|
+
pass
|
|
312
|
+
" "$config_file" 2>/dev/null | tr -d '\r')
|
|
313
|
+
fi
|
|
214
314
|
fi
|
|
215
|
-
|
|
315
|
+
|
|
316
|
+
if [ -n "$repos_to_scan" ]; then
|
|
317
|
+
# AUDIT FIX 1: Use explicit repos from config
|
|
318
|
+
log "Loading repos from config file"
|
|
319
|
+
while IFS= read -r dir; do
|
|
320
|
+
[ -z "$dir" ] && continue
|
|
321
|
+
# Validate repo path exists
|
|
322
|
+
if [ ! -d "$dir/.git" ]; then
|
|
323
|
+
log "WARN: configured repo not found or not a git repo (skipping): $dir"
|
|
324
|
+
continue
|
|
325
|
+
fi
|
|
326
|
+
# Normalize path to detect duplicates
|
|
327
|
+
real_dir=$(cd "$dir" && pwd 2>/dev/null)
|
|
328
|
+
if [ -z "$real_dir" ]; then continue; fi
|
|
329
|
+
if echo "$processed_paths" | grep -Fxq "$real_dir"; then
|
|
330
|
+
continue
|
|
331
|
+
fi
|
|
332
|
+
processed_paths="$processed_paths
|
|
216
333
|
$real_dir"
|
|
217
334
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
335
|
+
if is_touched "$dir"; then
|
|
336
|
+
temp_result=$(mktemp)
|
|
337
|
+
process_repo "$dir" > "$temp_result"
|
|
338
|
+
state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
|
|
339
|
+
name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
|
|
340
|
+
rm -f "$temp_result"
|
|
341
|
+
[ -z "$state" ] && continue
|
|
342
|
+
if [ "$first" = 1 ]; then
|
|
343
|
+
first=0
|
|
344
|
+
else
|
|
345
|
+
echo "," >> "$temp_json"
|
|
346
|
+
fi
|
|
347
|
+
printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
|
|
348
|
+
log "$state: $name"
|
|
349
|
+
fi
|
|
350
|
+
done <<< "$repos_to_scan"
|
|
351
|
+
else
|
|
352
|
+
# AUDIT FIX 1: Fall back to autodiscovery only when no repos configured
|
|
353
|
+
log "No repos configured, using autodiscovery"
|
|
354
|
+
# Discover all git repos: scan home dot-directories, root directories, dev/ subdirectory
|
|
355
|
+
# This pattern includes ~/.* (dot-dirs like .claude), ~/* (home root), ~/dev/* (dev subtree)
|
|
356
|
+
for dir in ~/.* ~/* ~/dev/*; do
|
|
357
|
+
base=$(basename "$dir")
|
|
358
|
+
[ "$base" = "." ] || [ "$base" = ".." ] && continue
|
|
359
|
+
[ ! -d "$dir/.git" ] && continue
|
|
360
|
+
|
|
361
|
+
# Normalize path to detect duplicates (e.g., .claude vs .claude/)
|
|
362
|
+
real_dir=$(cd "$dir" && pwd 2>/dev/null)
|
|
363
|
+
if [ -z "$real_dir" ]; then continue; fi
|
|
364
|
+
if echo "$processed_paths" | grep -Fxq "$real_dir"; then
|
|
365
|
+
continue
|
|
366
|
+
fi
|
|
367
|
+
processed_paths="$processed_paths
|
|
368
|
+
$real_dir"
|
|
369
|
+
|
|
370
|
+
if is_touched "$dir"; then
|
|
371
|
+
# P0 FIX: Eliminate $() command substitution to preserve NUL bytes in protocol
|
|
372
|
+
# Direct redirect maintains NUL delimiters (process_repo emits STATE\0name format)
|
|
373
|
+
temp_result=$(mktemp)
|
|
374
|
+
process_repo "$dir" > "$temp_result"
|
|
375
|
+
# Read first field (state) and second field (name) using NUL delimiter
|
|
376
|
+
# Portable solution: use awk with RS="\0" instead of GNU sed \x0 (BSD/macOS compatible)
|
|
377
|
+
state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
|
|
378
|
+
name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
|
|
379
|
+
rm -f "$temp_result"
|
|
380
|
+
[ -z "$state" ] && continue
|
|
381
|
+
if [ "$first" = 1 ]; then
|
|
382
|
+
first=0
|
|
383
|
+
else
|
|
384
|
+
echo "," >> "$temp_json"
|
|
385
|
+
fi
|
|
386
|
+
# P2 FIX: JSON-escape all interpolated values before emitting
|
|
387
|
+
# P2 FIX: Portable date format (not GNU-only date -Iseconds)
|
|
388
|
+
printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
|
|
389
|
+
log "$state: $name"
|
|
390
|
+
fi
|
|
391
|
+
done
|
|
238
392
|
fi
|
|
239
|
-
done
|
|
240
393
|
|
|
241
|
-
echo "" >> "$temp_json"
|
|
242
|
-
echo "]" >> "$temp_json"
|
|
243
|
-
mv "$temp_json" "$REPOS_STATUS"
|
|
244
|
-
log "=== cycle end ==="
|
|
394
|
+
echo "" >> "$temp_json"
|
|
395
|
+
echo "]" >> "$temp_json"
|
|
396
|
+
mv "$temp_json" "$REPOS_STATUS"
|
|
397
|
+
log "=== cycle end ==="
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
401
|
+
main "$@"
|
|
402
|
+
fi
|
package/daemons/run-watchdog.sh
CHANGED
|
@@ -5,12 +5,30 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Configuration: export AESOP_ROOT=/path/to/aesop before running (defaults to script directory's parent).
|
|
7
7
|
# Testing: export AESOP_WATCHDOG_CYCLE_CMD to override backup-fleet.sh invocation
|
|
8
|
+
#
|
|
9
|
+
# Kill switch (wave-26 safety brake): if AESOP_ROOT/state/.HALT exists (see
|
|
10
|
+
# tools/halt.py), every cycle logs "HALTED: <reason>" and skips all work — no
|
|
11
|
+
# backup, no push, no scan — until cleared via `python tools/halt.py --clear`.
|
|
8
12
|
|
|
9
13
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
14
|
AESOP_ROOT="${AESOP_ROOT:-$(dirname "$SCRIPT_DIR")}"
|
|
11
15
|
MODE="${1:-daemon}"
|
|
12
16
|
LOCK_DIR="$AESOP_ROOT/state/.watchdog-lock"
|
|
13
17
|
LOCK_STALE_THRESHOLD=300
|
|
18
|
+
HALT_SENTINEL="$AESOP_ROOT/state/.HALT"
|
|
19
|
+
|
|
20
|
+
# Resolve Python interpreter (portable: prefer python3, fallback to python)
|
|
21
|
+
PYTHON_EXE=""
|
|
22
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
23
|
+
PYTHON_EXE="python3"
|
|
24
|
+
elif command -v python >/dev/null 2>&1; then
|
|
25
|
+
PYTHON_EXE="python"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Resolve conductor3 directory (sibling of AESOP_ROOT)
|
|
29
|
+
CONDUCTOR_ROOT="$(dirname "$AESOP_ROOT")/conductor3"
|
|
30
|
+
MONITOR_HB_FILE="$CONDUCTOR_ROOT/monitor/.monitor-heartbeat"
|
|
31
|
+
MONITOR_HB_STALE_THRESHOLD=600
|
|
14
32
|
|
|
15
33
|
# Atomic lock acquire: mkdir is atomic (POSIX guarantees)
|
|
16
34
|
# Returns 0 if lock acquired, 1 if held by another process, 2 if stale lock reclaimed
|
|
@@ -30,20 +48,64 @@ acquire_lock() {
|
|
|
30
48
|
fi
|
|
31
49
|
|
|
32
50
|
# Lock directory already exists; check if it's stale
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
local
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
# AUDIT FIX 2: Verify both timestamp AND pid files exist before the stale comparison
|
|
52
|
+
if [ -d "$lock_dir" ]; then
|
|
53
|
+
# Try to read pid file to check if process is still running
|
|
54
|
+
local lock_pid=""
|
|
55
|
+
if [ -f "$lock_dir/pid" ]; then
|
|
56
|
+
lock_pid=$(cat "$lock_dir/pid" 2>/dev/null)
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Check if both required files exist
|
|
60
|
+
if [ -f "$lock_dir/timestamp" ] && [ -f "$lock_dir/pid" ] && [ -n "$lock_pid" ]; then
|
|
61
|
+
# Both files present and pid is readable - use timestamp for stale check
|
|
62
|
+
local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
|
|
63
|
+
local now=$(date +%s)
|
|
64
|
+
local lock_age=$((now - lock_mtime))
|
|
65
|
+
|
|
66
|
+
if [ "$lock_age" -gt "$stale_threshold" ]; then
|
|
67
|
+
# Lock is stale; try to reclaim it
|
|
68
|
+
rm -rf "$lock_dir" 2>/dev/null || true
|
|
69
|
+
if mkdir "$lock_dir" 2>/dev/null; then
|
|
70
|
+
date +%s > "$lock_dir/timestamp" 2>/dev/null
|
|
71
|
+
echo $$ > "$lock_dir/pid" 2>/dev/null
|
|
72
|
+
echo "watchdog lock was stale (${lock_age}s) — reclaimed." >&2
|
|
73
|
+
return 2
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
else
|
|
77
|
+
# AUDIT FIX 2: Timestamp or pid file missing, or pid is empty - lock is incomplete
|
|
78
|
+
# Reclaim only if we can verify it's abandoned: dead pid OR stale timestamp
|
|
79
|
+
local should_reclaim=0
|
|
80
|
+
|
|
81
|
+
# Case 1: pid file exists and is readable - check if process is running
|
|
82
|
+
if [ -f "$lock_dir/pid" ] && [ -n "$lock_pid" ]; then
|
|
83
|
+
if ! kill -0 "$lock_pid" 2>/dev/null; then
|
|
84
|
+
should_reclaim=1
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Case 2: no pid file, but timestamp exists and is old (definitely abandoned)
|
|
89
|
+
if [ $should_reclaim -eq 0 ] && [ -f "$lock_dir/timestamp" ]; then
|
|
90
|
+
local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
|
|
91
|
+
local now=$(date +%s)
|
|
92
|
+
local lock_age=$((now - lock_mtime))
|
|
93
|
+
if [ "$lock_age" -gt "$stale_threshold" ]; then
|
|
94
|
+
should_reclaim=1
|
|
95
|
+
fi
|
|
46
96
|
fi
|
|
97
|
+
|
|
98
|
+
if [ $should_reclaim -eq 1 ]; then
|
|
99
|
+
# Lock is abandoned - reclaim it
|
|
100
|
+
rm -rf "$lock_dir" 2>/dev/null || true
|
|
101
|
+
if mkdir "$lock_dir" 2>/dev/null; then
|
|
102
|
+
date +%s > "$lock_dir/timestamp" 2>/dev/null
|
|
103
|
+
echo $$ > "$lock_dir/pid" 2>/dev/null
|
|
104
|
+
echo "watchdog lock was stale (incomplete) — reclaimed." >&2
|
|
105
|
+
return 2
|
|
106
|
+
fi
|
|
107
|
+
fi
|
|
108
|
+
# If lock is incomplete but fresh, hold it (process might be in progress)
|
|
47
109
|
fi
|
|
48
110
|
fi
|
|
49
111
|
|
|
@@ -62,59 +124,143 @@ release_lock() {
|
|
|
62
124
|
fi
|
|
63
125
|
}
|
|
64
126
|
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
127
|
+
# Check monitor heartbeat staleness; log to fleet-backup.log if missing or >600s old
|
|
128
|
+
check_monitor_staleness() {
|
|
129
|
+
local hb_file="$1"
|
|
130
|
+
local stale_threshold="$2"
|
|
131
|
+
local log_file="$3"
|
|
132
|
+
if [ -z "$hb_file" ] || [ -z "$log_file" ]; then
|
|
133
|
+
return
|
|
134
|
+
fi
|
|
135
|
+
if [ ! -f "$hb_file" ]; then
|
|
136
|
+
echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat missing ($hb_file)" >> "$log_file"
|
|
137
|
+
return
|
|
138
|
+
fi
|
|
139
|
+
local hb_epoch=$(cat "$hb_file" 2>/dev/null || echo 0)
|
|
140
|
+
if [ -z "$hb_epoch" ] || [ "$hb_epoch" = "0" ]; then
|
|
141
|
+
echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat empty/unreadable" >> "$log_file"
|
|
142
|
+
return
|
|
143
|
+
fi
|
|
144
|
+
local now=$(date +%s)
|
|
145
|
+
local hb_age=$((now - hb_epoch))
|
|
146
|
+
if [ "$hb_age" -gt "$stale_threshold" ]; then
|
|
147
|
+
echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat stale (${hb_age}s > ${stale_threshold}s threshold)" >> "$log_file"
|
|
148
|
+
fi
|
|
149
|
+
}
|
|
68
150
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
151
|
+
# Kill switch check (wave-26 safety brake). Returns 0 (bash true) and logs
|
|
152
|
+
# "HALTED: <reason>" if $AESOP_ROOT/state/.HALT exists; returns 1 otherwise.
|
|
153
|
+
# Never runs backup/push/scan work when halted — caller must skip the cycle.
|
|
154
|
+
check_halt() {
|
|
155
|
+
local log_file="$1"
|
|
156
|
+
if [ ! -f "$HALT_SENTINEL" ]; then
|
|
157
|
+
return 1
|
|
158
|
+
fi
|
|
73
159
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
160
|
+
local reason="halted (reason unavailable)"
|
|
161
|
+
if [ -n "$PYTHON_EXE" ]; then
|
|
162
|
+
local parsed
|
|
163
|
+
parsed=$("$PYTHON_EXE" -c '
|
|
164
|
+
import json, sys
|
|
165
|
+
try:
|
|
166
|
+
with open(sys.argv[1], encoding="utf-8") as f:
|
|
167
|
+
data = json.load(f)
|
|
168
|
+
r = data.get("reason")
|
|
169
|
+
if r:
|
|
170
|
+
print(r)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
' "$HALT_SENTINEL" 2>/dev/null)
|
|
174
|
+
if [ -n "$parsed" ]; then
|
|
175
|
+
reason="$parsed"
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
90
178
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
echo "$full_out"
|
|
95
|
-
if [ $cmd_exit -ne 0 ]; then
|
|
96
|
-
err_msg="[$(date '+%F %T')] ERROR: cycle #1 failed with exit code $cmd_exit"
|
|
97
|
-
echo "$err_msg" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
98
|
-
echo "[ERROR: exit $cmd_exit]" >&2
|
|
179
|
+
echo "HALTED: $reason"
|
|
180
|
+
if [ -n "$log_file" ]; then
|
|
181
|
+
echo "[$(date '+%F %T')] HALTED: $reason" >> "$log_file"
|
|
99
182
|
fi
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
183
|
+
return 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# Main execution — guarded below so sourcing this file (e.g. from tests, to
|
|
187
|
+
# reuse acquire_lock/release_lock/check_halt) never runs a cycle as a
|
|
188
|
+
# side effect of the source itself.
|
|
189
|
+
main() {
|
|
190
|
+
# Try to acquire lock (applies to both --once and daemon modes)
|
|
191
|
+
acquire_lock "$LOCK_DIR" "$LOCK_STALE_THRESHOLD"
|
|
192
|
+
lock_result=$?
|
|
104
193
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
194
|
+
if [ $lock_result -eq 1 ]; then
|
|
195
|
+
echo "watchdog already running — not starting a duplicate."
|
|
196
|
+
exit 0
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
echo "==================================================================="
|
|
200
|
+
echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
|
|
201
|
+
echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
|
|
202
|
+
echo "==================================================================="
|
|
203
|
+
echo "[$(date '+%F %T')] === watchdog daemon (shell) STARTED ===" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
204
|
+
trap "release_lock \"$LOCK_DIR\"; echo \"[$(date '+%F %T')] === watchdog daemon (shell) STOPPED ===\" >> \"$AESOP_ROOT/state/FLEET-BACKUP.log\"; echo \"stopped.\"; exit 0" INT TERM
|
|
205
|
+
|
|
206
|
+
# Allow override of backup cycle command (for testing)
|
|
207
|
+
# Use array to safely handle paths with spaces (P1 fix)
|
|
208
|
+
if [ -n "$AESOP_WATCHDOG_CYCLE_CMD" ]; then
|
|
209
|
+
# Override: run as-is through bash -c
|
|
210
|
+
CYCLE_CMD_ARRAY=("bash" "-c" "$AESOP_WATCHDOG_CYCLE_CMD")
|
|
113
211
|
else
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
printf '%s cycle #%d [ERROR: exit %d]\n%s\n' "$(date '+%H:%M:%S')" "$n" "$cmd_exit" "$out"
|
|
212
|
+
# Default: array form for proper quoting
|
|
213
|
+
CYCLE_CMD_ARRAY=("bash" "$AESOP_ROOT/daemons/backup-fleet.sh")
|
|
117
214
|
fi
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
215
|
+
|
|
216
|
+
if [ "$MODE" = "--once" ]; then
|
|
217
|
+
if check_halt "$AESOP_ROOT/state/FLEET-BACKUP.log"; then
|
|
218
|
+
release_lock "$LOCK_DIR"
|
|
219
|
+
exit 0
|
|
220
|
+
fi
|
|
221
|
+
full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
|
|
222
|
+
cmd_exit=$?
|
|
223
|
+
echo "$full_out"
|
|
224
|
+
if [ $cmd_exit -ne 0 ]; then
|
|
225
|
+
err_msg="[$(date '+%F %T')] ERROR: cycle #1 failed with exit code $cmd_exit"
|
|
226
|
+
echo "$err_msg" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
227
|
+
echo "[ERROR: exit $cmd_exit]" >&2
|
|
228
|
+
fi
|
|
229
|
+
if [ -n "$PYTHON_EXE" ]; then
|
|
230
|
+
"$PYTHON_EXE" "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
|
|
231
|
+
fi
|
|
232
|
+
release_lock "$LOCK_DIR"
|
|
233
|
+
exit $cmd_exit
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
n=0
|
|
237
|
+
while true; do
|
|
238
|
+
n=$((n+1))
|
|
239
|
+
if check_halt "$AESOP_ROOT/state/FLEET-BACKUP.log"; then
|
|
240
|
+
sleep 150
|
|
241
|
+
continue
|
|
242
|
+
fi
|
|
243
|
+
full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
|
|
244
|
+
cmd_exit=$?
|
|
245
|
+
if [ $cmd_exit -eq 0 ]; then
|
|
246
|
+
out=$(echo "$full_out" | tail -2)
|
|
247
|
+
printf '%s cycle #%d\n%s\n' "$(date '+%H:%M:%S')" "$n" "$out"
|
|
248
|
+
else
|
|
249
|
+
echo "[$(date '+%F %T')] ERROR: cycle #$n failed with exit code $cmd_exit" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
250
|
+
out=$(echo "$full_out" | tail -2)
|
|
251
|
+
printf '%s cycle #%d [ERROR: exit %d]\n%s\n' "$(date '+%H:%M:%S')" "$n" "$cmd_exit" "$out"
|
|
252
|
+
fi
|
|
253
|
+
check_monitor_staleness "$MONITOR_HB_FILE" "$MONITOR_HB_STALE_THRESHOLD" "$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
254
|
+
if [ -n "$PYTHON_EXE" ]; then
|
|
255
|
+
"$PYTHON_EXE" "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
|
|
256
|
+
fi
|
|
257
|
+
sleep 150
|
|
258
|
+
done
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Execution guard: only run a cycle when this script is executed directly,
|
|
262
|
+
# not when it is sourced (e.g. `source run-watchdog.sh` in a test harness to
|
|
263
|
+
# reuse acquire_lock/release_lock/check_halt without triggering a cycle).
|
|
264
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
265
|
+
main "$@"
|
|
266
|
+
fi
|