@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/handler.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Aesop UI — HTTP request handler (DashboardHandler) + server entry (wave-9 split)."""
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import queue
|
|
6
|
+
import socketserver
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import config
|
|
13
|
+
import cost
|
|
14
|
+
import csrf
|
|
15
|
+
import sse
|
|
16
|
+
import api
|
|
17
|
+
import api.tracker
|
|
18
|
+
import api.submit
|
|
19
|
+
from render import render_dashboard
|
|
20
|
+
from csrf import validate_csrf_request
|
|
21
|
+
from collectors import (_snapshot_data, _snapshot_tracker,
|
|
22
|
+
_snapshot_orchestrator_status, drain_tracker_inbox,
|
|
23
|
+
get_alerts, get_heartbeat_status,
|
|
24
|
+
get_main_thread_messages, get_monitor_heartbeat_status,
|
|
25
|
+
get_recent_events, get_repos_status,
|
|
26
|
+
parse_audit_backlog)
|
|
27
|
+
from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
|
|
28
|
+
extract_agent_dispatch_prompt, get_fleet_agents)
|
|
29
|
+
from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
|
|
30
|
+
register_sse_client, unregister_sse_client)
|
|
31
|
+
|
|
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
|
+
|
|
69
|
+
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
70
|
+
"""HTTP request handler for dashboard."""
|
|
71
|
+
|
|
72
|
+
def log_message(self, format, *args):
|
|
73
|
+
"""Suppress default logging."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def do_GET(self):
|
|
77
|
+
"""Handle GET requests."""
|
|
78
|
+
if self.path == "/":
|
|
79
|
+
self.serve_html()
|
|
80
|
+
elif self.path == "/data":
|
|
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()
|
|
88
|
+
elif self.path == "/api/backlog":
|
|
89
|
+
self.serve_backlog()
|
|
90
|
+
elif self.path == "/api/agents":
|
|
91
|
+
self.serve_agents()
|
|
92
|
+
elif self.path.startswith("/api/tracker"):
|
|
93
|
+
self.serve_tracker()
|
|
94
|
+
elif self.path.startswith("/assets/"):
|
|
95
|
+
self.serve_asset()
|
|
96
|
+
elif self.path.startswith("/agent?"):
|
|
97
|
+
self.serve_agent()
|
|
98
|
+
elif self.path == "/events":
|
|
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()
|
|
105
|
+
else:
|
|
106
|
+
self.send_error(404)
|
|
107
|
+
|
|
108
|
+
def do_POST(self):
|
|
109
|
+
"""Handle POST requests."""
|
|
110
|
+
if self.path == "/submit":
|
|
111
|
+
self.handle_submit()
|
|
112
|
+
elif self.path == "/api/tracker":
|
|
113
|
+
self.handle_tracker_create()
|
|
114
|
+
elif self.path.startswith("/api/tracker/"):
|
|
115
|
+
self.handle_tracker_mutate()
|
|
116
|
+
else:
|
|
117
|
+
self.send_error(404)
|
|
118
|
+
|
|
119
|
+
def serve_html(self):
|
|
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)
|
|
141
|
+
self.send_response(200)
|
|
142
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
143
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
144
|
+
self.end_headers()
|
|
145
|
+
self.wfile.write(html.encode('utf-8'))
|
|
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
|
+
|
|
291
|
+
def serve_data(self):
|
|
292
|
+
"""Serve dashboard data as JSON."""
|
|
293
|
+
data = {
|
|
294
|
+
"watchdog": get_heartbeat_status(),
|
|
295
|
+
"monitor": get_monitor_heartbeat_status(),
|
|
296
|
+
"agents": get_fleet_agents(),
|
|
297
|
+
"repos": get_repos_status(),
|
|
298
|
+
"events": get_recent_events(),
|
|
299
|
+
"alerts": get_alerts(),
|
|
300
|
+
"messages": get_main_thread_messages(),
|
|
301
|
+
}
|
|
302
|
+
self.send_response(200)
|
|
303
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
304
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
305
|
+
self.end_headers()
|
|
306
|
+
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
307
|
+
|
|
308
|
+
def serve_tracker(self):
|
|
309
|
+
"""Serve tracker items as JSON via GET /api/tracker."""
|
|
310
|
+
try:
|
|
311
|
+
# Parse query string for filters
|
|
312
|
+
query = urllib.parse.urlparse(self.path).query
|
|
313
|
+
params = urllib.parse.parse_qs(query)
|
|
314
|
+
status = params.get('status', [None])[0]
|
|
315
|
+
priority = params.get('priority', [None])[0]
|
|
316
|
+
|
|
317
|
+
status_code, body = api.tracker.list_items(status=status, priority=priority)
|
|
318
|
+
self.send_response(status_code)
|
|
319
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
320
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
321
|
+
self.end_headers()
|
|
322
|
+
self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
|
|
323
|
+
except Exception as e:
|
|
324
|
+
self.send_response(500)
|
|
325
|
+
self.send_header("Content-Type", "application/json")
|
|
326
|
+
self.end_headers()
|
|
327
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
328
|
+
|
|
329
|
+
def handle_tracker_create(self):
|
|
330
|
+
"""Handle POST /api/tracker (create item)."""
|
|
331
|
+
try:
|
|
332
|
+
is_valid, reason = validate_csrf_request(self.headers)
|
|
333
|
+
if not is_valid:
|
|
334
|
+
self.send_response(403)
|
|
335
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
336
|
+
self.end_headers()
|
|
337
|
+
self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
content_length = int(self.headers.get('Content-Length', 0))
|
|
341
|
+
# Read bounded amount; api.validate_mutation() validates Content-Length
|
|
342
|
+
body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
|
|
343
|
+
status_code, result = api.tracker.create(self.headers, body_bytes)
|
|
344
|
+
self.send_response(status_code)
|
|
345
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
346
|
+
self.end_headers()
|
|
347
|
+
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
348
|
+
except Exception as e:
|
|
349
|
+
self.send_response(500)
|
|
350
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
351
|
+
self.end_headers()
|
|
352
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
353
|
+
|
|
354
|
+
def handle_tracker_mutate(self):
|
|
355
|
+
"""Handle POST /api/tracker/<id> (update or delete)."""
|
|
356
|
+
try:
|
|
357
|
+
is_valid, reason = validate_csrf_request(self.headers)
|
|
358
|
+
if not is_valid:
|
|
359
|
+
self.send_response(403)
|
|
360
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
361
|
+
self.end_headers()
|
|
362
|
+
self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# Extract item_id from path
|
|
366
|
+
path_parts = self.path.strip("/").split("/")
|
|
367
|
+
if len(path_parts) < 3:
|
|
368
|
+
self.send_response(404)
|
|
369
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
370
|
+
self.end_headers()
|
|
371
|
+
self.wfile.write(json.dumps({"error": "Not found"}).encode('utf-8'))
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
item_id = path_parts[2]
|
|
375
|
+
|
|
376
|
+
# Parse query for action (update or delete)
|
|
377
|
+
query = urllib.parse.urlparse(self.path).query
|
|
378
|
+
params = urllib.parse.parse_qs(query)
|
|
379
|
+
action = params.get('action', ['update'])[0]
|
|
380
|
+
|
|
381
|
+
if action == "delete":
|
|
382
|
+
status_code, result = api.tracker.delete(item_id)
|
|
383
|
+
self.send_response(status_code)
|
|
384
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
385
|
+
self.end_headers()
|
|
386
|
+
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
387
|
+
else:
|
|
388
|
+
content_length = int(self.headers.get('Content-Length', 0))
|
|
389
|
+
# Read bounded amount; api.validate_mutation() validates Content-Length
|
|
390
|
+
body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
|
|
391
|
+
status_code, result = api.tracker.update(item_id, body_bytes)
|
|
392
|
+
self.send_response(status_code)
|
|
393
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
394
|
+
self.end_headers()
|
|
395
|
+
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
396
|
+
except Exception as e:
|
|
397
|
+
self.send_response(500)
|
|
398
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
399
|
+
self.end_headers()
|
|
400
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def serve_backlog(self):
|
|
404
|
+
"""Serve audit backlog data as JSON via GET /api/backlog."""
|
|
405
|
+
try:
|
|
406
|
+
data = parse_audit_backlog()
|
|
407
|
+
self.send_response(200)
|
|
408
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
409
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
410
|
+
self.end_headers()
|
|
411
|
+
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
412
|
+
except Exception as e:
|
|
413
|
+
self.send_response(500)
|
|
414
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
415
|
+
self.end_headers()
|
|
416
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
417
|
+
|
|
418
|
+
def serve_agents(self):
|
|
419
|
+
"""Serve rich agent list with metadata via GET /api/agents."""
|
|
420
|
+
try:
|
|
421
|
+
agents = get_fleet_agents()
|
|
422
|
+
self.send_response(200)
|
|
423
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
424
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
425
|
+
self.end_headers()
|
|
426
|
+
self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
|
|
427
|
+
except Exception as e:
|
|
428
|
+
self.send_response(500)
|
|
429
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
430
|
+
self.end_headers()
|
|
431
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
432
|
+
|
|
433
|
+
def serve_agent(self):
|
|
434
|
+
"""Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
|
|
435
|
+
try:
|
|
436
|
+
# Parse query string
|
|
437
|
+
query = urllib.parse.urlparse(self.path).query
|
|
438
|
+
params = urllib.parse.parse_qs(query)
|
|
439
|
+
agent_id = params.get('id', [None])[0]
|
|
440
|
+
|
|
441
|
+
if not agent_id:
|
|
442
|
+
self.send_response(400)
|
|
443
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
444
|
+
self.end_headers()
|
|
445
|
+
self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
# Extract dispatch prompt and metadata
|
|
449
|
+
data = extract_agent_dispatch_prompt(agent_id)
|
|
450
|
+
|
|
451
|
+
if "error" in data:
|
|
452
|
+
# Rejected input (path traversal, glob metacharacters, or a match
|
|
453
|
+
# that resolved outside config.TRANSCRIPTS_ROOT) -> 400. A well-formed id
|
|
454
|
+
# with no matching transcript -> 404. Never 200 on error.
|
|
455
|
+
status = 400 if data.get("invalid") else 404
|
|
456
|
+
self.send_response(status)
|
|
457
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
458
|
+
self.end_headers()
|
|
459
|
+
self.wfile.write(json.dumps({"error": data["error"]}).encode('utf-8'))
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
self.send_response(200)
|
|
463
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
464
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
465
|
+
self.end_headers()
|
|
466
|
+
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
467
|
+
except Exception as e:
|
|
468
|
+
self.send_response(500)
|
|
469
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
470
|
+
self.end_headers()
|
|
471
|
+
print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
|
|
472
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
473
|
+
|
|
474
|
+
def _write_sse_event(self, event_name, payload):
|
|
475
|
+
"""Write one SSE frame with timeout. Caller handles disconnect exceptions."""
|
|
476
|
+
msg = f"event: {event_name}\ndata: {payload}\n\n"
|
|
477
|
+
# Set socket timeout to prevent stalled writes from blocking the server
|
|
478
|
+
old_timeout = None
|
|
479
|
+
try:
|
|
480
|
+
old_timeout = self.connection.gettimeout()
|
|
481
|
+
self.connection.settimeout(config.SSE_WRITE_TIMEOUT)
|
|
482
|
+
except (AttributeError, OSError):
|
|
483
|
+
pass
|
|
484
|
+
try:
|
|
485
|
+
self.wfile.write(msg.encode("utf-8"))
|
|
486
|
+
self.wfile.flush()
|
|
487
|
+
finally:
|
|
488
|
+
# Restore original timeout
|
|
489
|
+
try:
|
|
490
|
+
if old_timeout is not None:
|
|
491
|
+
self.connection.settimeout(old_timeout)
|
|
492
|
+
except (AttributeError, OSError):
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
def serve_events(self):
|
|
496
|
+
"""GET /events — Server-Sent Events stream.
|
|
497
|
+
|
|
498
|
+
No CSRF token required: this is a read-only stream, not a mutation (POST
|
|
499
|
+
/submit keeps its token requirement unchanged). Holds the connection open
|
|
500
|
+
for the life of the client; requires ThreadingHTTPServer (see run_server)
|
|
501
|
+
so one SSE client can't block every other request.
|
|
502
|
+
|
|
503
|
+
Returns HTTP 503 if concurrent connection cap (config.SSE_MAX_CLIENTS) is exceeded.
|
|
504
|
+
"""
|
|
505
|
+
sse.start_collector_thread()
|
|
506
|
+
|
|
507
|
+
q = register_sse_client()
|
|
508
|
+
if q is None:
|
|
509
|
+
# Connection cap exceeded; return 503 Service Unavailable
|
|
510
|
+
try:
|
|
511
|
+
self.send_response(503)
|
|
512
|
+
self.send_header("Content-Type", "text/plain")
|
|
513
|
+
self.send_header("Retry-After", "30")
|
|
514
|
+
self.end_headers()
|
|
515
|
+
self.wfile.write(b"Service overloaded: too many concurrent clients\n")
|
|
516
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
|
|
517
|
+
pass
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
self.send_response(200)
|
|
522
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
523
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
524
|
+
self.send_header("Connection", "keep-alive")
|
|
525
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
526
|
+
self.end_headers()
|
|
527
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
|
|
528
|
+
unregister_sse_client(q)
|
|
529
|
+
return
|
|
530
|
+
try:
|
|
531
|
+
# Send an immediate full snapshot so first paint isn't empty. If the
|
|
532
|
+
# collector hasn't produced anything yet (first-ever request), compute
|
|
533
|
+
# it inline once.
|
|
534
|
+
with _latest_lock:
|
|
535
|
+
initial = dict(_latest_snapshots)
|
|
536
|
+
if all(v is None for v in initial.values()):
|
|
537
|
+
initial["data"] = json.dumps(_snapshot_data(), default=str, sort_keys=True)
|
|
538
|
+
initial["backlog"] = json.dumps(parse_audit_backlog(), default=str, sort_keys=True)
|
|
539
|
+
initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
|
|
540
|
+
initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
|
|
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)
|
|
543
|
+
with _latest_lock:
|
|
544
|
+
_latest_snapshots.update(initial)
|
|
545
|
+
|
|
546
|
+
for name in _STATE_SECTIONS:
|
|
547
|
+
payload = initial.get(name)
|
|
548
|
+
if payload is not None:
|
|
549
|
+
self._write_sse_event(name, payload)
|
|
550
|
+
|
|
551
|
+
while True:
|
|
552
|
+
try:
|
|
553
|
+
event_name, payload = q.get(timeout=config.SSE_KEEPALIVE_SECONDS)
|
|
554
|
+
self._write_sse_event(event_name, payload)
|
|
555
|
+
except queue.Empty:
|
|
556
|
+
self.wfile.write(b": keepalive\n\n")
|
|
557
|
+
self.wfile.flush()
|
|
558
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
|
|
559
|
+
# Client disconnected (tab closed, network drop) — normal, not an error.
|
|
560
|
+
pass
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
finally:
|
|
564
|
+
unregister_sse_client(q)
|
|
565
|
+
|
|
566
|
+
def handle_submit(self):
|
|
567
|
+
"""Handle /submit POST with CSRF protection."""
|
|
568
|
+
try:
|
|
569
|
+
# CSRF validation: Check Origin/Referer + X-Aesop-Token
|
|
570
|
+
is_valid, reason = validate_csrf_request(self.headers)
|
|
571
|
+
if not is_valid:
|
|
572
|
+
self.send_response(403)
|
|
573
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
574
|
+
self.end_headers()
|
|
575
|
+
self.wfile.write(json.dumps({
|
|
576
|
+
"error": "CSRF protection: " + reason
|
|
577
|
+
}).encode('utf-8'))
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
content_length = int(self.headers.get('Content-Length', 0))
|
|
581
|
+
if content_length <= 0 or content_length > 10000: # 10KB limit, must be positive
|
|
582
|
+
self.send_response(400)
|
|
583
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
584
|
+
self.end_headers()
|
|
585
|
+
self.wfile.write(json.dumps({
|
|
586
|
+
"error": "Invalid Content-Length (must be 1-10000 bytes)"
|
|
587
|
+
}).encode('utf-8'))
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
body_bytes = self.rfile.read(content_length)
|
|
591
|
+
data = json.loads(body_bytes.decode('utf-8', errors='ignore'))
|
|
592
|
+
text = data.get("text", "").strip()
|
|
593
|
+
|
|
594
|
+
if not text:
|
|
595
|
+
self.send_response(400)
|
|
596
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
597
|
+
self.end_headers()
|
|
598
|
+
self.wfile.write(json.dumps({"error": "No text provided"}).encode('utf-8'))
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
ok, result = api.submit.append_to_inbox(text)
|
|
602
|
+
if not ok:
|
|
603
|
+
status_code, error = result
|
|
604
|
+
self.send_response(status_code)
|
|
605
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
606
|
+
self.end_headers()
|
|
607
|
+
self.wfile.write(json.dumps(error).encode('utf-8'))
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
self.send_response(200)
|
|
611
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
612
|
+
self.end_headers()
|
|
613
|
+
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
614
|
+
except Exception as e:
|
|
615
|
+
self.send_response(500)
|
|
616
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
617
|
+
self.end_headers()
|
|
618
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
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
|
+
|
|
653
|
+
def run_server():
|
|
654
|
+
"""Start the HTTP server.
|
|
655
|
+
|
|
656
|
+
Must be ThreadingHTTPServer, not HTTPServer: GET /events (SSE) holds its
|
|
657
|
+
connection open for the life of the client, so a single-threaded server would
|
|
658
|
+
wedge every other request (including the initial page load and /submit)
|
|
659
|
+
behind that one held connection.
|
|
660
|
+
|
|
661
|
+
Uses QuietThreadingHTTPServer to suppress expected socket disconnect exceptions.
|
|
662
|
+
"""
|
|
663
|
+
addr = ("127.0.0.1", config.PORT)
|
|
664
|
+
httpd = QuietThreadingHTTPServer(addr, DashboardHandler)
|
|
665
|
+
httpd.daemon_threads = True
|
|
666
|
+
sse.start_collector_thread()
|
|
667
|
+
print(f"Dashboard: http://localhost:{config.PORT}")
|
|
668
|
+
print(f"config.AESOP_ROOT: {config.AESOP_ROOT}")
|
|
669
|
+
print(f"Transcripts: {config.TRANSCRIPTS_ROOT}")
|
|
670
|
+
print(f"Press Ctrl-C to stop")
|
|
671
|
+
try:
|
|
672
|
+
httpd.serve_forever()
|
|
673
|
+
except KeyboardInterrupt:
|
|
674
|
+
sse._collector_stop_event.set()
|
|
675
|
+
print("\nShutdown complete.")
|
|
676
|
+
sys.exit(0)
|