@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.
Files changed (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /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> or http://localhost:<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("http://[::1]:") # IPv6 localhost
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
- html = render_dashboard(csrf.SESSION_TOKEN)
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 ("data", "backlog", "agents", "tracker", "status"):
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 = http.server.ThreadingHTTPServer(addr, DashboardHandler)
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 from
6
- serve.py in the wave-9 split). The only server-side substitution is the
7
- per-session CSRF token, injected via a unique sentinel so the template stays a
8
- plain static file (no .format()/% the CSS/JS is full of { } and % literals).
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
- with open(_DASHBOARD_HTML, encoding="utf-8") as f:
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))