@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""state_store.coordination — lease-by-append claim for exclusive resource access.
|
|
2
|
+
|
|
3
|
+
Implements DB-native CLAIM (lease-via-append) on top of the existing event log:
|
|
4
|
+
to claim resource R, append a claim_requested event to the 'claims' stream;
|
|
5
|
+
the winner is the lowest-version append for that resource key; readers fold
|
|
6
|
+
the stream to see who holds it.
|
|
7
|
+
|
|
8
|
+
Fail-CLOSED by construction: if you cannot append or read, you do NOT hold
|
|
9
|
+
the claim and must not dispatch. TTL + claim_released events handle crashed
|
|
10
|
+
holders (analogous to lock.mjs PID-liveness staleness, but expressed as events).
|
|
11
|
+
|
|
12
|
+
No risky changes to store.py or api.py — uses only existing append/read primitives.
|
|
13
|
+
|
|
14
|
+
Stdlib only: time.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fold_claims(events: list) -> dict[str, str]:
|
|
22
|
+
"""Fold a claims stream into the current state of who holds each resource.
|
|
23
|
+
|
|
24
|
+
Processes claim_requested and claim_released events to determine the winner
|
|
25
|
+
for each resource. The winner is the lowest-version un-released claim for
|
|
26
|
+
that resource (first serialized append wins; ties impossible because versions
|
|
27
|
+
are unique). A claim is valid only if it has NOT been released by a
|
|
28
|
+
subsequent claim_released event. If an instance releases and then re-claims,
|
|
29
|
+
the re-claim is the new active claim for that instance.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
events: list of event dicts from the claims stream
|
|
33
|
+
(as returned by EventStore.read('claims'))
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
dict mapping resource_id -> holding_instance_id for all currently
|
|
37
|
+
held resources. Empty dict if no claims exist or all have been released.
|
|
38
|
+
"""
|
|
39
|
+
# Track all claims by resource and instance (as list, to preserve order)
|
|
40
|
+
claims_by_resource = {} # resource -> {instance_id: [versions...]}
|
|
41
|
+
# Track all releases by resource and instance (as sorted list)
|
|
42
|
+
releases_by_resource = {} # resource -> {instance_id: [release_versions]}
|
|
43
|
+
|
|
44
|
+
# First pass: collect all claims and releases
|
|
45
|
+
for ev in events:
|
|
46
|
+
etype = ev.get("type")
|
|
47
|
+
payload = ev.get("payload") or {}
|
|
48
|
+
version = ev.get("version", 0)
|
|
49
|
+
|
|
50
|
+
if etype == "claim_requested":
|
|
51
|
+
resource = payload.get("resource")
|
|
52
|
+
instance_id = payload.get("instance_id")
|
|
53
|
+
if resource is not None and instance_id is not None:
|
|
54
|
+
if resource not in claims_by_resource:
|
|
55
|
+
claims_by_resource[resource] = {}
|
|
56
|
+
if instance_id not in claims_by_resource[resource]:
|
|
57
|
+
claims_by_resource[resource][instance_id] = []
|
|
58
|
+
claims_by_resource[resource][instance_id].append(version)
|
|
59
|
+
|
|
60
|
+
elif etype == "claim_released":
|
|
61
|
+
resource = payload.get("resource")
|
|
62
|
+
instance_id = payload.get("instance_id")
|
|
63
|
+
if resource is not None and instance_id is not None:
|
|
64
|
+
if resource not in releases_by_resource:
|
|
65
|
+
releases_by_resource[resource] = {}
|
|
66
|
+
if instance_id not in releases_by_resource[resource]:
|
|
67
|
+
releases_by_resource[resource][instance_id] = []
|
|
68
|
+
releases_by_resource[resource][instance_id].append(version)
|
|
69
|
+
|
|
70
|
+
# Second pass: determine current holders
|
|
71
|
+
holders = {}
|
|
72
|
+
for resource, claims_dict in claims_by_resource.items():
|
|
73
|
+
# For each instance claiming this resource, find the latest un-released claim
|
|
74
|
+
active_claims = {}
|
|
75
|
+
for instance_id, claim_versions in claims_dict.items():
|
|
76
|
+
releases = sorted(releases_by_resource.get(resource, {}).get(instance_id, []))
|
|
77
|
+
|
|
78
|
+
# Process claims in order, tracking "current active" claim within streaks
|
|
79
|
+
# (separated by releases). The current active claim is the latest claim
|
|
80
|
+
# that comes after the most recent release.
|
|
81
|
+
current_active = None
|
|
82
|
+
for claim_v in sorted(claim_versions):
|
|
83
|
+
# Check if there's a release between the current active and this claim
|
|
84
|
+
if current_active is not None:
|
|
85
|
+
# Check if current_active was released
|
|
86
|
+
if any(r > current_active for r in releases):
|
|
87
|
+
# Yes, released; start a new streak with this claim
|
|
88
|
+
current_active = claim_v
|
|
89
|
+
# else: already in this streak, keep current_active
|
|
90
|
+
else:
|
|
91
|
+
# First claim for this instance
|
|
92
|
+
current_active = claim_v
|
|
93
|
+
|
|
94
|
+
# After processing all claims, check if current_active is released
|
|
95
|
+
if current_active is not None:
|
|
96
|
+
if not any(r > current_active for r in releases):
|
|
97
|
+
# Not released; it's active
|
|
98
|
+
active_claims[instance_id] = current_active
|
|
99
|
+
|
|
100
|
+
# Find the minimum version among active claims (the winner)
|
|
101
|
+
if active_claims:
|
|
102
|
+
winner_instance = min(active_claims.keys(), key=lambda x: active_claims[x])
|
|
103
|
+
holders[resource] = winner_instance
|
|
104
|
+
|
|
105
|
+
return holders
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> bool:
|
|
109
|
+
"""Attempt to claim exclusive access to a resource.
|
|
110
|
+
|
|
111
|
+
Appends a claim_requested event to the 'claims' stream, then re-reads
|
|
112
|
+
and folds to check if this instance won the claim. Fail-CLOSED: if ANY
|
|
113
|
+
exception occurs (append fails, read fails, or exception during fold),
|
|
114
|
+
return False (claim not held, do not proceed).
|
|
115
|
+
|
|
116
|
+
If this instance does NOT win, it retracts its claim by appending a
|
|
117
|
+
claim_released event (scoped to this instance + resource) before returning
|
|
118
|
+
False. This prevents stale-claim resurrection: a losing claim left un-retracted
|
|
119
|
+
in the stream could later become the winner if the true holder releases.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
store: StateAPI or EventStore instance (must have append() and get() methods)
|
|
123
|
+
resource: the resource identifier to claim (e.g., "wave_123", "lane_0", ...)
|
|
124
|
+
instance_id: the instance identifier requesting the claim
|
|
125
|
+
ttl: time-to-live in seconds (default 300s = 5min); embedded in the payload
|
|
126
|
+
for later TTL-based expiry checks (not enforced here, but available
|
|
127
|
+
for reconciliation)
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
bool: True if this instance holds the claim after the append,
|
|
131
|
+
False otherwise (including on any error).
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
# Append claim request to the claims stream
|
|
135
|
+
store.append(
|
|
136
|
+
"claims",
|
|
137
|
+
"claim_requested",
|
|
138
|
+
{"resource": resource, "instance_id": instance_id, "ttl": ttl},
|
|
139
|
+
actor=instance_id,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Re-read claims stream and fold to see current state
|
|
143
|
+
events = store.get("claims")
|
|
144
|
+
claims = fold_claims(events)
|
|
145
|
+
|
|
146
|
+
# Check if we won
|
|
147
|
+
if claims.get(resource) == instance_id:
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# We did NOT win: retract our claim to prevent stale-claim resurrection.
|
|
151
|
+
# Fail-closed: if retract fails, still return False (never a false grant).
|
|
152
|
+
try:
|
|
153
|
+
store.append(
|
|
154
|
+
"claims",
|
|
155
|
+
"claim_released",
|
|
156
|
+
{"resource": resource, "instance_id": instance_id},
|
|
157
|
+
actor=instance_id,
|
|
158
|
+
)
|
|
159
|
+
except Exception:
|
|
160
|
+
# Retract failed, but we still don't hold the claim; return False.
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
return False
|
|
164
|
+
except Exception:
|
|
165
|
+
# Fail-closed: any exception means we don't hold the claim
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def release(store, resource: str, instance_id: str) -> None:
|
|
170
|
+
"""Release a claimed resource.
|
|
171
|
+
|
|
172
|
+
Appends a claim_released event to the 'claims' stream, marking that
|
|
173
|
+
this instance no longer holds the resource. Idempotent: releasing
|
|
174
|
+
a resource that was not held is a no-op (the fold will see the
|
|
175
|
+
release but no matching claim, so it has no effect).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
store: StateAPI or EventStore instance (must have append() method)
|
|
179
|
+
resource: the resource identifier being released
|
|
180
|
+
instance_id: the instance identifier releasing the claim
|
|
181
|
+
"""
|
|
182
|
+
store.append(
|
|
183
|
+
"claims",
|
|
184
|
+
"claim_released",
|
|
185
|
+
{"resource": resource, "instance_id": instance_id},
|
|
186
|
+
actor=instance_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def current_holder(store, resource: str) -> str | None:
|
|
191
|
+
"""Return the instance_id currently holding a resource, or None if unclaimed.
|
|
192
|
+
|
|
193
|
+
Reads and folds the claims stream to find the winner for the resource.
|
|
194
|
+
Returns None if the resource is not claimed or has been released.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
store: StateAPI or EventStore instance (must have get() method)
|
|
198
|
+
resource: the resource identifier to query
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The instance_id of the current holder, or None if unclaimed.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
events = store.get("claims")
|
|
205
|
+
claims = fold_claims(events)
|
|
206
|
+
return claims.get(resource)
|
|
207
|
+
except Exception:
|
|
208
|
+
# Fail-closed: on any error, we cannot determine the holder
|
|
209
|
+
return None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""state_store.identity — stable per-instance orchestrator identity.
|
|
2
|
+
|
|
3
|
+
Derives a stable instance_id for each orchestrator process: hostname:pid:nonce.
|
|
4
|
+
The nonce is a short random string (for entropy) so two orchestrators on the
|
|
5
|
+
same box with different pids still get different identities.
|
|
6
|
+
|
|
7
|
+
Identity is deterministic within a process lifetime (cached) but varies
|
|
8
|
+
across process restarts (the nonce is not stable across restarts by design,
|
|
9
|
+
so instance_id changes on restart). Recommended: auto-derived for zero-config
|
|
10
|
+
solo mode, with optional aesop.config.json override for stable identities
|
|
11
|
+
across restarts.
|
|
12
|
+
|
|
13
|
+
Stdlib only: socket, os, random, string.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import socket
|
|
20
|
+
import string
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Cached instance_id, computed once per process
|
|
24
|
+
_INSTANCE_ID_CACHE: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_instance_id() -> str:
|
|
28
|
+
"""Return a stable per-instance orchestrator id.
|
|
29
|
+
|
|
30
|
+
Computed once per process and cached. The id is the form:
|
|
31
|
+
hostname:pid:nonce
|
|
32
|
+
|
|
33
|
+
Where nonce is a random 6-character alphanumeric string, used to
|
|
34
|
+
differentiate multiple orchestrators on the same box.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A stable string identifier for this orchestrator instance.
|
|
38
|
+
"""
|
|
39
|
+
global _INSTANCE_ID_CACHE
|
|
40
|
+
if _INSTANCE_ID_CACHE is None:
|
|
41
|
+
_INSTANCE_ID_CACHE = _derive_instance_id()
|
|
42
|
+
return _INSTANCE_ID_CACHE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _derive_instance_id() -> str:
|
|
46
|
+
"""Derive a new instance_id from hostname, pid, and random nonce."""
|
|
47
|
+
hostname = socket.gethostname()
|
|
48
|
+
pid = os.getpid()
|
|
49
|
+
# Random 6-char alphanumeric nonce for entropy across processes
|
|
50
|
+
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
51
|
+
return f"{hostname}:{pid}:{nonce}"
|
|
@@ -124,3 +124,66 @@ def save_snapshot(store, stream: str, event_version: int, projection: dict) -> N
|
|
|
124
124
|
projection: the materialized state dict (e.g. full tracker projection)
|
|
125
125
|
"""
|
|
126
126
|
store.save_snapshot(stream, event_version, projection)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
AGENT_LIFECYCLE_VERSION = 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def project_agent_lifecycle(events: list) -> dict:
|
|
133
|
+
"""Project current agent lifecycle state from agent_* events.
|
|
134
|
+
|
|
135
|
+
Folds agent lifecycle events into a map of agent_id -> {state, transitions, last_activity}.
|
|
136
|
+
|
|
137
|
+
Event types:
|
|
138
|
+
- agent_dispatched: payload {"agent_id", "timestamp"}; marks dispatch start
|
|
139
|
+
- agent_working: payload {"agent_id", "timestamp"}; marks work in progress
|
|
140
|
+
- agent_done: payload {"agent_id", "timestamp"}; marks completion
|
|
141
|
+
- agent_stalled: payload {"agent_id", "timestamp"}; marks stall/error
|
|
142
|
+
|
|
143
|
+
Returns a dict: {"version": 1, "agents": [{"id", "state", "transitions", "last_activity"}, ...]}
|
|
144
|
+
where transitions is a list of {state, at (ISO 8601 timestamp)} in chronological order.
|
|
145
|
+
"""
|
|
146
|
+
agents = {}
|
|
147
|
+
|
|
148
|
+
for ev in events:
|
|
149
|
+
etype = ev.get("type")
|
|
150
|
+
payload = ev.get("payload") or {}
|
|
151
|
+
|
|
152
|
+
if etype in ("agent_dispatched", "agent_working", "agent_done", "agent_stalled"):
|
|
153
|
+
agent_id = payload.get("agent_id")
|
|
154
|
+
timestamp = payload.get("timestamp")
|
|
155
|
+
|
|
156
|
+
if agent_id is None:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Initialize agent if first time seeing it
|
|
160
|
+
if agent_id not in agents:
|
|
161
|
+
agents[agent_id] = {
|
|
162
|
+
"id": agent_id,
|
|
163
|
+
"state": None,
|
|
164
|
+
"transitions": [],
|
|
165
|
+
"last_activity": None,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Map event type to state
|
|
169
|
+
state_map = {
|
|
170
|
+
"agent_dispatched": "dispatch",
|
|
171
|
+
"agent_working": "working",
|
|
172
|
+
"agent_done": "done",
|
|
173
|
+
"agent_stalled": "stalled",
|
|
174
|
+
}
|
|
175
|
+
new_state = state_map.get(etype)
|
|
176
|
+
|
|
177
|
+
# Only append transition if state actually changed
|
|
178
|
+
if new_state and new_state != agents[agent_id]["state"]:
|
|
179
|
+
agents[agent_id]["state"] = new_state
|
|
180
|
+
if timestamp:
|
|
181
|
+
agents[agent_id]["transitions"].append({
|
|
182
|
+
"state": new_state,
|
|
183
|
+
"at": timestamp,
|
|
184
|
+
})
|
|
185
|
+
agents[agent_id]["last_activity"] = timestamp
|
|
186
|
+
|
|
187
|
+
# Return as versioned list (ordered by agent_id for determinism)
|
|
188
|
+
agent_list = sorted(agents.values(), key=lambda a: a["id"])
|
|
189
|
+
return {"version": AGENT_LIFECYCLE_VERSION, "agents": agent_list}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
state_store.read_api — Typed read-only facade for state surfaces.
|
|
4
|
+
|
|
5
|
+
Consolidates read patterns for: tracker snapshot, orchestrator-status,
|
|
6
|
+
heartbeat freshness, and ledger rows. This facade allows the underlying
|
|
7
|
+
state representation to change (git → SQLite → Postgres) without altering
|
|
8
|
+
caller code.
|
|
9
|
+
|
|
10
|
+
Callers use:
|
|
11
|
+
api = ReadAPI(state_dir)
|
|
12
|
+
tracker = api.read_tracker_snapshot()
|
|
13
|
+
status = api.read_orchestrator_status()
|
|
14
|
+
is_fresh = api.is_orchestrator_status_fresh(threshold_s=300)
|
|
15
|
+
is_fresh = api.check_heartbeat_fresh(".watchdog-heartbeat", 200)
|
|
16
|
+
rows = api.read_ledger_rows()
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Ensure tools module is importable (sys.path fix for bootstrapping)
|
|
24
|
+
repo_root = Path(__file__).parent.parent
|
|
25
|
+
if str(repo_root) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(repo_root))
|
|
27
|
+
|
|
28
|
+
from tools.common import check_heartbeat_staleness
|
|
29
|
+
from tools.fleet_ledger import parse_ledger_rows as parse_ledger_rows_impl
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReadAPI:
|
|
33
|
+
"""Read-only facade over state surfaces (tracker, orchestrator-status, heartbeats, ledger).
|
|
34
|
+
|
|
35
|
+
Designed to be swappable: backends can change (git → SQLite → Postgres) without
|
|
36
|
+
altering call sites. Current implementation reads from filesystem; future may
|
|
37
|
+
read from SQLite projections or API.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, state_dir):
|
|
41
|
+
"""Initialize the read API with a state directory.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
state_dir: Path to the state directory (e.g., "state" or "/absolute/path/state")
|
|
45
|
+
"""
|
|
46
|
+
self.state_dir = Path(state_dir)
|
|
47
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def read_tracker_snapshot(self):
|
|
50
|
+
"""Read the current tracker snapshot.
|
|
51
|
+
|
|
52
|
+
Returns the tracker.json file, or an empty dict if missing/unreadable.
|
|
53
|
+
In the future, this may read from the SQLite projection instead.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
dict: Tracker snapshot (items, metadata, etc.) or {} if unavailable.
|
|
57
|
+
"""
|
|
58
|
+
tracker_file = self.state_dir / "tracker.json"
|
|
59
|
+
if not tracker_file.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
content = tracker_file.read_text(encoding="utf-8")
|
|
64
|
+
return json.loads(content)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Malformed JSON or read error: return empty dict (fail-open)
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
def read_orchestrator_status(self):
|
|
70
|
+
"""Read orchestrator-status.json if present.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
dict with "updated_at" (required) and optional "phase", "activity" fields,
|
|
74
|
+
or None if file missing/unreadable.
|
|
75
|
+
"""
|
|
76
|
+
status_file = self.state_dir / "orchestrator-status.json"
|
|
77
|
+
if not status_file.exists():
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
content = status_file.read_text(encoding="utf-8")
|
|
82
|
+
data = json.loads(content)
|
|
83
|
+
# Only require updated_at field for freshness checks;
|
|
84
|
+
# phase is optional (may not be set when status.json created before phase exists)
|
|
85
|
+
# Don't validate here—let callers decide what fields are required for their use case
|
|
86
|
+
return data
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def is_orchestrator_status_fresh(self, threshold_s=300):
|
|
91
|
+
"""Check if orchestrator-status.json exists and is fresh.
|
|
92
|
+
|
|
93
|
+
Reads updated_at timestamp and compares to current time.
|
|
94
|
+
Returns False if file missing, unparseable, or stale.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
threshold_s: Staleness threshold in seconds (default: 300s)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
bool: True if file is fresh, False if stale/missing/malformed.
|
|
101
|
+
"""
|
|
102
|
+
status = self.read_orchestrator_status()
|
|
103
|
+
if status is None:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
updated_at_str = status.get("updated_at")
|
|
108
|
+
if not updated_at_str:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Parse ISO format timestamp (handle both "Z" and "+00:00")
|
|
112
|
+
normalized = updated_at_str.replace("Z", "+00:00")
|
|
113
|
+
updated_at = datetime.fromisoformat(normalized)
|
|
114
|
+
|
|
115
|
+
now = datetime.now(timezone.utc)
|
|
116
|
+
age = (now - updated_at).total_seconds()
|
|
117
|
+
|
|
118
|
+
# Treat future-dated timestamps as stale (fail-closed)
|
|
119
|
+
if age < -120: # More than 2min in future = clock skew, treat as stale
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Clamp small negative ages to 0
|
|
123
|
+
age = max(0, age)
|
|
124
|
+
|
|
125
|
+
return age < threshold_s
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def check_heartbeat_fresh(self, heartbeat_filename, threshold_s=200):
|
|
130
|
+
"""Check if a heartbeat file is fresh.
|
|
131
|
+
|
|
132
|
+
Delegates to tools.common.check_heartbeat_staleness (single source of truth).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
heartbeat_filename: Filename in state_dir (e.g., ".watchdog-heartbeat")
|
|
136
|
+
threshold_s: Staleness threshold in seconds (default: 200s)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
bool: True if file exists and is fresh, False otherwise.
|
|
140
|
+
"""
|
|
141
|
+
hb_file = self.state_dir / heartbeat_filename
|
|
142
|
+
# Use the shared implementation from common.py; invert staleness to freshness
|
|
143
|
+
is_stale, _, _ = check_heartbeat_staleness(hb_file, threshold_s)
|
|
144
|
+
return not is_stale
|
|
145
|
+
|
|
146
|
+
def read_ledger_rows(self):
|
|
147
|
+
"""Read OUTCOMES-LEDGER.md and parse rows.
|
|
148
|
+
|
|
149
|
+
Delegates to tools.fleet_ledger.parse_ledger_rows() (single source of truth).
|
|
150
|
+
Returns empty list if file missing or unparseable.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
list: List of row dicts parsed from the ledger table, or [].
|
|
154
|
+
"""
|
|
155
|
+
# Use the shared implementation from fleet_ledger.py
|
|
156
|
+
return parse_ledger_rows_impl()
|