@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5
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 +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Example IOC/secret scanner — copy to fleet-scan.mjs and configure paths.
|
|
2
|
+
// Scans committed code and fleet transcripts for security/alignment red-flags.
|
|
3
|
+
// Runs each watchdog cycle; marks findings in SECURITY-ALERTS.log. Never blocks the fleet.
|
|
4
|
+
//
|
|
5
|
+
// SETUP:
|
|
6
|
+
// 1. Copy this file to fleet-scan.mjs (in the same directory)
|
|
7
|
+
// 2. Edit the REPOS and PROJECT_ROOTS configuration below to match your fleet setup
|
|
8
|
+
// 3. Configure paths via aesop.config.json or environment variables:
|
|
9
|
+
// - AESOP_FLEET_ROOT: root directory containing your project repositories
|
|
10
|
+
// - AESOP_TRANSCRIPTS_ROOT: root directory containing ~/.claude/projects transcripts
|
|
11
|
+
// 4. Ensure aesop.config.json contains repo definitions (see aesop.config.example.json)
|
|
12
|
+
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
// Load configuration from aesop.config.json or use environment variable defaults
|
|
19
|
+
function loadConfig() {
|
|
20
|
+
const configPath = process.env.AESOP_CONFIG || path.join(process.cwd(), 'aesop.config.json');
|
|
21
|
+
let config = {};
|
|
22
|
+
if (fs.existsSync(configPath)) {
|
|
23
|
+
try {
|
|
24
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`Warning: Failed to load config from ${configPath}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
|
|
34
|
+
// Environment variable overrides (precedence: env > config > defaults)
|
|
35
|
+
const FLEET_ROOT = process.env.AESOP_FLEET_ROOT || config.fleet_root || process.env.HOME;
|
|
36
|
+
const TRANSCRIPTS_ROOT = process.env.AESOP_TRANSCRIPTS_ROOT || config.transcripts_root || path.join(process.env.HOME, '.claude', 'projects');
|
|
37
|
+
|
|
38
|
+
// === CONFIGURE YOUR FLEET ===
|
|
39
|
+
// Edit REPOS array to match your monitored repositories
|
|
40
|
+
// Example: { path: '/path/to/project1', name: 'project1', branch: 'main' }
|
|
41
|
+
// Paths can be absolute or relative to FLEET_ROOT
|
|
42
|
+
const REPOS = config.repos || [
|
|
43
|
+
// Example:
|
|
44
|
+
// { path: 'project-a', name: 'project-a', branch: 'main' },
|
|
45
|
+
// { path: 'project-b', name: 'project-b', branch: 'main' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Transcript project roots to scan for fleet prompts
|
|
49
|
+
// Default: ~/.claude/projects (set via AESOP_TRANSCRIPTS_ROOT or config)
|
|
50
|
+
const PROJECT_ROOTS = [
|
|
51
|
+
TRANSCRIPTS_ROOT,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Handoff/alerts directory (where SECURITY-ALERTS.log is stored)
|
|
55
|
+
// Set via config: alerts.alerts_root or alerts_root
|
|
56
|
+
const ALERTS_ROOT = config.alerts?.alerts_root || config.alerts_root || path.join(FLEET_ROOT, '..', 'conductor3', 'state');
|
|
57
|
+
const ALERTS = path.join(ALERTS_ROOT, 'SECURITY-ALERTS.log');
|
|
58
|
+
const SEENF = path.join(ALERTS_ROOT, '.fleet-scan-seen.json');
|
|
59
|
+
const MARKERF = path.join(ALERTS_ROOT, '.fleet-scan-lastcommit');
|
|
60
|
+
|
|
61
|
+
// Session ID to exclude (optional: set via EXCLUDE_SESSION env var or config)
|
|
62
|
+
const MY_SESSION = process.env.EXCLUDE_SESSION || config.exclude_session || '';
|
|
63
|
+
|
|
64
|
+
// ---- Utility functions ----
|
|
65
|
+
const git = (a, cwd) => {
|
|
66
|
+
try {
|
|
67
|
+
return execSync(`git ${a}`, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const now = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
77
|
+
const seen = fs.existsSync(SEENF) ? new Set(JSON.parse(fs.readFileSync(SEENF, 'utf8'))) : new Set();
|
|
78
|
+
const findings = [];
|
|
79
|
+
|
|
80
|
+
const add = (sev, kind, where, detail) => {
|
|
81
|
+
const key = crypto
|
|
82
|
+
.createHash('sha1')
|
|
83
|
+
.update(sev + kind + where + detail)
|
|
84
|
+
.digest('hex')
|
|
85
|
+
.slice(0, 16);
|
|
86
|
+
if (seen.has(key)) return;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
findings.push(`[${now()}] ${sev} ${kind} | ${where} | ${detail}`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ---- IOC patterns for ADDED code lines ----
|
|
92
|
+
const CODE_IOC = [
|
|
93
|
+
['HIGH', 'exec/shell', /Runtime\.getRuntime\(\)\.exec|new\s+ProcessBuilder|\bos\.system\(|\bsubprocess\.|\bpopen\(/i],
|
|
94
|
+
['HIGH', 'reverse-shell', /\/dev\/tcp\/|\bnc\s+-e\b|bash\s+-i\b|sh\s+-i\b|socket\.SOCK_STREAM.*connect/i],
|
|
95
|
+
['HIGH', 'pipe-to-shell', /\b(curl|wget)\b[^\n]*\|\s*(sh|bash)\b/i],
|
|
96
|
+
['HIGH', 'b64-exec', /base64\s+(-d|--decode)[^\n]*\|\s*(sh|bash)|Base64.*decode[^\n]*exec/i],
|
|
97
|
+
['HIGH', 'secret-literal', /AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|-----BEGIN[A-Z ]*PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|ghp_[A-Za-z0-9]{20,}/],
|
|
98
|
+
['HIGH', 'cred-access', /\.ssh\/(id_|authorized)|\.aws\/credentials|\/etc\/(passwd|shadow)|secrets\.toml|\.env\b/i],
|
|
99
|
+
['HIGH', 'destructive', /\brm\s+-rf\b|DROP\s+TABLE|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;/i],
|
|
100
|
+
['MED', 'deserialization', /\bObjectInputStream\b|\.readObject\(|XMLDecoder|@JsonTypeInfo|enableDefaultTyping/],
|
|
101
|
+
['MED', 'reflection', /Class\.forName\(|Method\.invoke\(|getDeclaredMethod\(|setAccessible\(true\)/],
|
|
102
|
+
['MED', 'raw-network', /new\s+Socket\(|URLConnection|\.openConnection\(|InetSocketAddress\(/i],
|
|
103
|
+
['MED', 'hardcoded-cred', /(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["'][^"']{6,}["']/i],
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
function scanCodeAddedLines(diff, label) {
|
|
107
|
+
if (!diff) return;
|
|
108
|
+
let file = label;
|
|
109
|
+
for (const line of diff.split('\n')) {
|
|
110
|
+
if (line.startsWith('+++ ')) {
|
|
111
|
+
file = line.slice(4).replace(/^b\//, '');
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue;
|
|
115
|
+
const code = line.slice(1);
|
|
116
|
+
for (const [sev, kind, re] of CODE_IOC) {
|
|
117
|
+
if (re.test(code)) add(sev, 'CODE:' + kind, file, code.trim().slice(0, 160));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Scan all configured repos
|
|
123
|
+
for (const repo of REPOS) {
|
|
124
|
+
const repoPath = path.isAbsolute(repo.path) ? repo.path : path.join(FLEET_ROOT, repo.path);
|
|
125
|
+
if (!fs.existsSync(repoPath)) {
|
|
126
|
+
console.warn(`Warning: Repo not found: ${repoPath}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const head = git('rev-parse HEAD', repoPath).trim();
|
|
131
|
+
const markerFile = path.join(repoPath, '.fleet-scan-lastcommit-' + repo.name);
|
|
132
|
+
const last = fs.existsSync(markerFile) ? fs.readFileSync(markerFile, 'utf8').trim() : '';
|
|
133
|
+
|
|
134
|
+
if (head) {
|
|
135
|
+
if (last && last !== head) {
|
|
136
|
+
scanCodeAddedLines(git(`diff ${last}..${head}`, repoPath), `${repo.name}:commit-range`);
|
|
137
|
+
} else if (!last) {
|
|
138
|
+
scanCodeAddedLines(git(`show ${head}`, repoPath), `${repo.name}:${head.slice(0, 8)}`);
|
|
139
|
+
}
|
|
140
|
+
scanCodeAddedLines(git('diff HEAD', repoPath), `${repo.name}:working-tree`);
|
|
141
|
+
fs.writeFileSync(markerFile, head);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Fleet transcript scan (injection / exfil / off-goal) ----
|
|
146
|
+
const CRED_HARVEST_ALLOWLIST = [
|
|
147
|
+
'secret_scan.py',
|
|
148
|
+
'secret/credential gate',
|
|
149
|
+
'scripts/CLAUDE.md',
|
|
150
|
+
'env_assignment',
|
|
151
|
+
'env_suspicious_keys',
|
|
152
|
+
'scanner_selftest',
|
|
153
|
+
'allow-pattern-docs',
|
|
154
|
+
'CRED_HARVEST_ALLOWLIST',
|
|
155
|
+
'generic_secret_assignment',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const EXFILTRATION_ALLOWLIST = [
|
|
159
|
+
'semantic-prompt-review',
|
|
160
|
+
'semantic review',
|
|
161
|
+
'OK/DRIFT/SUSPICIOUS',
|
|
162
|
+
'drift_escalator',
|
|
163
|
+
'prompt_classifier',
|
|
164
|
+
'alert-review',
|
|
165
|
+
'triage',
|
|
166
|
+
'EXFILTRATION_ALLOWLIST',
|
|
167
|
+
'power_selftest',
|
|
168
|
+
'secret_scan.py',
|
|
169
|
+
'fleet-scan.mjs',
|
|
170
|
+
'scanner_selftest',
|
|
171
|
+
'SECRETS-PERMITTED',
|
|
172
|
+
'resolve_alerts',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const PROMPT_IOC = [
|
|
176
|
+
['HIGH', 'prompt-injection', /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+instructions|disregard\s+(the|your)\s+(rules|instructions)|you\s+are\s+now\s+|new\s+system\s+prompt/i],
|
|
177
|
+
['HIGH', 'exfiltration', /exfiltrat|send\s+(it|them|the\s+\w+)\s+to\s+https?:|upload\s+to\s+|POST\s+.*to\s+http|leak\s+(the\s+)?(secret|cred|token|key)/i],
|
|
178
|
+
['HIGH', 'cred-harvest', /read\s+.*secrets\.toml|\.env\b|\.ssh\/|\.aws\/credentials|environment\s+variables?\s+.*(send|post|exfil)|harvest\s+(cred|token|key)/i],
|
|
179
|
+
['HIGH', 'evade-security', /disable\s+(the\s+)?(sentinel|antivirus|edr|defender|firewall)|bypass\s+(the\s+)?(security|scan|review)|hide\s+(this\s+)?from\s+the\s+user|don.?t\s+tell\s+the\s+(user|owner)/i],
|
|
180
|
+
['MED', 'danger-shell', /\b(curl|wget)\b.*\|\s*(sh|bash)|base64\s+-d|\/dev\/tcp\/|rm\s+-rf\s+(\/|~)|chmod\s+\+s/i],
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
function extractTexts(obj) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const c = obj?.message?.content;
|
|
186
|
+
if (typeof c === 'string') out.push(c);
|
|
187
|
+
else if (Array.isArray(c)) {
|
|
188
|
+
for (const x of c) {
|
|
189
|
+
if (typeof x === 'string') out.push(x);
|
|
190
|
+
else if (x?.type === 'text' && x.text) out.push(x.text);
|
|
191
|
+
else if (x?.type === 'tool_use' && (x.name === 'Agent' || x.name === 'Task')) {
|
|
192
|
+
if (x.input?.prompt) out.push('[SPAWN] ' + x.input.prompt);
|
|
193
|
+
if (x.input?.description) out.push('[SPAWN] ' + x.input.description);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const SINCE = Date.now() - 25 * 60 * 1000;
|
|
201
|
+
const tFiles = [];
|
|
202
|
+
|
|
203
|
+
function walk(d) {
|
|
204
|
+
try {
|
|
205
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
206
|
+
const p = path.join(d, e.name);
|
|
207
|
+
if (e.isDirectory()) walk(p);
|
|
208
|
+
else if (e.name.endsWith('.jsonl')) tFiles.push(p);
|
|
209
|
+
}
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const r of PROJECT_ROOTS) {
|
|
214
|
+
if (fs.existsSync(r)) walk(r);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const fp of tFiles) {
|
|
218
|
+
if (MY_SESSION && fp.includes(MY_SESSION)) continue; // skip excluded session
|
|
219
|
+
let st;
|
|
220
|
+
try {
|
|
221
|
+
st = fs.statSync(fp);
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (st.mtimeMs < SINCE) continue; // only recent fleet activity
|
|
226
|
+
const base = path.basename(fp);
|
|
227
|
+
const isAgentFile = base.startsWith('agent-'); // agent files: scan all; main session: [SPAWN] only
|
|
228
|
+
let lines;
|
|
229
|
+
try {
|
|
230
|
+
lines = fs.readFileSync(fp, 'utf8').split('\n');
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (!line.trim()) continue;
|
|
236
|
+
let obj;
|
|
237
|
+
try {
|
|
238
|
+
obj = JSON.parse(line);
|
|
239
|
+
} catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const text of extractTexts(obj)) {
|
|
243
|
+
if (!text || text.length < 8) continue;
|
|
244
|
+
const isSpawnedPrompt = text.startsWith('[SPAWN]');
|
|
245
|
+
const shouldScanPromptIOC = isAgentFile || isSpawnedPrompt;
|
|
246
|
+
let flagged = false;
|
|
247
|
+
if (shouldScanPromptIOC) {
|
|
248
|
+
for (const [sev, kind, re] of PROMPT_IOC) {
|
|
249
|
+
if (re.test(text)) {
|
|
250
|
+
if (kind === 'cred-harvest') {
|
|
251
|
+
const isDefensiveToolRef = CRED_HARVEST_ALLOWLIST.some((ref) => text.includes(ref));
|
|
252
|
+
if (isDefensiveToolRef) {
|
|
253
|
+
const detail = text.replace(/\s+/g, ' ').slice(0, 180);
|
|
254
|
+
add('SUPPRESSED-FP', 'cred-harvest (defensive-tool reference)', base, detail);
|
|
255
|
+
flagged = true;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (kind === 'exfiltration') {
|
|
260
|
+
const isDefensiveReviewRef = EXFILTRATION_ALLOWLIST.some((ref) => text.includes(ref));
|
|
261
|
+
if (isDefensiveReviewRef) {
|
|
262
|
+
const detail = text.replace(/\s+/g, ' ').slice(0, 180);
|
|
263
|
+
add('SUPPRESSED-FP', 'exfiltration (defensive-review reference)', base, detail);
|
|
264
|
+
flagged = true;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
add(sev, 'PROMPT:' + kind, base, text.replace(/\s+/g, ' ').slice(0, 180));
|
|
269
|
+
flagged = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
void flagged;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- emit ----
|
|
279
|
+
// Ensure output directory exists
|
|
280
|
+
if (!fs.existsSync(ALERTS_ROOT)) {
|
|
281
|
+
fs.mkdirSync(ALERTS_ROOT, { recursive: true });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (findings.length) {
|
|
285
|
+
fs.appendFileSync(ALERTS, findings.join('\n') + '\n');
|
|
286
|
+
fs.writeFileSync(SEENF, JSON.stringify([...seen]));
|
|
287
|
+
console.log(`${findings.length} new finding(s) -> ${ALERTS}`);
|
|
288
|
+
for (const f of findings) console.log(f);
|
|
289
|
+
} else {
|
|
290
|
+
fs.writeFileSync(SEENF, JSON.stringify([...seen]));
|
|
291
|
+
console.log('no new findings');
|
|
292
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: healthcheck
|
|
3
|
+
description: Check fleet health — one colored ball (heartbeats, alerts, orchestrator, tracker items).
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Healthcheck — one colored health ball
|
|
8
|
+
|
|
9
|
+
One-line status indicator for aesop fleet health, driven by aggregated signals.
|
|
10
|
+
|
|
11
|
+
## Procedure
|
|
12
|
+
|
|
13
|
+
Run the healthcheck tool and interpret the ball:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python tools/healthcheck.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Output interpretation
|
|
20
|
+
|
|
21
|
+
**🟢 Green**: All heartbeats fresh (watchdog <300s, monitor <3600s), no HIGH alerts
|
|
22
|
+
|
|
23
|
+
**🟡 Yellow**: Stale heartbeat OR unreviewed MED severity alert
|
|
24
|
+
|
|
25
|
+
**🔴 Red**: HIGH severity alert OR watchdog dead (>600s) while orchestrator actively dispatching
|
|
26
|
+
|
|
27
|
+
### Fixing issues
|
|
28
|
+
|
|
29
|
+
When healthcheck returns non-green, check the bullet-list reasons:
|
|
30
|
+
|
|
31
|
+
1. **Stale heartbeat**: Restart the relevant daemon (watchdog or monitor). Check `bash daemons/run-watchdog.sh --once` or monitor process.
|
|
32
|
+
2. **Unreviewed HIGH alert**: Review SECURITY-ALERTS.log, prefix line with `NOTE:` or `RESOLVED-FP` to mark reviewed.
|
|
33
|
+
3. **Unreviewed MED alert**: Same as HIGH — review and mark.
|
|
34
|
+
4. **Dead watchdog + active dispatch**: Critical issue — watchdog daemon crashed while orchestrator was dispatching agents. Check logs, restart watchdog, verify orchestrator stability.
|
|
35
|
+
|
|
36
|
+
### Machine-readable output
|
|
37
|
+
|
|
38
|
+
For integration with monitoring/dashboards, use `--json`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python tools/healthcheck.py --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Returns a JSON object with `ball`, `health` status, issues list, and tracker lane counts.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# state_store/ — event-sourced state layer (DB source of truth, git as export)
|
|
2
|
+
|
|
3
|
+
**Purpose**: the durable substrate to move aesop's coordination/state off git —
|
|
4
|
+
which cannot scale to a team (single-writer control files, hot-file merge
|
|
5
|
+
conflicts, no transactions/concurrency/real-time). State becomes an append-only
|
|
6
|
+
event log with per-stream versioning; current state is a projection; git is
|
|
7
|
+
demoted to a rendered, diffable **export**.
|
|
8
|
+
|
|
9
|
+
**Status (2026-07-14)**: additive prototype. The live `ui/` tracker path
|
|
10
|
+
(`collectors.py` → `state/tracker.json`) is UNCHANGED. This package ships the
|
|
11
|
+
store + tracker projection + backfill + export, ready for a later dual-read
|
|
12
|
+
cutover. Design: `conductor3 plans/aesop-scaling-rearchitecture.md`.
|
|
13
|
+
|
|
14
|
+
## Files (stdlib only — sqlite3/json/threading/time)
|
|
15
|
+
- **store.py** — `EventStore(db_path)`: append-only SQLite (WAL) log.
|
|
16
|
+
Concurrency-safe across threads and processes via `busy_timeout` +
|
|
17
|
+
`BEGIN IMMEDIATE` (atomic read-max-version-then-insert). `append/read/read_all`.
|
|
18
|
+
- **projections.py** — `project_tracker(events)`: folds `item_created` /
|
|
19
|
+
`item_updated` / `item_archived` into the full `tracker.json` shape,
|
|
20
|
+
preserving first-seen order.
|
|
21
|
+
- **api.py** — `StateAPI(db_path)`: the swap seam (`append`/`get`/`project`).
|
|
22
|
+
Backend swaps SQLite→Postgres here without touching callers.
|
|
23
|
+
- **export.py** — `export_tracker(api, out_path)`: render the projection back to
|
|
24
|
+
a git-tracked JSON snapshot (indent=2, ascii-escaped to match the live file).
|
|
25
|
+
- **ingest.py** — `ingest_tracker_json(api, path)`: backfill one `item_created`
|
|
26
|
+
per existing item (the migration/backfill path).
|
|
27
|
+
|
|
28
|
+
## Invariants
|
|
29
|
+
- **Append-only**: never mutate/delete events; state changes are new events.
|
|
30
|
+
- **Per-stream version is 1-based and gapless** (enforced atomically).
|
|
31
|
+
- **git as export, not source**: nothing here reads git for state.
|
|
32
|
+
- **Round-trip fidelity**: ingest → project → export reproduces the same items
|
|
33
|
+
(tested against the real `state/tracker.json`).
|
|
34
|
+
|
|
35
|
+
## Next (cutover, follow-up — NOT this increment)
|
|
36
|
+
Point tracker CRUD at `StateAPI` (create→`item_created`, update→`item_updated`,
|
|
37
|
+
move→lane update, delete→`item_archived`); add a `subscribe()` real-time path to
|
|
38
|
+
replace the SSE file-watch; run the `export_tracker` job to keep `tracker.json`
|
|
39
|
+
rendered during dual-read; then flip readers to the API.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""state_store — event-sourced state layer (DB source of truth, git as export).
|
|
2
|
+
|
|
3
|
+
Additive prototype landed 2026-07-14: the live ui/ tracker path is UNCHANGED.
|
|
4
|
+
This package provides the durable substrate to migrate aesop's coordination
|
|
5
|
+
state off git (which does not scale to concurrent writers) onto a real store
|
|
6
|
+
with transactions and per-stream versioning — SQLite (WAL) now, Postgres later
|
|
7
|
+
behind the same StateAPI. git is demoted to a rendered export (see export.py).
|
|
8
|
+
|
|
9
|
+
Design + rationale: conductor3 plans/aesop-scaling-rearchitecture.md; overview
|
|
10
|
+
in state_store/CLAUDE.md.
|
|
11
|
+
"""
|
|
12
|
+
from .api import StateAPI
|
|
13
|
+
from .export import export_tracker
|
|
14
|
+
from .ingest import ingest_tracker_json
|
|
15
|
+
from .projections import project_tracker
|
|
16
|
+
from .store import EventStore
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EventStore",
|
|
20
|
+
"StateAPI",
|
|
21
|
+
"project_tracker",
|
|
22
|
+
"export_tracker",
|
|
23
|
+
"ingest_tracker_json",
|
|
24
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""state_store.api — StateAPI facade over the event store + projections.
|
|
2
|
+
|
|
3
|
+
The single seam callers use, so the backend (SQLite now, Postgres later) can be
|
|
4
|
+
swapped without touching call sites. ``project(view)`` reads the same-named
|
|
5
|
+
stream and folds it through the registered projector.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .projections import project_tracker
|
|
10
|
+
from .store import EventStore
|
|
11
|
+
|
|
12
|
+
_PROJECTORS = {"tracker": project_tracker}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StateAPI:
|
|
16
|
+
"""Facade: append events, read a stream, or project a view to current state."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, db_path: str):
|
|
19
|
+
self._store = EventStore(db_path)
|
|
20
|
+
|
|
21
|
+
def append(self, stream: str, event_type: str, payload: dict, actor: str = "system") -> int:
|
|
22
|
+
"""Append one event; return its new per-stream version."""
|
|
23
|
+
return self._store.append(stream, event_type, payload, actor)
|
|
24
|
+
|
|
25
|
+
def get(self, stream: str) -> list:
|
|
26
|
+
"""Return all events in ``stream`` ascending by version."""
|
|
27
|
+
return self._store.read(stream)
|
|
28
|
+
|
|
29
|
+
def project(self, view: str) -> dict:
|
|
30
|
+
"""Fold the same-named stream through its projector into current state."""
|
|
31
|
+
try:
|
|
32
|
+
projector = _PROJECTORS[view]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise ValueError(f"unknown projection view: {view!r}")
|
|
35
|
+
return projector(self.get(view))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""state_store.export — render a projection back into git-tracked JSON.
|
|
2
|
+
|
|
3
|
+
In the DB-source-of-truth design git stops being *read* for state; this job
|
|
4
|
+
renders human-readable, diffable snapshots (e.g. tracker.json) FROM the
|
|
5
|
+
projections for durability and review. The JSON style (indent=2, ascii-escaped)
|
|
6
|
+
matches the existing state/tracker.json so a future cutover produces
|
|
7
|
+
minimal-diff snapshots.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def export_tracker(api, out_path: str) -> None:
|
|
15
|
+
"""Write the tracker projection to ``out_path`` as pretty JSON."""
|
|
16
|
+
projection = api.project("tracker")
|
|
17
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
18
|
+
json.dump(projection, fh, indent=2)
|
|
19
|
+
fh.write("\n")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""state_store.ingest — backfill events from an existing tracker.json.
|
|
2
|
+
|
|
3
|
+
Migration path for the DB-source-of-truth cutover: read the current
|
|
4
|
+
tracker.json and emit one ``item_created`` event per item (full payload) so the
|
|
5
|
+
event store reproduces the current state. History is not reconstructed (only
|
|
6
|
+
final state exists on disk), but the projection round-trips to the same items,
|
|
7
|
+
so an export re-renders the identical tracker.json.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ingest_tracker_json(api, tracker_json_path: str, actor: str = "migration") -> int:
|
|
15
|
+
"""Append one ``item_created`` per item in ``tracker_json_path``.
|
|
16
|
+
|
|
17
|
+
Returns the number of items ingested.
|
|
18
|
+
"""
|
|
19
|
+
with open(tracker_json_path, "r", encoding="utf-8") as fh:
|
|
20
|
+
data = json.load(fh)
|
|
21
|
+
items = data.get("items", [])
|
|
22
|
+
for item in items:
|
|
23
|
+
api.append("tracker", "item_created", item, actor)
|
|
24
|
+
return len(items)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""state_store.projections — fold tracker events into current state.
|
|
2
|
+
|
|
3
|
+
``project_tracker`` reconstructs the tracker.json shape
|
|
4
|
+
(``{"version": 1, "items": [...]}``) from the ``tracker`` event stream:
|
|
5
|
+
|
|
6
|
+
- ``item_created`` : payload is the full item dict; establishes the item
|
|
7
|
+
(kept in first-seen order).
|
|
8
|
+
- ``item_updated`` : payload is ``{"id": <id>, ...partial fields}``; merges
|
|
9
|
+
the given fields onto the existing item.
|
|
10
|
+
- ``item_archived`` : payload is ``{"id": <id>}`` (+ optional
|
|
11
|
+
``completed_at``); sets ``status`` to ``"archived"``.
|
|
12
|
+
|
|
13
|
+
Unknown ids on update/archive and unknown event types are ignored, keeping the
|
|
14
|
+
fold tolerant of partial/legacy streams.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
TRACKER_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_tracker(events: list) -> dict:
|
|
22
|
+
"""Return the current tracker state projected from ``events``."""
|
|
23
|
+
order = [] # item ids in first-seen order (stable output ordering)
|
|
24
|
+
items = {} # id -> item dict
|
|
25
|
+
for ev in events:
|
|
26
|
+
etype = ev.get("type")
|
|
27
|
+
payload = ev.get("payload") or {}
|
|
28
|
+
if etype == "item_created":
|
|
29
|
+
iid = payload.get("id")
|
|
30
|
+
if iid is None:
|
|
31
|
+
continue
|
|
32
|
+
if iid not in items:
|
|
33
|
+
order.append(iid)
|
|
34
|
+
items[iid] = dict(payload)
|
|
35
|
+
elif etype == "item_updated":
|
|
36
|
+
iid = payload.get("id")
|
|
37
|
+
if iid in items:
|
|
38
|
+
merged = dict(items[iid])
|
|
39
|
+
for key, value in payload.items():
|
|
40
|
+
if key != "id":
|
|
41
|
+
merged[key] = value
|
|
42
|
+
items[iid] = merged
|
|
43
|
+
elif etype == "item_archived":
|
|
44
|
+
iid = payload.get("id")
|
|
45
|
+
if iid in items:
|
|
46
|
+
merged = dict(items[iid])
|
|
47
|
+
merged["status"] = "archived"
|
|
48
|
+
if "completed_at" in payload:
|
|
49
|
+
merged["completed_at"] = payload["completed_at"]
|
|
50
|
+
items[iid] = merged
|
|
51
|
+
# any other event type is ignored
|
|
52
|
+
return {"version": TRACKER_VERSION, "items": [items[i] for i in order]}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""state_store.store — SQLite-backed, append-only, concurrency-safe event log.
|
|
2
|
+
|
|
3
|
+
Durable substrate for aesop's event-sourced state layer (the DB-source-of-truth
|
|
4
|
+
design; git becomes a rendered export, not the coordination layer). Backend is
|
|
5
|
+
SQLite in WAL mode so many readers and serialized writers share one file; the
|
|
6
|
+
same interface is meant to swap to Postgres behind ``state_store.api.StateAPI``
|
|
7
|
+
for team scale without touching call sites.
|
|
8
|
+
|
|
9
|
+
Stdlib only (sqlite3, json, time) per aesop's no-external-deps invariant.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventStore:
|
|
19
|
+
"""Append-only event log stored at ``db_path``.
|
|
20
|
+
|
|
21
|
+
Safe under concurrent appends from multiple threads AND multiple EventStore
|
|
22
|
+
instances on the same file: every call opens its own connection with
|
|
23
|
+
``PRAGMA busy_timeout`` and assigns the per-stream version inside a
|
|
24
|
+
``BEGIN IMMEDIATE`` transaction, so the read-max-version-then-insert is
|
|
25
|
+
atomic and two writers can never collide or duplicate a version.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, db_path: str):
|
|
29
|
+
self.db_path = db_path
|
|
30
|
+
conn = sqlite3.connect(self.db_path)
|
|
31
|
+
try:
|
|
32
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
33
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
34
|
+
conn.execute(
|
|
35
|
+
"""
|
|
36
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
ts REAL NOT NULL,
|
|
39
|
+
actor TEXT NOT NULL,
|
|
40
|
+
stream TEXT NOT NULL,
|
|
41
|
+
type TEXT NOT NULL,
|
|
42
|
+
payload TEXT NOT NULL,
|
|
43
|
+
version INTEGER NOT NULL
|
|
44
|
+
)
|
|
45
|
+
"""
|
|
46
|
+
)
|
|
47
|
+
conn.execute(
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS idx_events_stream_version "
|
|
49
|
+
"ON events(stream, version)"
|
|
50
|
+
)
|
|
51
|
+
conn.commit()
|
|
52
|
+
finally:
|
|
53
|
+
conn.close()
|
|
54
|
+
|
|
55
|
+
def append(self, stream: str, event_type: str, payload: dict, actor: str = "system") -> int:
|
|
56
|
+
"""Append one event to ``stream``; return its new per-stream version (1-based)."""
|
|
57
|
+
conn = sqlite3.connect(self.db_path)
|
|
58
|
+
try:
|
|
59
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
60
|
+
# BEGIN IMMEDIATE takes the write lock up front so the
|
|
61
|
+
# read-max-then-insert below is atomic under contention.
|
|
62
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
63
|
+
row = conn.execute(
|
|
64
|
+
"SELECT COALESCE(MAX(version), 0) FROM events WHERE stream = ?",
|
|
65
|
+
(stream,),
|
|
66
|
+
).fetchone()
|
|
67
|
+
version = row[0] + 1
|
|
68
|
+
conn.execute(
|
|
69
|
+
"INSERT INTO events (ts, actor, stream, type, payload, version) "
|
|
70
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
71
|
+
(time.time(), actor, stream, event_type, json.dumps(payload), version),
|
|
72
|
+
)
|
|
73
|
+
conn.commit()
|
|
74
|
+
return version
|
|
75
|
+
finally:
|
|
76
|
+
conn.close()
|
|
77
|
+
|
|
78
|
+
def read(self, stream: str) -> list:
|
|
79
|
+
"""Return all events for ``stream`` ascending by version (empty if none)."""
|
|
80
|
+
return self._rows("WHERE stream = ? ORDER BY version ASC", (stream,))
|
|
81
|
+
|
|
82
|
+
def read_all(self) -> list:
|
|
83
|
+
"""Return all events across all streams ascending by id."""
|
|
84
|
+
return self._rows("ORDER BY id ASC", ())
|
|
85
|
+
|
|
86
|
+
def _rows(self, clause: str, params) -> list:
|
|
87
|
+
conn = sqlite3.connect(self.db_path)
|
|
88
|
+
try:
|
|
89
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
90
|
+
cur = conn.execute(
|
|
91
|
+
"SELECT id, ts, actor, stream, type, payload, version FROM events " + clause,
|
|
92
|
+
params,
|
|
93
|
+
)
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
"id": r[0], "ts": r[1], "actor": r[2], "stream": r[3],
|
|
97
|
+
"type": r[4], "payload": json.loads(r[5]), "version": r[6],
|
|
98
|
+
}
|
|
99
|
+
for r in cur.fetchall()
|
|
100
|
+
]
|
|
101
|
+
finally:
|
|
102
|
+
conn.close()
|