@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
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
CHANGED
|
@@ -102,7 +102,7 @@ def validate_csrf_request(headers):
|
|
|
102
102
|
|
|
103
103
|
Performs two checks:
|
|
104
104
|
1. Origin/Referer validation: if Origin or Referer header is present, must be local
|
|
105
|
-
(http://127.0.0.1:<port>, http://localhost:<port>)
|
|
105
|
+
(http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>)
|
|
106
106
|
2. X-Aesop-Token validation: must match SESSION_TOKEN
|
|
107
107
|
|
|
108
108
|
Args:
|
|
@@ -120,11 +120,15 @@ def validate_csrf_request(headers):
|
|
|
120
120
|
# If Origin or Referer is present, validate it's local
|
|
121
121
|
if origin or referer:
|
|
122
122
|
check_value = origin or referer
|
|
123
|
-
# Check if it's a local origin: http://127.0.0.1:<PORT
|
|
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)
|
|
124
125
|
is_local = (
|
|
125
126
|
check_value.startswith("http://127.0.0.1:") or
|
|
127
|
+
check_value.startswith("https://127.0.0.1:") or
|
|
126
128
|
check_value.startswith("http://localhost:") or
|
|
127
|
-
check_value.startswith("
|
|
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
|
|
128
132
|
)
|
|
129
133
|
if not is_local:
|
|
130
134
|
return (False, "Foreign Origin/Referer rejected")
|
package/ui/handler.py
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
import http.server
|
|
4
4
|
import json
|
|
5
5
|
import queue
|
|
6
|
+
import socketserver
|
|
6
7
|
import sys
|
|
7
8
|
import threading
|
|
8
9
|
import urllib.parse
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
import config
|
|
13
|
+
import cost
|
|
12
14
|
import csrf
|
|
13
15
|
import sse
|
|
14
16
|
import api
|
|
@@ -28,6 +30,42 @@ from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
|
|
|
28
30
|
register_sse_client, unregister_sse_client)
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
# SSE section names, in emit order. /api/state returns the same sections so the
|
|
34
|
+
# frontend's first paint is one round trip (plan D3.1).
|
|
35
|
+
_STATE_SECTIONS = ("data", "backlog", "agents", "tracker", "status", "cost")
|
|
36
|
+
|
|
37
|
+
# MIME types for the built dist's content-hashed assets (wave-14, plan D3.4).
|
|
38
|
+
_ASSET_MIME = {
|
|
39
|
+
".js": "text/javascript; charset=utf-8",
|
|
40
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
41
|
+
".css": "text/css; charset=utf-8",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
".map": "application/json; charset=utf-8",
|
|
44
|
+
".json": "application/json; charset=utf-8",
|
|
45
|
+
".woff2": "font/woff2",
|
|
46
|
+
".woff": "font/woff",
|
|
47
|
+
".png": "image/png",
|
|
48
|
+
".ico": "image/x-icon",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_local_origin(origin):
|
|
53
|
+
"""True if an Origin header value is on the local allowlist.
|
|
54
|
+
|
|
55
|
+
Keep in sync with csrf.validate_csrf_request(): same six local forms
|
|
56
|
+
(http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>).
|
|
57
|
+
Both http:// and https:// schemes are accepted for the same loopback hosts.
|
|
58
|
+
"""
|
|
59
|
+
return bool(origin) and (
|
|
60
|
+
origin.startswith("http://127.0.0.1:") or
|
|
61
|
+
origin.startswith("https://127.0.0.1:") or
|
|
62
|
+
origin.startswith("http://localhost:") or
|
|
63
|
+
origin.startswith("https://localhost:") or
|
|
64
|
+
origin.startswith("http://[::1]:") or
|
|
65
|
+
origin.startswith("https://[::1]:")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
31
69
|
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
32
70
|
"""HTTP request handler for dashboard."""
|
|
33
71
|
|
|
@@ -41,16 +79,29 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
41
79
|
self.serve_html()
|
|
42
80
|
elif self.path == "/data":
|
|
43
81
|
self.serve_data()
|
|
82
|
+
elif self.path == "/api/state":
|
|
83
|
+
self.serve_api_state()
|
|
84
|
+
elif self.path == "/api/session":
|
|
85
|
+
self.serve_api_session()
|
|
86
|
+
elif self.path == "/api/cost":
|
|
87
|
+
self.serve_api_cost()
|
|
44
88
|
elif self.path == "/api/backlog":
|
|
45
89
|
self.serve_backlog()
|
|
46
90
|
elif self.path == "/api/agents":
|
|
47
91
|
self.serve_agents()
|
|
48
92
|
elif self.path.startswith("/api/tracker"):
|
|
49
93
|
self.serve_tracker()
|
|
94
|
+
elif self.path.startswith("/assets/"):
|
|
95
|
+
self.serve_asset()
|
|
50
96
|
elif self.path.startswith("/agent?"):
|
|
51
97
|
self.serve_agent()
|
|
52
98
|
elif self.path == "/events":
|
|
53
99
|
self.serve_events()
|
|
100
|
+
elif self.path == "/favicon.ico":
|
|
101
|
+
# Browsers auto-request this; answer 204 so it never 404s (keeps the
|
|
102
|
+
# console clean — the dashboard ships no favicon asset).
|
|
103
|
+
self.send_response(204)
|
|
104
|
+
self.end_headers()
|
|
54
105
|
else:
|
|
55
106
|
self.send_error(404)
|
|
56
107
|
|
|
@@ -66,14 +117,177 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
66
117
|
self.send_error(404)
|
|
67
118
|
|
|
68
119
|
def serve_html(self):
|
|
69
|
-
"""Serve the dashboard HTML.
|
|
70
|
-
|
|
120
|
+
"""Serve the dashboard HTML.
|
|
121
|
+
|
|
122
|
+
Wave-14 (plan D3.4/D7 U9 cutover): the built frontend at
|
|
123
|
+
config.WEB_DIST/index.html is always required and must be present;
|
|
124
|
+
if missing, return a hard 500 with a clear error (never fall back to
|
|
125
|
+
a legacy template). config.WEB_DIST is read at call time so
|
|
126
|
+
config.reload() keeps working across fixtures.
|
|
127
|
+
"""
|
|
128
|
+
dist_index = config.WEB_DIST / "index.html"
|
|
129
|
+
if not dist_index.is_file():
|
|
130
|
+
error_msg = (
|
|
131
|
+
"built dashboard missing — run npm run build in ui/web"
|
|
132
|
+
)
|
|
133
|
+
self.send_response(500)
|
|
134
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
135
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
136
|
+
self.end_headers()
|
|
137
|
+
self.wfile.write(error_msg.encode('utf-8'))
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
html = render_dashboard(csrf.SESSION_TOKEN, template_path=dist_index)
|
|
71
141
|
self.send_response(200)
|
|
72
142
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
73
143
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
74
144
|
self.end_headers()
|
|
75
145
|
self.wfile.write(html.encode('utf-8'))
|
|
76
146
|
|
|
147
|
+
def serve_asset(self):
|
|
148
|
+
"""GET /assets/* — static files from the built dist (config.WEB_DIST/assets).
|
|
149
|
+
|
|
150
|
+
Security: path-traversal containment via resolve() + is_relative_to,
|
|
151
|
+
mirroring the pattern in agents.py — the resolved candidate must stay
|
|
152
|
+
inside WEB_DIST/assets or the request is refused. URL-encoded (%2e%2e,
|
|
153
|
+
%2f, %5c) and absolute-path inputs are decoded first so the containment
|
|
154
|
+
check sees the real target.
|
|
155
|
+
|
|
156
|
+
Caching: filenames are content-hashed by the Vite build, so responses
|
|
157
|
+
are immutable (Cache-Control: public, max-age=31536000, immutable).
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
assets_root = config.WEB_DIST / "assets"
|
|
161
|
+
if not assets_root.is_dir():
|
|
162
|
+
# No built dist present (pre-cutover main) — nothing to serve.
|
|
163
|
+
self.send_error(404)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
raw_path = urllib.parse.urlparse(self.path).path
|
|
167
|
+
rel = urllib.parse.unquote(raw_path[len("/assets/"):])
|
|
168
|
+
if not rel or "\x00" in rel:
|
|
169
|
+
self.send_error(404)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
candidate = (assets_root / rel).resolve()
|
|
173
|
+
|
|
174
|
+
# Containment check: the resolved target must stay inside
|
|
175
|
+
# WEB_DIST/assets. Same belt-and-suspenders shape as agents.py.
|
|
176
|
+
try:
|
|
177
|
+
is_contained = candidate.is_relative_to(assets_root.resolve())
|
|
178
|
+
except AttributeError:
|
|
179
|
+
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
180
|
+
try:
|
|
181
|
+
candidate.relative_to(assets_root.resolve())
|
|
182
|
+
is_contained = True
|
|
183
|
+
except ValueError:
|
|
184
|
+
is_contained = False
|
|
185
|
+
if not is_contained:
|
|
186
|
+
self.send_error(403)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not candidate.is_file():
|
|
190
|
+
self.send_error(404)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
content = candidate.read_bytes()
|
|
194
|
+
mime = _ASSET_MIME.get(candidate.suffix.lower(), "application/octet-stream")
|
|
195
|
+
self.send_response(200)
|
|
196
|
+
self.send_header("Content-Type", mime)
|
|
197
|
+
self.send_header("Content-Length", str(len(content)))
|
|
198
|
+
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
|
|
199
|
+
self.end_headers()
|
|
200
|
+
self.wfile.write(content)
|
|
201
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
|
|
202
|
+
pass # client went away mid-response — normal, not an error
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
|
|
205
|
+
self.send_error(500)
|
|
206
|
+
|
|
207
|
+
def serve_api_state(self):
|
|
208
|
+
"""GET /api/state — consolidated snapshot of all six SSE sections.
|
|
209
|
+
|
|
210
|
+
One round trip for the frontend's first paint (plan D3.1). Reuses the
|
|
211
|
+
collectors' latest-snapshot mechanism: sections the background collector
|
|
212
|
+
has already produced are returned as-is; anything not yet snapshotted
|
|
213
|
+
(first-ever request) is computed inline, mirroring serve_events.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
sse.start_collector_thread()
|
|
217
|
+
with _latest_lock:
|
|
218
|
+
latest = dict(_latest_snapshots)
|
|
219
|
+
|
|
220
|
+
computers = {
|
|
221
|
+
"data": _snapshot_data,
|
|
222
|
+
"backlog": parse_audit_backlog,
|
|
223
|
+
"agents": get_fleet_agents,
|
|
224
|
+
"tracker": _snapshot_tracker,
|
|
225
|
+
"status": _snapshot_orchestrator_status,
|
|
226
|
+
"cost": cost.get_cost_summary,
|
|
227
|
+
}
|
|
228
|
+
state = {}
|
|
229
|
+
for name in _STATE_SECTIONS:
|
|
230
|
+
payload = latest.get(name)
|
|
231
|
+
if payload is not None:
|
|
232
|
+
state[name] = json.loads(payload)
|
|
233
|
+
else:
|
|
234
|
+
state[name] = computers[name]()
|
|
235
|
+
|
|
236
|
+
self.send_response(200)
|
|
237
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
238
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
239
|
+
self.end_headers()
|
|
240
|
+
self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
|
|
241
|
+
except Exception as e:
|
|
242
|
+
self.send_response(500)
|
|
243
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
244
|
+
self.end_headers()
|
|
245
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
246
|
+
|
|
247
|
+
def serve_api_session(self):
|
|
248
|
+
"""GET /api/session — the CSRF token for same-origin JS (plan D3.3).
|
|
249
|
+
|
|
250
|
+
Exists so the Vite dev server (which cannot do the sentinel
|
|
251
|
+
substitution) still gets a working token; the built app keeps the
|
|
252
|
+
sentinel-injection path as primary.
|
|
253
|
+
|
|
254
|
+
SECURITY — Origin-checked FAIL-CLOSED: unlike the mutation endpoints
|
|
255
|
+
(where a missing Origin is tolerated because the token itself gates the
|
|
256
|
+
write), this endpoint HANDS OUT the token, so absence of an Origin
|
|
257
|
+
header is a refusal. Only the same local allowlist csrf.py uses
|
|
258
|
+
(127.0.0.1 / localhost / [::1]) is accepted.
|
|
259
|
+
"""
|
|
260
|
+
origin = self.headers.get("Origin", "").strip()
|
|
261
|
+
if not _is_local_origin(origin):
|
|
262
|
+
self.send_response(403)
|
|
263
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
264
|
+
self.end_headers()
|
|
265
|
+
self.wfile.write(json.dumps(
|
|
266
|
+
{"error": "Forbidden: /api/session requires a local Origin header"}
|
|
267
|
+
).encode('utf-8'))
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
self.send_response(200)
|
|
271
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
272
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
273
|
+
self.end_headers()
|
|
274
|
+
self.wfile.write(json.dumps({"token": csrf.SESSION_TOKEN}).encode('utf-8'))
|
|
275
|
+
|
|
276
|
+
def serve_api_cost(self):
|
|
277
|
+
"""GET /api/cost — cost/scorecard summary from the outcomes ledger (plan D3.2)."""
|
|
278
|
+
try:
|
|
279
|
+
summary = cost.get_cost_summary()
|
|
280
|
+
self.send_response(200)
|
|
281
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
282
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
283
|
+
self.end_headers()
|
|
284
|
+
self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
|
|
285
|
+
except Exception as e:
|
|
286
|
+
self.send_response(500)
|
|
287
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
288
|
+
self.end_headers()
|
|
289
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
290
|
+
|
|
77
291
|
def serve_data(self):
|
|
78
292
|
"""Serve dashboard data as JSON."""
|
|
79
293
|
data = {
|
|
@@ -325,10 +539,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
325
539
|
initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
|
|
326
540
|
initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
|
|
327
541
|
initial["status"] = json.dumps(_snapshot_orchestrator_status(), default=str, sort_keys=True)
|
|
542
|
+
initial["cost"] = json.dumps(cost.get_cost_summary(), default=str, sort_keys=True)
|
|
328
543
|
with _latest_lock:
|
|
329
544
|
_latest_snapshots.update(initial)
|
|
330
545
|
|
|
331
|
-
for name in
|
|
546
|
+
for name in _STATE_SECTIONS:
|
|
332
547
|
payload = initial.get(name)
|
|
333
548
|
if payload is not None:
|
|
334
549
|
self._write_sse_event(name, payload)
|
|
@@ -402,6 +617,39 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
402
617
|
self.end_headers()
|
|
403
618
|
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
404
619
|
|
|
620
|
+
|
|
621
|
+
class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
|
622
|
+
"""ThreadingHTTPServer that suppresses expected socket disconnect exceptions.
|
|
623
|
+
|
|
624
|
+
During normal operation and especially during shutdown, ThreadingHTTPServer may
|
|
625
|
+
encounter ConnectionAbortedError (WinError 10053) or ConnectionResetError
|
|
626
|
+
(WinError 10054) when clients disconnect abruptly. These are expected, not errors,
|
|
627
|
+
and clutter stderr with tracebacks.
|
|
628
|
+
|
|
629
|
+
This server overrides handle_error() to suppress only these two exception types
|
|
630
|
+
while still reporting all other exceptions (real bugs, timeouts, etc.).
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
def handle_error(self, request, client_address):
|
|
634
|
+
"""Suppress client disconnect exceptions; report all others.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
request: The socket request object
|
|
638
|
+
client_address: The client address tuple
|
|
639
|
+
"""
|
|
640
|
+
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
641
|
+
|
|
642
|
+
# Suppress only the two disconnect exception types that occur during
|
|
643
|
+
# normal client aborts (especially on shutdown). All other exceptions
|
|
644
|
+
# (real bugs, timeouts, etc.) still get logged via super().
|
|
645
|
+
if exc_type in (ConnectionAbortedError, ConnectionResetError):
|
|
646
|
+
# Expected client disconnect; silent is correct.
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
# All other exceptions get the default handler (logged to stderr)
|
|
650
|
+
super().handle_error(request, client_address)
|
|
651
|
+
|
|
652
|
+
|
|
405
653
|
def run_server():
|
|
406
654
|
"""Start the HTTP server.
|
|
407
655
|
|
|
@@ -409,9 +657,11 @@ def run_server():
|
|
|
409
657
|
connection open for the life of the client, so a single-threaded server would
|
|
410
658
|
wedge every other request (including the initial page load and /submit)
|
|
411
659
|
behind that one held connection.
|
|
660
|
+
|
|
661
|
+
Uses QuietThreadingHTTPServer to suppress expected socket disconnect exceptions.
|
|
412
662
|
"""
|
|
413
663
|
addr = ("127.0.0.1", config.PORT)
|
|
414
|
-
httpd =
|
|
664
|
+
httpd = QuietThreadingHTTPServer(addr, DashboardHandler)
|
|
415
665
|
httpd.daemon_threads = True
|
|
416
666
|
sse.start_collector_thread()
|
|
417
667
|
print(f"Dashboard: http://localhost:{config.PORT}")
|
package/ui/render.py
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Aesop UI template rendering — stdlib-only.
|
|
4
4
|
|
|
5
|
-
The dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted
|
|
6
|
-
serve.py in the wave-9 split).
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
The legacy dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted
|
|
6
|
+
from serve.py in the wave-9 split). Wave-14 (dashboard rewrite, plan D3/D7)
|
|
7
|
+
retargets the same mechanism at the built React app: the handler passes
|
|
8
|
+
ui/web/dist/index.html as template_path when a committed dist is present, and
|
|
9
|
+
falls back to the legacy template otherwise. The only server-side substitution
|
|
10
|
+
is the per-session CSRF token, injected via a unique sentinel so the template
|
|
11
|
+
stays a plain static file (no .format()/% — the CSS/JS is full of { } and %
|
|
12
|
+
literals; the Vite build passes the sentinel-carrying inline script through
|
|
13
|
+
verbatim).
|
|
9
14
|
"""
|
|
10
15
|
import json
|
|
11
16
|
import os
|
|
@@ -17,13 +22,28 @@ _DASHBOARD_HTML = os.path.join(_TEMPLATE_DIR, "dashboard.html")
|
|
|
17
22
|
_CSRF_SENTINEL = "__AESOP_CSRF_SENTINEL__"
|
|
18
23
|
|
|
19
24
|
|
|
20
|
-
def render_dashboard(session_token):
|
|
25
|
+
def render_dashboard(session_token, template_path=None):
|
|
21
26
|
"""Return the dashboard HTML with the CSRF token substituted in.
|
|
22
27
|
|
|
28
|
+
Args:
|
|
29
|
+
session_token: the per-session CSRF token to inject.
|
|
30
|
+
template_path: path to the template file carrying the CSRF sentinel
|
|
31
|
+
(wave-14 U9 cutover: dist/index.html is always required; no
|
|
32
|
+
fallback to templates/dashboard.html).
|
|
33
|
+
|
|
23
34
|
Reads the template fresh each call (cheap; keeps edits live in dev). The
|
|
24
35
|
token is inserted as a JS string literal via json.dumps so it is always a
|
|
25
36
|
valid, properly-quoted value.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
TypeError: if template_path is None (legacy fallback removed at U9).
|
|
40
|
+
FileNotFoundError: if the template file does not exist.
|
|
26
41
|
"""
|
|
27
|
-
|
|
42
|
+
if template_path is None:
|
|
43
|
+
raise TypeError(
|
|
44
|
+
"render_dashboard() requires template_path after wave-14 U9 cutover; "
|
|
45
|
+
"legacy fallback to templates/dashboard.html has been removed"
|
|
46
|
+
)
|
|
47
|
+
with open(template_path, encoding="utf-8") as f:
|
|
28
48
|
html = f.read()
|
|
29
49
|
return html.replace(_CSRF_SENTINEL, json.dumps(session_token))
|