@matt82198/aesop 0.1.0-beta.3 → 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 +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- 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 +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -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 +72 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.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__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.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/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -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 +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -0
- 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 +183 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- 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/config.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop UI Configuration — Path and environment resolution.
|
|
4
|
+
|
|
5
|
+
This module centralizes all path configuration, environment variable resolution,
|
|
6
|
+
and config file loading for the UI dashboard. It provides a reload() function to
|
|
7
|
+
recompute all paths when environment changes (e.g., between test fixtures).
|
|
8
|
+
|
|
9
|
+
Config precedence: env vars > aesop.config.json > built-in defaults.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ==============================================================================
|
|
18
|
+
# Path Resolution Functions
|
|
19
|
+
# ==============================================================================
|
|
20
|
+
|
|
21
|
+
def reload():
|
|
22
|
+
"""Recompute all configuration from current environment.
|
|
23
|
+
|
|
24
|
+
Called at module load and whenever environment changes (test fixtures).
|
|
25
|
+
Mutates module-level globals in place so that importers see the current state.
|
|
26
|
+
"""
|
|
27
|
+
global PORT, AESOP_ROOT, CONFIG_FILE, STATE_DIR, TRANSCRIPTS_ROOT
|
|
28
|
+
global WATCHDOG_HEARTBEAT, MONITOR_HEARTBEAT, REPOS_JSON, BACKUP_LOG
|
|
29
|
+
global ALERTS_LOG, INBOX_FILE, AUDIT_BACKLOG_FILE
|
|
30
|
+
global UI_SESSION_TOKEN_FILE, TRACKER_FILE, ORCH_STATUS_FILE
|
|
31
|
+
global WEB_DIST, LEDGER_FILE
|
|
32
|
+
global COLLECTOR_INTERVAL, SSE_KEEPALIVE_SECONDS, SSE_MAX_CLIENTS, SSE_QUEUE_MAXSIZE, SSE_WRITE_TIMEOUT
|
|
33
|
+
|
|
34
|
+
# PORT: env PORT > default 8770
|
|
35
|
+
PORT = int(os.getenv("PORT", "8770"))
|
|
36
|
+
|
|
37
|
+
# Determine AESOP_ROOT: env AESOP_ROOT > default $HOME/aesop
|
|
38
|
+
AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
|
|
39
|
+
|
|
40
|
+
# Try to load config file for additional settings
|
|
41
|
+
CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
|
|
42
|
+
config_data = {}
|
|
43
|
+
if CONFIG_FILE.exists():
|
|
44
|
+
try:
|
|
45
|
+
with open(CONFIG_FILE) as f:
|
|
46
|
+
config_data = json.load(f)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"[config] Failed to load {CONFIG_FILE}: {e}", file=sys.stderr)
|
|
49
|
+
|
|
50
|
+
# Derive paths with precedence: env var > config file > built-in default
|
|
51
|
+
# STATE_DIR: env AESOP_STATE_ROOT > config state_root > AESOP_ROOT/state
|
|
52
|
+
STATE_DIR = Path(
|
|
53
|
+
os.getenv(
|
|
54
|
+
"AESOP_STATE_ROOT",
|
|
55
|
+
config_data.get("state_root", str(AESOP_ROOT / "state"))
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# TRANSCRIPTS_ROOT: env AESOP_TRANSCRIPTS_ROOT > config transcripts_root > ~/.claude/projects
|
|
60
|
+
TRANSCRIPTS_ROOT = Path(
|
|
61
|
+
os.getenv(
|
|
62
|
+
"AESOP_TRANSCRIPTS_ROOT",
|
|
63
|
+
config_data.get("transcripts_root", "~/.claude/projects")
|
|
64
|
+
)
|
|
65
|
+
).expanduser()
|
|
66
|
+
|
|
67
|
+
# Data file paths (all derived from STATE_DIR and AESOP_ROOT)
|
|
68
|
+
WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
|
|
69
|
+
MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
|
|
70
|
+
REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
|
|
71
|
+
BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
|
|
72
|
+
ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
|
|
73
|
+
INBOX_FILE = STATE_DIR / "ui-inbox.md"
|
|
74
|
+
AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
|
|
75
|
+
UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
|
|
76
|
+
TRACKER_FILE = STATE_DIR / "tracker.json"
|
|
77
|
+
ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
|
|
78
|
+
|
|
79
|
+
# Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
|
|
80
|
+
# WEB_DIST: the committed Vite build output served at / and /assets/*.
|
|
81
|
+
WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
|
|
82
|
+
# LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
|
|
83
|
+
LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
84
|
+
|
|
85
|
+
# Collector and SSE configuration
|
|
86
|
+
COLLECTOR_INTERVAL = float(os.getenv("AESOP_UI_COLLECT_INTERVAL", "1.0"))
|
|
87
|
+
SSE_KEEPALIVE_SECONDS = 15
|
|
88
|
+
SSE_MAX_CLIENTS = 100 # Resource cap: reject new connections past this
|
|
89
|
+
SSE_QUEUE_MAXSIZE = 50 # Per-client bounded queue (drops oldest on overflow)
|
|
90
|
+
SSE_WRITE_TIMEOUT = 5.0 # Write timeout in seconds to prevent stalled clients
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ==============================================================================
|
|
94
|
+
# Module-level initialization
|
|
95
|
+
# ==============================================================================
|
|
96
|
+
|
|
97
|
+
# Initialize configuration at module load time
|
|
98
|
+
# These globals are recomputed by reload() and accessed by other modules
|
|
99
|
+
PORT = 8770
|
|
100
|
+
AESOP_ROOT = Path.home() / "aesop"
|
|
101
|
+
CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
|
|
102
|
+
STATE_DIR = AESOP_ROOT / "state"
|
|
103
|
+
TRANSCRIPTS_ROOT = Path("~/.claude/projects").expanduser()
|
|
104
|
+
WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
|
|
105
|
+
MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
|
|
106
|
+
REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
|
|
107
|
+
BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
|
|
108
|
+
ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
|
|
109
|
+
INBOX_FILE = STATE_DIR / "ui-inbox.md"
|
|
110
|
+
AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
|
|
111
|
+
UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
|
|
112
|
+
TRACKER_FILE = STATE_DIR / "tracker.json"
|
|
113
|
+
ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
|
|
114
|
+
WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
|
|
115
|
+
LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
116
|
+
COLLECTOR_INTERVAL = 1.0
|
|
117
|
+
SSE_KEEPALIVE_SECONDS = 15
|
|
118
|
+
SSE_MAX_CLIENTS = 100
|
|
119
|
+
SSE_QUEUE_MAXSIZE = 50
|
|
120
|
+
SSE_WRITE_TIMEOUT = 5.0
|
|
121
|
+
|
|
122
|
+
# Perform initial load from environment
|
|
123
|
+
reload()
|
package/ui/cost.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cost/scorecard collector — parse ledger and aggregate costs.
|
|
3
|
+
|
|
4
|
+
This module provides get_cost_summary() which parses the outcomes ledger
|
|
5
|
+
markdown table and returns per-model, per-day, and overall cost/token aggregations
|
|
6
|
+
with optional pricing estimates.
|
|
7
|
+
|
|
8
|
+
Ledger format (markdown table):
|
|
9
|
+
| ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
|
|
10
|
+
| 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
|
|
11
|
+
|
|
12
|
+
CostSummary JSON shape (returned by get_cost_summary()):
|
|
13
|
+
{
|
|
14
|
+
"models": {
|
|
15
|
+
"model-id": {
|
|
16
|
+
"runs": int,
|
|
17
|
+
"tokens_in": int,
|
|
18
|
+
"tokens_out": int,
|
|
19
|
+
"verdicts": {"OK": int, "FAILED": int, "EMPTY": int, "HUNG": int}
|
|
20
|
+
},
|
|
21
|
+
...
|
|
22
|
+
},
|
|
23
|
+
"daily_totals": {
|
|
24
|
+
"YYYY-MM-DD": {"tokens_in": int, "tokens_out": int},
|
|
25
|
+
...
|
|
26
|
+
},
|
|
27
|
+
"overall_scorecard": {
|
|
28
|
+
"total_runs": int,
|
|
29
|
+
"ok_count": int,
|
|
30
|
+
"failed_count": int,
|
|
31
|
+
"empty_count": int,
|
|
32
|
+
"hung_count": int,
|
|
33
|
+
"ok_rate": float (0.0-1.0),
|
|
34
|
+
"failed_rate": float,
|
|
35
|
+
"empty_rate": float,
|
|
36
|
+
"hung_rate": float
|
|
37
|
+
},
|
|
38
|
+
"skipped_lines": int,
|
|
39
|
+
"has_pricing": bool,
|
|
40
|
+
"estimates_by_model": {
|
|
41
|
+
"model-id": {
|
|
42
|
+
"input_cost": float (dollars),
|
|
43
|
+
"output_cost": float (dollars),
|
|
44
|
+
"total_cost": float (dollars)
|
|
45
|
+
},
|
|
46
|
+
...
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Key behavior:
|
|
51
|
+
- Missing ledger file: returns empty summary with documented shape.
|
|
52
|
+
- Malformed lines: skipped and counted in skipped_lines field.
|
|
53
|
+
- Config read at CALL time (not import time) for test isolation.
|
|
54
|
+
- UTF-8 explicit encoding for all file operations.
|
|
55
|
+
- No external dependencies (pure stdlib).
|
|
56
|
+
- Pricing estimates ONLY if aesop.config.json has a "pricing" map.
|
|
57
|
+
"""
|
|
58
|
+
import json
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
# Note: import config at module level, but read config.X at CALL time
|
|
62
|
+
# (not at import time) to ensure test-fixture isolation works.
|
|
63
|
+
import config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_cost_summary():
|
|
67
|
+
"""Parse the outcomes ledger and return cost/token/verdict aggregations.
|
|
68
|
+
|
|
69
|
+
Reads from the ledger path exposed by config (config.STATE_DIR/ledger/OUTCOMES-LEDGER.md).
|
|
70
|
+
Returns an empty summary with documented shape if ledger is missing or empty.
|
|
71
|
+
Malformed lines are skipped and counted in skipped_lines.
|
|
72
|
+
|
|
73
|
+
All config paths are read at call time (not import time) to ensure
|
|
74
|
+
test-fixture isolation via config.reload().
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
dict: CostSummary with models, daily_totals, overall_scorecard,
|
|
78
|
+
skipped_lines, has_pricing, estimates_by_model.
|
|
79
|
+
"""
|
|
80
|
+
# Read ledger path at call time
|
|
81
|
+
ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
82
|
+
|
|
83
|
+
# Initialize result structure
|
|
84
|
+
result = {
|
|
85
|
+
"models": {},
|
|
86
|
+
"daily_totals": {},
|
|
87
|
+
"overall_scorecard": {
|
|
88
|
+
"total_runs": 0,
|
|
89
|
+
"ok_count": 0,
|
|
90
|
+
"failed_count": 0,
|
|
91
|
+
"empty_count": 0,
|
|
92
|
+
"hung_count": 0,
|
|
93
|
+
"ok_rate": 0.0,
|
|
94
|
+
"failed_rate": 0.0,
|
|
95
|
+
"empty_rate": 0.0,
|
|
96
|
+
"hung_rate": 0.0,
|
|
97
|
+
},
|
|
98
|
+
"skipped_lines": 0,
|
|
99
|
+
"has_pricing": False,
|
|
100
|
+
"estimates_by_model": {},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# If ledger file doesn't exist, return empty summary
|
|
104
|
+
if not ledger_file.exists():
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
# Read and parse ledger with explicit UTF-8 encoding
|
|
108
|
+
try:
|
|
109
|
+
content = ledger_file.read_text(encoding='utf-8')
|
|
110
|
+
except Exception:
|
|
111
|
+
# Graceful: if read fails, return empty summary
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
lines = content.strip().split('\n')
|
|
115
|
+
|
|
116
|
+
# Parse each line
|
|
117
|
+
for line in lines:
|
|
118
|
+
line = line.strip()
|
|
119
|
+
|
|
120
|
+
# Skip empty lines
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Skip header separator lines (all dashes and pipes) — silently, don't count as skipped
|
|
125
|
+
if all(c in '|-' for c in line):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Parse pipe-delimited row
|
|
129
|
+
if not line.startswith('|') or not line.endswith('|'):
|
|
130
|
+
result["skipped_lines"] += 1
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Split by pipe and strip whitespace
|
|
134
|
+
parts = [p.strip() for p in line.split('|')]
|
|
135
|
+
|
|
136
|
+
# Markdown tables have empty strings at start and end after split
|
|
137
|
+
# Format: | col1 | col2 | col3 | col4 | col5 | col6 | col7 |
|
|
138
|
+
# After split: ['', 'col1', 'col2', 'col3', 'col4', 'col5', 'col6', 'col7', '']
|
|
139
|
+
if len(parts) < 9: # Need at least 9 parts (empty + 7 columns + empty)
|
|
140
|
+
result["skipped_lines"] += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Extract columns (skip leading/trailing empty)
|
|
144
|
+
try:
|
|
145
|
+
timestamp = parts[1] # ISO timestamp
|
|
146
|
+
agent_type = parts[2] # "Agent"
|
|
147
|
+
model = parts[3]
|
|
148
|
+
duration_str = parts[4]
|
|
149
|
+
tokens_in_str = parts[5]
|
|
150
|
+
tokens_out_str = parts[6]
|
|
151
|
+
verdict = parts[7]
|
|
152
|
+
except IndexError:
|
|
153
|
+
result["skipped_lines"] += 1
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Parse numeric fields
|
|
157
|
+
try:
|
|
158
|
+
tokens_in = int(tokens_in_str)
|
|
159
|
+
tokens_out = int(tokens_out_str)
|
|
160
|
+
except ValueError:
|
|
161
|
+
result["skipped_lines"] += 1
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Validate verdict
|
|
165
|
+
if verdict not in ("OK", "FAILED", "EMPTY", "HUNG"):
|
|
166
|
+
result["skipped_lines"] += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Extract date from ISO timestamp (YYYY-MM-DD)
|
|
170
|
+
try:
|
|
171
|
+
date_str = timestamp.split('T')[0]
|
|
172
|
+
except IndexError:
|
|
173
|
+
result["skipped_lines"] += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Aggregate by model
|
|
177
|
+
if model not in result["models"]:
|
|
178
|
+
result["models"][model] = {
|
|
179
|
+
"runs": 0,
|
|
180
|
+
"tokens_in": 0,
|
|
181
|
+
"tokens_out": 0,
|
|
182
|
+
"verdicts": {"OK": 0, "FAILED": 0, "EMPTY": 0, "HUNG": 0},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
result["models"][model]["runs"] += 1
|
|
186
|
+
result["models"][model]["tokens_in"] += tokens_in
|
|
187
|
+
result["models"][model]["tokens_out"] += tokens_out
|
|
188
|
+
result["models"][model]["verdicts"][verdict] += 1
|
|
189
|
+
|
|
190
|
+
# Aggregate by date
|
|
191
|
+
if date_str not in result["daily_totals"]:
|
|
192
|
+
result["daily_totals"][date_str] = {"tokens_in": 0, "tokens_out": 0}
|
|
193
|
+
|
|
194
|
+
result["daily_totals"][date_str]["tokens_in"] += tokens_in
|
|
195
|
+
result["daily_totals"][date_str]["tokens_out"] += tokens_out
|
|
196
|
+
|
|
197
|
+
# Count for overall scorecard
|
|
198
|
+
result["overall_scorecard"]["total_runs"] += 1
|
|
199
|
+
if verdict == "OK":
|
|
200
|
+
result["overall_scorecard"]["ok_count"] += 1
|
|
201
|
+
elif verdict == "FAILED":
|
|
202
|
+
result["overall_scorecard"]["failed_count"] += 1
|
|
203
|
+
elif verdict == "EMPTY":
|
|
204
|
+
result["overall_scorecard"]["empty_count"] += 1
|
|
205
|
+
elif verdict == "HUNG":
|
|
206
|
+
result["overall_scorecard"]["hung_count"] += 1
|
|
207
|
+
|
|
208
|
+
# Calculate rates
|
|
209
|
+
total = result["overall_scorecard"]["total_runs"]
|
|
210
|
+
if total > 0:
|
|
211
|
+
result["overall_scorecard"]["ok_rate"] = result["overall_scorecard"]["ok_count"] / total
|
|
212
|
+
result["overall_scorecard"]["failed_rate"] = result["overall_scorecard"]["failed_count"] / total
|
|
213
|
+
result["overall_scorecard"]["empty_rate"] = result["overall_scorecard"]["empty_count"] / total
|
|
214
|
+
result["overall_scorecard"]["hung_rate"] = result["overall_scorecard"]["hung_count"] / total
|
|
215
|
+
|
|
216
|
+
# Load pricing config at call time and compute estimates
|
|
217
|
+
pricing_map = _load_pricing_config()
|
|
218
|
+
if pricing_map:
|
|
219
|
+
result["has_pricing"] = True
|
|
220
|
+
for model, stats in result["models"].items():
|
|
221
|
+
if model in pricing_map:
|
|
222
|
+
pricing = pricing_map[model]
|
|
223
|
+
input_price = pricing.get("input_per_mtok", 0.0)
|
|
224
|
+
output_price = pricing.get("output_per_mtok", 0.0)
|
|
225
|
+
|
|
226
|
+
# Calculate costs: (tokens / 1_000_000) * price_per_mtok
|
|
227
|
+
input_cost = (stats["tokens_in"] * input_price) / 1_000_000
|
|
228
|
+
output_cost = (stats["tokens_out"] * output_price) / 1_000_000
|
|
229
|
+
total_cost = input_cost + output_cost
|
|
230
|
+
|
|
231
|
+
result["estimates_by_model"][model] = {
|
|
232
|
+
"input_cost": input_cost,
|
|
233
|
+
"output_cost": output_cost,
|
|
234
|
+
"total_cost": total_cost,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_pricing_config():
|
|
241
|
+
"""Load pricing map from aesop.config.json at call time.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
dict or None: pricing map {model: {input_per_mtok: float, output_per_mtok: float}}
|
|
245
|
+
or None if no pricing config found.
|
|
246
|
+
"""
|
|
247
|
+
# Read config file at call time (not import time)
|
|
248
|
+
config_file = config.CONFIG_FILE
|
|
249
|
+
|
|
250
|
+
if not config_file.exists():
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
with open(config_file, encoding='utf-8') as f:
|
|
255
|
+
config_data = json.load(f)
|
|
256
|
+
except Exception:
|
|
257
|
+
# Graceful: if config read fails, no pricing
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
return config_data.get("pricing", None)
|
package/ui/csrf.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop UI CSRF Protection — Token generation and validation.
|
|
4
|
+
|
|
5
|
+
Session token is generated once at startup and persisted to state/.ui-session-token (0600).
|
|
6
|
+
Token is used to validate mutations via /submit POST endpoint.
|
|
7
|
+
|
|
8
|
+
CRITICAL: This module reads config paths LIVE at call time via 'import config',
|
|
9
|
+
never 'from config import <path>'. This ensures paths are recomputed when
|
|
10
|
+
config.reload() is called (e.g., between test fixtures).
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Must import config module, never "from config import <paths>"
|
|
18
|
+
import config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Module-level session token (set by init())
|
|
22
|
+
SESSION_TOKEN = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init():
|
|
26
|
+
"""Initialize CSRF protection: generate or load the per-session token.
|
|
27
|
+
|
|
28
|
+
This should be called once at startup (after config.reload()).
|
|
29
|
+
Token is persisted to state/.ui-session-token (mode 0600) and reused
|
|
30
|
+
across server restarts.
|
|
31
|
+
|
|
32
|
+
SECURITY: File is created atomically with restricted permissions using
|
|
33
|
+
os.open(O_CREAT|O_EXCL) to avoid TOCTOU window where file exists with
|
|
34
|
+
world-readable permissions.
|
|
35
|
+
"""
|
|
36
|
+
global SESSION_TOKEN
|
|
37
|
+
SESSION_TOKEN = _generate_session_token()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _generate_session_token():
|
|
41
|
+
"""Generate or load the per-session CSRF token.
|
|
42
|
+
|
|
43
|
+
Token is generated once at startup and persisted to state/.ui-session-token (mode 0600).
|
|
44
|
+
Subsequent calls return the same token (in-memory).
|
|
45
|
+
|
|
46
|
+
SECURITY: File is created atomically with restricted permissions using os.open(O_CREAT|O_EXCL)
|
|
47
|
+
to avoid TOCTOU window where file exists with world-readable permissions.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
str: 43-character base64-like random token (256 bits / 3 bytes per char = ~43 chars)
|
|
51
|
+
"""
|
|
52
|
+
# LIVE CONFIG READ: access config.UI_SESSION_TOKEN_FILE at call time
|
|
53
|
+
token_file = config.UI_SESSION_TOKEN_FILE
|
|
54
|
+
|
|
55
|
+
# Check if token file exists and is readable
|
|
56
|
+
if token_file.exists():
|
|
57
|
+
try:
|
|
58
|
+
token = token_file.read_text().strip()
|
|
59
|
+
if token and len(token) >= 32:
|
|
60
|
+
return token
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print(f"[csrf] Failed to read token file: {e}", file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
# Generate new token: 32 random bytes → 43-char base64-like string
|
|
65
|
+
token = secrets.token_urlsafe(32)
|
|
66
|
+
|
|
67
|
+
# Persist to file with restricted permissions (0600) using atomic creation
|
|
68
|
+
try:
|
|
69
|
+
# LIVE CONFIG READ: access config.STATE_DIR at call time
|
|
70
|
+
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
# Atomically create file with 0600 permissions using os.open with O_CREAT|O_EXCL.
|
|
73
|
+
# This ensures the file is never world-readable (no TOCTOU window).
|
|
74
|
+
# On Windows, mode bits are largely ignored, which is fine.
|
|
75
|
+
try:
|
|
76
|
+
fd = os.open(
|
|
77
|
+
str(token_file),
|
|
78
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
79
|
+
0o600
|
|
80
|
+
)
|
|
81
|
+
# Write token via the file descriptor (no separate chmod needed)
|
|
82
|
+
with os.fdopen(fd, 'w') as f:
|
|
83
|
+
f.write(token)
|
|
84
|
+
except FileExistsError:
|
|
85
|
+
# File already exists (race condition or previous run).
|
|
86
|
+
# Try to read it and use that token instead.
|
|
87
|
+
try:
|
|
88
|
+
token = token_file.read_text().strip()
|
|
89
|
+
if token and len(token) >= 32:
|
|
90
|
+
return token
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"[csrf] Failed to read existing token file: {e}", file=sys.stderr)
|
|
93
|
+
# If we can't read the existing file, fall back to in-memory token
|
|
94
|
+
except Exception:
|
|
95
|
+
pass # Fail-open: token exists in memory even if file write fails
|
|
96
|
+
|
|
97
|
+
return token
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_csrf_request(headers):
|
|
101
|
+
"""Validate CSRF protections on /submit POST request.
|
|
102
|
+
|
|
103
|
+
Performs two checks:
|
|
104
|
+
1. Origin/Referer validation: if Origin or Referer header is present, must be local
|
|
105
|
+
(http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>)
|
|
106
|
+
2. X-Aesop-Token validation: must match SESSION_TOKEN
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
headers: dict-like object with HTTP headers (case-insensitive)
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
tuple: (is_valid: bool, reason: str or None)
|
|
113
|
+
- (True, None) if CSRF checks pass
|
|
114
|
+
- (False, reason) if either check fails
|
|
115
|
+
"""
|
|
116
|
+
# Check 1: Origin/Referer header validation
|
|
117
|
+
origin = headers.get("Origin", "").strip()
|
|
118
|
+
referer = headers.get("Referer", "").strip()
|
|
119
|
+
|
|
120
|
+
# If Origin or Referer is present, validate it's local
|
|
121
|
+
if origin or referer:
|
|
122
|
+
check_value = origin or referer
|
|
123
|
+
# Check if it's a local origin: http[s]://127.0.0.1:<PORT>, http[s]://localhost:<PORT>,
|
|
124
|
+
# or http[s]://[::1]:<PORT> (both http:// and https:// schemes accepted for same hosts)
|
|
125
|
+
is_local = (
|
|
126
|
+
check_value.startswith("http://127.0.0.1:") or
|
|
127
|
+
check_value.startswith("https://127.0.0.1:") or
|
|
128
|
+
check_value.startswith("http://localhost:") or
|
|
129
|
+
check_value.startswith("https://localhost:") or
|
|
130
|
+
check_value.startswith("http://[::1]:") or # IPv6 localhost
|
|
131
|
+
check_value.startswith("https://[::1]:") # IPv6 localhost HTTPS
|
|
132
|
+
)
|
|
133
|
+
if not is_local:
|
|
134
|
+
return (False, "Foreign Origin/Referer rejected")
|
|
135
|
+
|
|
136
|
+
# Check 2: X-Aesop-Token validation
|
|
137
|
+
token = headers.get("X-Aesop-Token", "").strip()
|
|
138
|
+
if not token:
|
|
139
|
+
return (False, "Missing X-Aesop-Token header")
|
|
140
|
+
|
|
141
|
+
if token != SESSION_TOKEN:
|
|
142
|
+
return (False, "Invalid X-Aesop-Token")
|
|
143
|
+
|
|
144
|
+
return (True, None)
|