@matt82198/aesop 0.1.0 → 0.3.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 +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/tools/eod_sweep.py
CHANGED
|
@@ -11,21 +11,32 @@ Output contract:
|
|
|
11
11
|
Line 1: EOD-SWEEP: SAFE or EOD-SWEEP: AT-RISK — <n> findings
|
|
12
12
|
Lines 2+: One finding per line (if any)
|
|
13
13
|
Exit code 0 only when SAFE.
|
|
14
|
+
Verdict also appended to BUILDLOG.md (if --buildlog provided or AESOP_STATE_ROOT set).
|
|
14
15
|
|
|
15
16
|
Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
|
|
17
|
+
[--buildlog PATH] [--timestamp YYYY-MM-DD HH:MM]
|
|
16
18
|
|
|
17
19
|
--repos: Colon-separated paths to scan (default: empty; use env var or flag to specify)
|
|
18
20
|
--readonly-repos: Colon-separated paths that should NOT be auto-pushed
|
|
19
21
|
--fix-push: Auto-push unpushed commits in repos where safe
|
|
22
|
+
--buildlog: Path to BUILDLOG.md (default: AESOP_STATE_ROOT/BUILDLOG.md or ./state/BUILDLOG.md)
|
|
23
|
+
--timestamp: Timestamp for BUILDLOG entry (format: YYYY-MM-DD HH:MM; omit to exclude timestamp)
|
|
20
24
|
"""
|
|
21
25
|
|
|
22
26
|
import json
|
|
27
|
+
import os
|
|
23
28
|
import subprocess
|
|
24
29
|
import sys
|
|
30
|
+
import os
|
|
25
31
|
from pathlib import Path
|
|
26
32
|
from datetime import datetime
|
|
27
33
|
import time
|
|
28
34
|
|
|
35
|
+
try:
|
|
36
|
+
from common import get_state_dir
|
|
37
|
+
except ImportError:
|
|
38
|
+
from tools.common import get_state_dir
|
|
39
|
+
|
|
29
40
|
|
|
30
41
|
class Finding:
|
|
31
42
|
"""A single finding with repo + message."""
|
|
@@ -38,13 +49,27 @@ class Finding:
|
|
|
38
49
|
|
|
39
50
|
|
|
40
51
|
def get_git_status(repo_path):
|
|
41
|
-
"""Return (is_clean, dirty_files_list) for a repo.
|
|
52
|
+
"""Return (is_clean, dirty_files_list) for a repo.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
(True, []): Repo is clean
|
|
56
|
+
(False, list): Repo is dirty with file list
|
|
57
|
+
(None, error_msg): Git command failed (FAIL-CLOSED: treat as AT-RISK)
|
|
58
|
+
"""
|
|
42
59
|
try:
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
61
|
+
resolved_path = Path(repo_path).resolve()
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
['git', '-C', str(resolved_path), 'status', '--porcelain'],
|
|
45
64
|
capture_output=True, text=True, timeout=5
|
|
46
|
-
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# FAIL-CLOSED: Check return code before processing output
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
error_msg = result.stderr.strip() if result.stderr.strip() else f"exit code {result.returncode}"
|
|
70
|
+
return (None, f"git status check failed: {error_msg}")
|
|
47
71
|
|
|
72
|
+
output = result.stdout.strip()
|
|
48
73
|
if not output:
|
|
49
74
|
return (True, [])
|
|
50
75
|
else:
|
|
@@ -55,21 +80,56 @@ def get_git_status(repo_path):
|
|
|
55
80
|
|
|
56
81
|
|
|
57
82
|
def get_ahead_count(repo_path):
|
|
58
|
-
"""Return count of commits ahead of origin/HEAD (or None on error).
|
|
83
|
+
"""Return count of commits ahead of origin/HEAD (or None on error).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
int >= 0: Number of commits ahead (0 = all pushed or no remote)
|
|
87
|
+
None: Git command failed unexpectedly (FAIL-CLOSED: treat as AT-RISK)
|
|
88
|
+
"""
|
|
59
89
|
try:
|
|
90
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
91
|
+
resolved_path = Path(repo_path).resolve()
|
|
60
92
|
# First check if there's a tracking branch
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
output =
|
|
69
|
-
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
|
|
95
|
+
capture_output=True, text=True, timeout=5
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# If upstream tracking exists, use that result
|
|
99
|
+
if result.returncode == 0:
|
|
100
|
+
output = result.stdout.strip()
|
|
101
|
+
try:
|
|
102
|
+
return int(output) if output else 0
|
|
103
|
+
except:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# If no upstream tracking, try origin/HEAD (fallback for single-branch or no-remote repos)
|
|
107
|
+
# Expected error for local-only repos: "fatal: no upstream configured"
|
|
108
|
+
stderr_lower = result.stderr.lower()
|
|
109
|
+
if 'no upstream configured' in stderr_lower:
|
|
110
|
+
# Local-only repo (no tracking branch), try origin/HEAD as fallback
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
|
|
70
113
|
capture_output=True, text=True, timeout=5
|
|
71
|
-
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check return code; if fails, determine if it's an expected "no remote" error or a real error
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
stderr_lower = result.stderr.lower()
|
|
119
|
+
# Expected errors for local-only repos (no remote configured)
|
|
120
|
+
expected_errors = [
|
|
121
|
+
'no upstream configured',
|
|
122
|
+
'ambiguous argument',
|
|
123
|
+
'unknown revision',
|
|
124
|
+
'not a valid object name',
|
|
125
|
+
'no such ref'
|
|
126
|
+
]
|
|
127
|
+
if any(err in stderr_lower for err in expected_errors):
|
|
128
|
+
return 0 # No remote/tracking, treat as "all pushed"
|
|
129
|
+
else:
|
|
130
|
+
return None # Real error, fail-closed
|
|
72
131
|
|
|
132
|
+
output = result.stdout.strip()
|
|
73
133
|
try:
|
|
74
134
|
return int(output) if output else 0
|
|
75
135
|
except:
|
|
@@ -79,13 +139,26 @@ def get_ahead_count(repo_path):
|
|
|
79
139
|
|
|
80
140
|
|
|
81
141
|
def check_untracked_files(repo_path):
|
|
82
|
-
"""Return list of untracked files not in .gitignore.
|
|
142
|
+
"""Return list of untracked files not in .gitignore.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
[]: No untracked files
|
|
146
|
+
[list]: Untracked files found
|
|
147
|
+
None: Git command failed (FAIL-CLOSED: treat as AT-RISK)
|
|
148
|
+
"""
|
|
83
149
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
150
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
151
|
+
resolved_path = Path(repo_path).resolve()
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
['git', '-C', str(resolved_path), 'ls-files', '--others', '--exclude-standard'],
|
|
86
154
|
capture_output=True, text=True, timeout=5
|
|
87
|
-
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# FAIL-CLOSED: Check return code before processing output
|
|
158
|
+
if result.returncode != 0:
|
|
159
|
+
return None
|
|
88
160
|
|
|
161
|
+
output = result.stdout.strip()
|
|
89
162
|
if output:
|
|
90
163
|
return output.split('\n')
|
|
91
164
|
return []
|
|
@@ -94,12 +167,18 @@ def check_untracked_files(repo_path):
|
|
|
94
167
|
|
|
95
168
|
|
|
96
169
|
def check_repo(repo_path):
|
|
97
|
-
"""Check a single repo;
|
|
170
|
+
"""Check a single repo; returns a list of Finding objects.
|
|
171
|
+
|
|
172
|
+
FAIL-CLOSED (runner incident): a repo the caller ASKED to check that does
|
|
173
|
+
not exist or is not a git repo is an AT-RISK finding, never a silent skip
|
|
174
|
+
— the silent-None path made the sweep report a vacuous SAFE (exit 0) when
|
|
175
|
+
fixture/real repos failed to initialize.
|
|
176
|
+
"""
|
|
98
177
|
if not repo_path.exists():
|
|
99
|
-
return
|
|
178
|
+
return [Finding(repo_path, "repo path does not exist")]
|
|
100
179
|
|
|
101
180
|
if not (repo_path / '.git').exists():
|
|
102
|
-
return
|
|
181
|
+
return [Finding(repo_path, "not a git repository (.git missing)")]
|
|
103
182
|
|
|
104
183
|
findings = []
|
|
105
184
|
|
|
@@ -130,8 +209,10 @@ def check_repo(repo_path):
|
|
|
130
209
|
def push_repo(repo_path):
|
|
131
210
|
"""Push commits for a repo (return True if successful)."""
|
|
132
211
|
try:
|
|
212
|
+
# Resolve path to normalize 8.3 short names on Windows
|
|
213
|
+
resolved_path = Path(repo_path).resolve()
|
|
133
214
|
result = subprocess.run(
|
|
134
|
-
['git', '-C', str(
|
|
215
|
+
['git', '-C', str(resolved_path), 'push'],
|
|
135
216
|
capture_output=True, text=True, timeout=30
|
|
136
217
|
)
|
|
137
218
|
return result.returncode == 0
|
|
@@ -153,13 +234,64 @@ def run_secret_scan(repo_path):
|
|
|
153
234
|
return False
|
|
154
235
|
|
|
155
236
|
|
|
237
|
+
def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
|
|
238
|
+
"""Append verdict to BUILDLOG.md (append-only).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
buildlog_path: Path to BUILDLOG.md file.
|
|
242
|
+
verdict_line: The verdict line to append (e.g., "EOD-SWEEP: SAFE").
|
|
243
|
+
timestamp_str: Optional timestamp string (format: YYYY-MM-DD HH:MM).
|
|
244
|
+
If None, timestamp is omitted from the entry.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
OSError: If file operations fail (permission denied, locked file, etc.).
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
buildlog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
except OSError as e:
|
|
252
|
+
# Parent directory creation failed; re-raise with context
|
|
253
|
+
raise OSError(f"Failed to create parent directory for BUILDLOG: {e}") from e
|
|
254
|
+
|
|
255
|
+
# Create header if file doesn't exist
|
|
256
|
+
try:
|
|
257
|
+
if not buildlog_path.exists():
|
|
258
|
+
buildlog_path.write_text("# Build Log (append-only)\n")
|
|
259
|
+
except OSError as e:
|
|
260
|
+
# File creation failed; re-raise with context
|
|
261
|
+
raise OSError(f"Failed to create BUILDLOG file: {e}") from e
|
|
262
|
+
|
|
263
|
+
# Build entry line with optional timestamp
|
|
264
|
+
if timestamp_str:
|
|
265
|
+
entry_line = f"### [{timestamp_str}] {verdict_line}"
|
|
266
|
+
else:
|
|
267
|
+
entry_line = f"### {verdict_line}"
|
|
268
|
+
|
|
269
|
+
# Append to BUILDLOG with retry logic for Windows file locking
|
|
270
|
+
max_retries = 3
|
|
271
|
+
retry_delay = 0.1 # seconds
|
|
272
|
+
last_error = None
|
|
273
|
+
|
|
274
|
+
for attempt in range(max_retries):
|
|
275
|
+
try:
|
|
276
|
+
with open(buildlog_path, "a", encoding="utf-8") as f:
|
|
277
|
+
f.write(entry_line + "\n")
|
|
278
|
+
return
|
|
279
|
+
except OSError as e:
|
|
280
|
+
last_error = e
|
|
281
|
+
if attempt < max_retries - 1:
|
|
282
|
+
time.sleep(retry_delay)
|
|
283
|
+
|
|
284
|
+
# All retries failed
|
|
285
|
+
raise OSError(f"Failed to append to BUILDLOG after {max_retries} attempts: {last_error}") from last_error
|
|
286
|
+
|
|
287
|
+
|
|
156
288
|
def main():
|
|
157
289
|
import argparse
|
|
158
290
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
159
291
|
parser.add_argument(
|
|
160
292
|
'--repos',
|
|
161
293
|
default='',
|
|
162
|
-
help=
|
|
294
|
+
help="os.pathsep-separated paths (; on Windows, : on POSIX)"
|
|
163
295
|
)
|
|
164
296
|
parser.add_argument(
|
|
165
297
|
'--readonly-repos',
|
|
@@ -171,17 +303,31 @@ def main():
|
|
|
171
303
|
action='store_true',
|
|
172
304
|
help='Auto-push unpushed commits'
|
|
173
305
|
)
|
|
306
|
+
parser.add_argument(
|
|
307
|
+
'--buildlog',
|
|
308
|
+
default=None,
|
|
309
|
+
help='Path to BUILDLOG.md (default: AESOP_STATE_ROOT/BUILDLOG.md or ./state/BUILDLOG.md)'
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
'--timestamp',
|
|
313
|
+
default=None,
|
|
314
|
+
help='Timestamp for BUILDLOG entry (format: YYYY-MM-DD HH:MM; omit to exclude timestamp)'
|
|
315
|
+
)
|
|
174
316
|
args = parser.parse_args()
|
|
175
317
|
|
|
176
318
|
# Parse repos
|
|
319
|
+
# Split on os.pathsep (';' on Windows, ':' on POSIX): a ':' delimiter
|
|
320
|
+
# eats Windows drive letters (the runner's vacuous-SAFE incident: tmp on
|
|
321
|
+
# C:, checkout on D:, drive-relative remainder resolved to nothing and
|
|
322
|
+
# the old silent-skip reported SAFE with zero repos scanned).
|
|
177
323
|
repos_to_check = []
|
|
178
324
|
if args.repos:
|
|
179
|
-
repos_to_check = [Path(p) for p in args.repos.split(
|
|
325
|
+
repos_to_check = [Path(p) for p in args.repos.split(os.pathsep) if p]
|
|
180
326
|
|
|
181
327
|
# Parse readonly repos
|
|
182
328
|
readonly_repos = set()
|
|
183
329
|
if args.readonly_repos:
|
|
184
|
-
readonly_repos = {Path(p) for p in args.readonly_repos.split(
|
|
330
|
+
readonly_repos = {Path(p) for p in args.readonly_repos.split(os.pathsep) if p}
|
|
185
331
|
|
|
186
332
|
findings = []
|
|
187
333
|
|
|
@@ -232,6 +378,22 @@ def main():
|
|
|
232
378
|
for finding in findings:
|
|
233
379
|
print(f" {finding}")
|
|
234
380
|
|
|
381
|
+
# Append to BUILDLOG if path is available
|
|
382
|
+
buildlog_path = None
|
|
383
|
+
if args.buildlog:
|
|
384
|
+
buildlog_path = Path(args.buildlog)
|
|
385
|
+
else:
|
|
386
|
+
# Try to derive from AESOP_STATE_ROOT or default to ./state
|
|
387
|
+
state_dir = get_state_dir()
|
|
388
|
+
buildlog_path = state_dir / "BUILDLOG.md"
|
|
389
|
+
|
|
390
|
+
if buildlog_path:
|
|
391
|
+
try:
|
|
392
|
+
append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
|
|
393
|
+
except OSError as e:
|
|
394
|
+
# Log error but don't fail the verdict — exit code depends on findings, not BUILDLOG
|
|
395
|
+
print(f"WARNING: Failed to append to BUILDLOG: {e}", file=sys.stderr)
|
|
396
|
+
|
|
235
397
|
sys.exit(exit_code)
|
|
236
398
|
|
|
237
399
|
|
package/tools/fleet.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop fleet — One-shot fleet snapshot
|
|
5
|
+
*
|
|
6
|
+
* Prints a comprehensive fleet status snapshot:
|
|
7
|
+
* - Agents (from dash-extra.mjs --json passthrough)
|
|
8
|
+
* - Heartbeat ages (watchdog + monitor, seconds, with STALE status if >threshold)
|
|
9
|
+
* - Tracker lane counts (from state/tracker.json)
|
|
10
|
+
* - Orchestrator status (from state/orchestrator-status.json)
|
|
11
|
+
*
|
|
12
|
+
* Graceful degradation: any missing state file produces explicit 'unavailable: <why>'
|
|
13
|
+
* rather than crashing or leaving blank output. Never crashes; always exits cleanly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const CURRENT_DIR = process.cwd();
|
|
22
|
+
const WATCHDOG_STALE_THRESHOLD = 300; // seconds
|
|
23
|
+
const MONITOR_STALE_THRESHOLD = 3600; // seconds
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Utility Functions
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
function readHeartbeat(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(filePath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
35
|
+
const timestamp = parseInt(content, 10);
|
|
36
|
+
if (isNaN(timestamp)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return timestamp;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getHeartbeatStatus(timestamp, threshold) {
|
|
46
|
+
if (timestamp === null) {
|
|
47
|
+
return { age: null, status: 'MISSING' };
|
|
48
|
+
}
|
|
49
|
+
const now = Math.floor(Date.now() / 1000);
|
|
50
|
+
const age = now - timestamp;
|
|
51
|
+
const isStale = age > threshold;
|
|
52
|
+
const status = isStale ? 'STALE' : 'OK';
|
|
53
|
+
return { age, status };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadJSON(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(filePath)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
62
|
+
if (!content) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(content);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Invoke dash-extra.mjs --json to get active agents
|
|
73
|
+
* Returns { agents: [...] } or { unavailable: "reason" }
|
|
74
|
+
*/
|
|
75
|
+
function getFleetAgents(aesopRoot) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const dashExtraPath = path.join(aesopRoot, 'dash', 'dash-extra.mjs');
|
|
78
|
+
|
|
79
|
+
if (!fs.existsSync(dashExtraPath)) {
|
|
80
|
+
resolve({ unavailable: 'dash-extra.mjs not found' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const proc = spawn('node', [dashExtraPath, '--json'], {
|
|
86
|
+
cwd: aesopRoot,
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
AESOP_ROOT: aesopRoot
|
|
90
|
+
},
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
let resolved = false;
|
|
98
|
+
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
resolved = true;
|
|
102
|
+
if (proc && !proc.killed) {
|
|
103
|
+
try {
|
|
104
|
+
proc.kill();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const timeoutHandle = setTimeout(() => {
|
|
113
|
+
cleanup();
|
|
114
|
+
resolve({ unavailable: 'dash-extra.mjs timeout' });
|
|
115
|
+
}, 6000);
|
|
116
|
+
|
|
117
|
+
proc.stdout.on('data', (data) => {
|
|
118
|
+
stdout += data.toString();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.stderr.on('data', (data) => {
|
|
122
|
+
stderr += data.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
proc.on('close', (code) => {
|
|
126
|
+
clearTimeout(timeoutHandle);
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
resolved = true;
|
|
129
|
+
if (code === 0 && stdout.trim()) {
|
|
130
|
+
try {
|
|
131
|
+
const agents = JSON.parse(stdout);
|
|
132
|
+
resolve({
|
|
133
|
+
agents: Array.isArray(agents) ? agents : [],
|
|
134
|
+
count: Array.isArray(agents) ? agents.length : 0
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
resolve({ unavailable: 'failed to parse dash-extra output' });
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
resolve({ unavailable: 'dash-extra.mjs failed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
proc.on('error', (err) => {
|
|
146
|
+
clearTimeout(timeoutHandle);
|
|
147
|
+
cleanup();
|
|
148
|
+
if (!resolved) {
|
|
149
|
+
resolved = true;
|
|
150
|
+
resolve({ unavailable: 'dash-extra.mjs spawn error' });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} catch (e) {
|
|
154
|
+
resolve({ unavailable: 'exception spawning dash-extra.mjs' });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getTrackerLaneCounts(trackerPath) {
|
|
160
|
+
const tracker = loadJSON(trackerPath);
|
|
161
|
+
if (!tracker) {
|
|
162
|
+
return { unavailable: 'tracker.json not found or malformed' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const items = tracker.items || [];
|
|
166
|
+
const byLane = {};
|
|
167
|
+
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
const lane = item.lane || 'unknown';
|
|
170
|
+
byLane[lane] = (byLane[lane] || 0) + 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
total_items: items.length,
|
|
175
|
+
by_lane: byLane
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getOrchestratorStatus(orchStatusPath) {
|
|
180
|
+
const status = loadJSON(orchStatusPath);
|
|
181
|
+
if (!status) {
|
|
182
|
+
return { unavailable: 'orchestrator-status.json not found or malformed' };
|
|
183
|
+
}
|
|
184
|
+
return status;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Main
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
(async function main() {
|
|
192
|
+
try {
|
|
193
|
+
// Determine AESOP_ROOT
|
|
194
|
+
const aesopRoot = process.env.AESOP_ROOT || CURRENT_DIR;
|
|
195
|
+
|
|
196
|
+
const stateDir = path.join(aesopRoot, 'state');
|
|
197
|
+
const watchdogHeartbeatPath = path.join(stateDir, '.watchdog-heartbeat');
|
|
198
|
+
const monitorHeartbeatPath = path.join(stateDir, '.monitor-heartbeat');
|
|
199
|
+
const trackerPath = path.join(stateDir, 'tracker.json');
|
|
200
|
+
const orchStatusPath = path.join(stateDir, 'orchestrator-status.json');
|
|
201
|
+
|
|
202
|
+
// Gather all data concurrently
|
|
203
|
+
const [watchdogTs, monitorTs, agentsData, trackerData, orchStatusData] = await Promise.all([
|
|
204
|
+
Promise.resolve(readHeartbeat(watchdogHeartbeatPath)),
|
|
205
|
+
Promise.resolve(readHeartbeat(monitorHeartbeatPath)),
|
|
206
|
+
getFleetAgents(aesopRoot),
|
|
207
|
+
Promise.resolve(getTrackerLaneCounts(trackerPath)),
|
|
208
|
+
Promise.resolve(getOrchestratorStatus(orchStatusPath))
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
// Build result object
|
|
212
|
+
const result = {
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
aesop_root: aesopRoot,
|
|
215
|
+
heartbeats: {
|
|
216
|
+
watchdog: null,
|
|
217
|
+
monitor: null
|
|
218
|
+
},
|
|
219
|
+
agents: agentsData,
|
|
220
|
+
tracker: trackerData,
|
|
221
|
+
orchestrator: orchStatusData
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Process watchdog heartbeat
|
|
225
|
+
const watchdogStatus = getHeartbeatStatus(watchdogTs, WATCHDOG_STALE_THRESHOLD);
|
|
226
|
+
if (watchdogStatus.age !== null) {
|
|
227
|
+
result.heartbeats.watchdog = {
|
|
228
|
+
age_seconds: watchdogStatus.age,
|
|
229
|
+
status: watchdogStatus.status,
|
|
230
|
+
threshold_seconds: WATCHDOG_STALE_THRESHOLD
|
|
231
|
+
};
|
|
232
|
+
} else {
|
|
233
|
+
result.heartbeats.watchdog = {
|
|
234
|
+
unavailable: watchdogStatus.status
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Process monitor heartbeat
|
|
239
|
+
const monitorStatus = getHeartbeatStatus(monitorTs, MONITOR_STALE_THRESHOLD);
|
|
240
|
+
if (monitorStatus.age !== null) {
|
|
241
|
+
result.heartbeats.monitor = {
|
|
242
|
+
age_seconds: monitorStatus.age,
|
|
243
|
+
status: monitorStatus.status,
|
|
244
|
+
threshold_seconds: MONITOR_STALE_THRESHOLD
|
|
245
|
+
};
|
|
246
|
+
} else {
|
|
247
|
+
result.heartbeats.monitor = {
|
|
248
|
+
unavailable: monitorStatus.status
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Output JSON
|
|
253
|
+
console.log(JSON.stringify(result, null, 2));
|
|
254
|
+
|
|
255
|
+
process.exitCode = 0;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`Error gathering fleet snapshot: ${err.message}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
}
|
|
260
|
+
})();
|