@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +101 -2
  2. package/README.md +66 -15
  3. package/bin/cli.js +164 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. 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}"
@@ -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
- conn = sqlite3.connect(self.db_path)
33
- try:
34
- conn.execute("PRAGMA journal_mode=WAL")
35
- conn.execute("PRAGMA busy_timeout=5000")
36
- conn.execute(
37
- """
38
- CREATE TABLE IF NOT EXISTS events (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- ts REAL NOT NULL,
41
- actor TEXT NOT NULL,
42
- stream TEXT NOT NULL,
43
- type TEXT NOT NULL,
44
- payload TEXT NOT NULL,
45
- version INTEGER NOT NULL
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
- conn.execute(
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
- conn.execute(
66
- "CREATE INDEX IF NOT EXISTS idx_snapshots_stream_version "
67
- "ON snapshots(stream, event_version)"
68
- )
69
- conn.commit()
70
- finally:
71
- conn.close()
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
- def append(self, stream: str, event_type: str, payload: dict, actor: str = "system") -> int:
74
- """Append one event to ``stream``; return its new per-stream version (1-based)."""
75
- conn = sqlite3.connect(self.db_path)
76
- try:
77
- conn.execute("PRAGMA busy_timeout=5000")
78
- # BEGIN IMMEDIATE takes the write lock up front so the
79
- # read-max-then-insert below is atomic under contention.
80
- conn.execute("BEGIN IMMEDIATE")
81
- row = conn.execute(
82
- "SELECT COALESCE(MAX(version), 0) FROM events WHERE stream = ?",
83
- (stream,),
84
- ).fetchone()
85
- version = row[0] + 1
86
- conn.execute(
87
- "INSERT INTO events (ts, actor, stream, type, payload, version) "
88
- "VALUES (?, ?, ?, ?, ?, ?)",
89
- (time.time(), actor, stream, event_type, json.dumps(payload), version),
90
- )
91
- conn.commit()
92
- return version
93
- finally:
94
- conn.close()
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
- conn = sqlite3.connect(self.db_path)
142
- try:
143
- conn.execute("PRAGMA busy_timeout=5000")
144
- projection_json = json.dumps(projection, separators=(",", ":"), sort_keys=True)
145
- checksum = hashlib.sha256(projection_json.encode()).hexdigest()
146
- conn.execute(
147
- "INSERT INTO snapshots (ts, stream, event_version, projection, checksum) "
148
- "VALUES (?, ?, ?, ?, ?)",
149
- (time.time(), stream, event_version, projection_json, checksum),
150
- )
151
- conn.commit()
152
- finally:
153
- conn.close()
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
+ }