@matt82198/aesop 0.1.0-rc.1 → 0.2.0
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 +101 -2
- package/README.md +66 -15
- package/bin/cli.js +164 -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/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -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 +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- 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 +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- 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 +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- 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 +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -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_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 +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- 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 +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -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}"
|
package/state_store/store.py
CHANGED
|
@@ -16,6 +16,60 @@ import sqlite3
|
|
|
16
16
|
import sys
|
|
17
17
|
import time
|
|
18
18
|
|
|
19
|
+
# Retry configuration for database lock contention (especially under parallel CI shards)
|
|
20
|
+
_MAX_DB_LOCK_RETRIES = 3
|
|
21
|
+
_DB_LOCK_RETRY_BASE_DELAY = 0.05 # 50ms base, exponential backoff
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConcurrencyConflict(Exception):
|
|
25
|
+
"""Raised when an optimistic concurrency control check fails on append.
|
|
26
|
+
|
|
27
|
+
Signifies that the event stream's current version does not match the
|
|
28
|
+
expected version provided to append(), indicating that another writer
|
|
29
|
+
has extended the stream since the caller last read it. No event is
|
|
30
|
+
written when this exception is raised (fail-closed).
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
expected_version: The version the caller expected
|
|
34
|
+
actual_version: The version found in the database
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, expected_version: int, actual_version: int):
|
|
38
|
+
self.expected_version = expected_version
|
|
39
|
+
self.actual_version = actual_version
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"Concurrency conflict: expected version {expected_version}, "
|
|
42
|
+
f"but stream is at version {actual_version}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _retry_on_db_lock(func, max_retries=_MAX_DB_LOCK_RETRIES, base_delay=_DB_LOCK_RETRY_BASE_DELAY):
|
|
47
|
+
"""Retry a callable up to max_retries times if it hits 'database is locked' error.
|
|
48
|
+
|
|
49
|
+
Used for defense-in-depth when multiple EventStore instances or CI shards
|
|
50
|
+
briefly contend on WAL locks. Implements exponential backoff.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
func: A callable that may raise sqlite3.OperationalError('database is locked').
|
|
54
|
+
max_retries: Number of attempts (default 3).
|
|
55
|
+
base_delay: Base delay in seconds for exponential backoff (default 0.05s).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The return value of func().
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
sqlite3.OperationalError: If all retries are exhausted or non-lock error occurs.
|
|
62
|
+
"""
|
|
63
|
+
for attempt in range(max_retries):
|
|
64
|
+
try:
|
|
65
|
+
return func()
|
|
66
|
+
except sqlite3.OperationalError as e:
|
|
67
|
+
if "database is locked" not in str(e):
|
|
68
|
+
raise
|
|
69
|
+
if attempt == max_retries - 1:
|
|
70
|
+
raise
|
|
71
|
+
time.sleep(base_delay * (2 ** attempt)) # exponential: 0.05s, 0.1s, 0.2s
|
|
72
|
+
|
|
19
73
|
|
|
20
74
|
class EventStore:
|
|
21
75
|
"""Append-only event log stored at ``db_path``.
|
|
@@ -25,73 +79,128 @@ class EventStore:
|
|
|
25
79
|
``PRAGMA busy_timeout`` and assigns the per-stream version inside a
|
|
26
80
|
``BEGIN IMMEDIATE`` transaction, so the read-max-version-then-insert is
|
|
27
81
|
atomic and two writers can never collide or duplicate a version.
|
|
82
|
+
|
|
83
|
+
Implements defense-in-depth retry logic for 'database is locked' errors that
|
|
84
|
+
can occur under heavy parallel contention (e.g., CI shards).
|
|
28
85
|
"""
|
|
29
86
|
|
|
30
87
|
def __init__(self, db_path: str):
|
|
31
88
|
self.db_path = db_path
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
conn.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
89
|
+
|
|
90
|
+
def _init_db():
|
|
91
|
+
conn = sqlite3.connect(self.db_path)
|
|
92
|
+
try:
|
|
93
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
94
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
95
|
+
conn.execute(
|
|
96
|
+
"""
|
|
97
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
ts REAL NOT NULL,
|
|
100
|
+
actor TEXT NOT NULL,
|
|
101
|
+
stream TEXT NOT NULL,
|
|
102
|
+
type TEXT NOT NULL,
|
|
103
|
+
payload TEXT NOT NULL,
|
|
104
|
+
version INTEGER NOT NULL
|
|
105
|
+
)
|
|
106
|
+
"""
|
|
46
107
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"CREATE INDEX IF NOT EXISTS idx_events_stream_version "
|
|
51
|
-
"ON events(stream, version)"
|
|
52
|
-
)
|
|
53
|
-
conn.execute(
|
|
54
|
-
"""
|
|
55
|
-
CREATE TABLE IF NOT EXISTS snapshots (
|
|
56
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
-
ts REAL NOT NULL,
|
|
58
|
-
stream TEXT NOT NULL,
|
|
59
|
-
event_version INTEGER NOT NULL,
|
|
60
|
-
projection TEXT NOT NULL,
|
|
61
|
-
checksum TEXT NOT NULL
|
|
108
|
+
conn.execute(
|
|
109
|
+
"CREATE INDEX IF NOT EXISTS idx_events_stream_version "
|
|
110
|
+
"ON events(stream, version)"
|
|
62
111
|
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
112
|
+
conn.execute(
|
|
113
|
+
"""
|
|
114
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
116
|
+
ts REAL NOT NULL,
|
|
117
|
+
stream TEXT NOT NULL,
|
|
118
|
+
event_version INTEGER NOT NULL,
|
|
119
|
+
projection TEXT NOT NULL,
|
|
120
|
+
checksum TEXT NOT NULL
|
|
121
|
+
)
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
conn.execute(
|
|
125
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshots_stream_version "
|
|
126
|
+
"ON snapshots(stream, event_version)"
|
|
127
|
+
)
|
|
128
|
+
conn.commit()
|
|
129
|
+
finally:
|
|
130
|
+
conn.close()
|
|
72
131
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
132
|
+
_retry_on_db_lock(_init_db)
|
|
133
|
+
|
|
134
|
+
def append(
|
|
135
|
+
self,
|
|
136
|
+
stream: str,
|
|
137
|
+
event_type: str,
|
|
138
|
+
payload: dict,
|
|
139
|
+
actor: str = "system",
|
|
140
|
+
expected_version: int | None = None,
|
|
141
|
+
) -> int:
|
|
142
|
+
"""Append one event to ``stream``; return its new per-stream version (1-based).
|
|
143
|
+
|
|
144
|
+
Supports optimistic concurrency control via the optional ``expected_version``
|
|
145
|
+
parameter. If provided, the append succeeds ONLY if the stream's current
|
|
146
|
+
maximum version equals ``expected_version``. If the versions do not match,
|
|
147
|
+
raises ConcurrencyConflict WITHOUT writing any event (fail-closed, atomic).
|
|
148
|
+
|
|
149
|
+
The version check and append are both performed under BEGIN IMMEDIATE so
|
|
150
|
+
they are atomic (no TOCTOU window).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
stream: The stream name (e.g. "tracker", "claims")
|
|
154
|
+
event_type: The event type (e.g. "claim_requested", "claim_released")
|
|
155
|
+
payload: The event payload dict (will be JSON-serialized)
|
|
156
|
+
actor: The actor performing the append (default "system")
|
|
157
|
+
expected_version: Optional OCC check: if provided, append only if the
|
|
158
|
+
stream's current max version equals this value.
|
|
159
|
+
If None (default), OCC is disabled (backward-compatible).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The new per-stream version assigned to this event (1-based).
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ConcurrencyConflict: If expected_version is provided and the stream's
|
|
166
|
+
current max version does not match. The exception
|
|
167
|
+
carries expected_version and actual_version for retry.
|
|
168
|
+
No event is written when this exception is raised.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def _do_append():
|
|
172
|
+
conn = sqlite3.connect(self.db_path)
|
|
173
|
+
try:
|
|
174
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
175
|
+
# BEGIN IMMEDIATE takes the write lock up front so the
|
|
176
|
+
# read-max-then-insert below is atomic under contention.
|
|
177
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
178
|
+
row = conn.execute(
|
|
179
|
+
"SELECT COALESCE(MAX(version), 0) FROM events WHERE stream = ?",
|
|
180
|
+
(stream,),
|
|
181
|
+
).fetchone()
|
|
182
|
+
current_version = row[0]
|
|
183
|
+
|
|
184
|
+
# Perform OCC check (if expected_version provided)
|
|
185
|
+
if expected_version is not None:
|
|
186
|
+
if current_version != expected_version:
|
|
187
|
+
# Mismatch: abort transaction and raise
|
|
188
|
+
conn.rollback()
|
|
189
|
+
raise ConcurrencyConflict(expected_version, current_version)
|
|
190
|
+
|
|
191
|
+
# OCC check passed or not enabled: proceed with append
|
|
192
|
+
version = current_version + 1
|
|
193
|
+
conn.execute(
|
|
194
|
+
"INSERT INTO events (ts, actor, stream, type, payload, version) "
|
|
195
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
196
|
+
(time.time(), actor, stream, event_type, json.dumps(payload), version),
|
|
197
|
+
)
|
|
198
|
+
conn.commit()
|
|
199
|
+
return version
|
|
200
|
+
finally:
|
|
201
|
+
conn.close()
|
|
202
|
+
|
|
203
|
+
return _retry_on_db_lock(_do_append)
|
|
95
204
|
|
|
96
205
|
def read(self, stream: str) -> list:
|
|
97
206
|
"""Return all events for ``stream`` ascending by version (empty if none)."""
|
|
@@ -138,19 +247,22 @@ class EventStore:
|
|
|
138
247
|
event_version: the per-stream event version this snapshot was computed through
|
|
139
248
|
projection: the materialized state dict (e.g. the full tracker projection)
|
|
140
249
|
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
250
|
+
def _do_save_snapshot():
|
|
251
|
+
conn = sqlite3.connect(self.db_path)
|
|
252
|
+
try:
|
|
253
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
254
|
+
projection_json = json.dumps(projection, separators=(",", ":"), sort_keys=True)
|
|
255
|
+
checksum = hashlib.sha256(projection_json.encode()).hexdigest()
|
|
256
|
+
conn.execute(
|
|
257
|
+
"INSERT INTO snapshots (ts, stream, event_version, projection, checksum) "
|
|
258
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
259
|
+
(time.time(), stream, event_version, projection_json, checksum),
|
|
260
|
+
)
|
|
261
|
+
conn.commit()
|
|
262
|
+
finally:
|
|
263
|
+
conn.close()
|
|
264
|
+
|
|
265
|
+
_retry_on_db_lock(_do_save_snapshot)
|
|
154
266
|
|
|
155
267
|
def read_snapshot(self, stream: str) -> tuple | None:
|
|
156
268
|
"""Read the most recent snapshot for a stream.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Data Pipeline & Analytics",
|
|
3
|
+
"description": "Bootstrap a data project wave with ETL pipeline, data warehouse, and analytics components",
|
|
4
|
+
"categories": ["data-engineering", "analytics", "infrastructure"],
|
|
5
|
+
"items": [
|
|
6
|
+
{
|
|
7
|
+
"slug": "etl-pipeline",
|
|
8
|
+
"ownsFiles": [
|
|
9
|
+
"src/pipeline/extract.py",
|
|
10
|
+
"src/pipeline/transform.py",
|
|
11
|
+
"src/pipeline/load.py",
|
|
12
|
+
"src/pipeline/main.py",
|
|
13
|
+
"src/pipeline/config.yaml",
|
|
14
|
+
"tests/pipeline/",
|
|
15
|
+
"dags/",
|
|
16
|
+
"requirements.txt"
|
|
17
|
+
],
|
|
18
|
+
"prompt": "Build the {project_name} ETL pipeline. Implement data extraction from CSV/APIs, transformation logic for cleaning and aggregation, and loading into a data warehouse. Use Apache Airflow or Python scheduler for orchestration. Include error handling, logging, and retry logic. Create sample DAGs that run daily. Tests must validate transform outputs.",
|
|
19
|
+
"testCmd": "python -m pytest tests/pipeline/ -v --cov=src/pipeline",
|
|
20
|
+
"workDir": "{base_dir}"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"slug": "data-warehouse",
|
|
24
|
+
"ownsFiles": [
|
|
25
|
+
"warehouse/schema/",
|
|
26
|
+
"warehouse/migrations/",
|
|
27
|
+
"warehouse/seeds/",
|
|
28
|
+
"warehouse/macros/",
|
|
29
|
+
"dbt_project.yml",
|
|
30
|
+
"warehouse/models/",
|
|
31
|
+
"tests/warehouse/"
|
|
32
|
+
],
|
|
33
|
+
"prompt": "Set up the {project_name} data warehouse using dbt or SQL-based approach. Design fact and dimension tables for analytics. Create data models, transformations, and data quality tests. Build marts for reporting (daily sales, user cohorts, product performance). Use {base_dir}/warehouse as root. Ensure idempotent, incremental loading strategies.",
|
|
34
|
+
"testCmd": "dbt test --select +state:modified --state ./target",
|
|
35
|
+
"workDir": "{base_dir}"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"slug": "analytics-dashboard",
|
|
39
|
+
"ownsFiles": [
|
|
40
|
+
"src/dashboard/app.py",
|
|
41
|
+
"src/dashboard/pages/",
|
|
42
|
+
"src/dashboard/components/",
|
|
43
|
+
"src/dashboard/utils/",
|
|
44
|
+
"tests/dashboard/",
|
|
45
|
+
"requirements-dash.txt"
|
|
46
|
+
],
|
|
47
|
+
"prompt": "Build the {project_name} analytics dashboard using Streamlit or Plotly Dash. Create interactive visualizations for: daily revenue trends, user acquisition cohorts, product performance leaderboard. Connect to the data warehouse for live queries. Add filters and date pickers. Deploy instructions for local and cloud. Responsive and accessible design.",
|
|
48
|
+
"testCmd": "python -m pytest tests/dashboard/ -v && streamlit run src/dashboard/app.py --logger.level=debug 2>&1 | head -50",
|
|
49
|
+
"workDir": "{base_dir}"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"slug": "data-quality",
|
|
53
|
+
"ownsFiles": [
|
|
54
|
+
"src/quality/checks.py",
|
|
55
|
+
"src/quality/validations.py",
|
|
56
|
+
"src/quality/reports.py",
|
|
57
|
+
"tests/quality/",
|
|
58
|
+
"config/quality.yaml"
|
|
59
|
+
],
|
|
60
|
+
"prompt": "Implement data quality framework for {project_name}. Create validation rules for data freshness, null percentages, value ranges. Build automated checks that run after ETL. Generate quality reports (pass/fail/anomalies). Integrate with monitoring (alerting on failures). Include documentation on data lineage and SLAs.",
|
|
61
|
+
"testCmd": "python -m pytest tests/quality/ -v",
|
|
62
|
+
"workDir": "{base_dir}"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|