@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
@@ -57,6 +57,14 @@ def build_fixture(root: Path):
57
57
  (root / "state").mkdir(exist_ok=True)
58
58
  (root / "transcripts").mkdir(exist_ok=True)
59
59
  (root / "dash").mkdir(exist_ok=True)
60
+
61
+ # Copy ui/web/dist from the real repo so the server can serve the built React app
62
+ repo = Path(__file__).resolve().parent.parent
63
+ real_dist = repo / "ui" / "web" / "dist"
64
+ if real_dist.is_dir():
65
+ fixture_dist = root / "ui" / "web" / "dist"
66
+ shutil.copytree(real_dist, fixture_dist)
67
+
60
68
  (root / "AUDIT-BACKLOG.md").write_text(
61
69
  "# Audit backlog — verify_submit_encoding fixture\n\n## Landing log\n- fixture\n",
62
70
  encoding="utf-8",
@@ -142,11 +150,11 @@ def main():
142
150
  page = browser.new_page()
143
151
  page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
144
152
 
145
- # Drive the real /submit flow: #inbox-input -> #inbox-button, real
153
+ # Drive the real /submit flow: inbox-input -> inbox-submit, real
146
154
  # CSRF token (window.__AESOP_CSRF_TOKEN__ injected server-side).
147
- page.wait_for_selector("#inbox-input", timeout=8000)
148
- page.fill("#inbox-input", MARKER)
149
- page.click("#inbox-button")
155
+ page.wait_for_selector("[data-testid='inbox-input']", timeout=8000)
156
+ page.fill("[data-testid='inbox-input']", MARKER)
157
+ page.click("[data-testid='inbox-submit']")
150
158
 
151
159
  # Wait for the inbox file to land on disk (async POST).
152
160
  for _ in range(50):
package/ui/CLAUDE.md CHANGED
@@ -2,28 +2,48 @@
2
2
 
3
3
  **Purpose**: Stdlib-only local observability dashboard. Serves a dark-theme HTML dashboard on a configurable port (realtime via Server-Sent Events), enabling real-time fleet monitoring without external dependencies.
4
4
 
5
- ## Files (wave-9 split: serve.py monolith decomposed into focused modules)
5
+ ## Files (wave-14 U9 cutover: new React+Vite app structure)
6
6
 
7
+ **Backend (Python, unchanged from wave-9 split)**:
7
8
  - **serve.py** — Thin (~65-line) entry point + composition layer. Wires the sibling modules, calls `config.reload()` / `csrf.init()` / `sse.reset_state()`, and re-exports their symbols so `serve.X` keeps resolving for the test suite (which loads serve.py by path) and for `python ui/serve.py`.
8
- - **config.py** — Path / env / `aesop.config.json` resolution. `reload()` recomputes all path globals from the current environment. **Load-bearing rule: every other module reads `config.X` at call time (`import config`), never `from config import <path>` — a frozen import would go stale after reload() (breaks test-fixture isolation).**
9
+ - **config.py** — Path / env / `aesop.config.json` resolution. `reload()` recomputes all path globals from the current environment. **Load-bearing rule: every other module reads `config.X` at call time (`import config`), never `from config import <path>` — a frozen import would go stale after reload() (breaks test-fixture isolation).** Added in wave-14: `WEB_DIST` path pointing to `ui/web/dist/`.
9
10
  - **csrf.py** — Session-token generation (atomic O_EXCL 0600) + `validate_csrf_request()`; `init()` sets `SESSION_TOKEN`.
10
11
  - **collectors.py** — Read-only data collectors (heartbeats, repos, events, alerts, messages, backlog parse), tracker CRUD, and SSE section snapshots (incl. `read`-style tracker/orchestrator-status snapshots + inbox drain).
11
12
  - **agents.py** — Agent transcript reading (`get_fleet_agents`, `extract_agent_dispatch_prompt`) and path-traversal-safe agent-id handling (`_AGENT_ID_FORBIDDEN`).
12
- - **sse.py** — SSE client registry, bounded broadcast, hash-gated `_maybe_emit`, and the background `collector_loop`. `reset_state()` restores per-import collector isolation (the `sse` module is cached across test re-imports).
13
- - **render.py** — Renders `templates/dashboard.html`, substituting the CSRF token via a unique sentinel (no `.format`/% the CSS/JS is full of `{}`/`%`).
14
- - **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
15
- - **templates/dashboard.html** — The dashboard HTML/CSS/JS (extracted from the serve.py string), incl. tracker panel + orchestrator-status panel + audit-cycle ASCII banner.
16
- - **README.md** — User guide and configuration reference.
17
-
18
- ## API Package (ui/api/)
19
-
20
- Shared mutation-request validation and domain-logic handlers (wave-10 split from handler.py). Every mutating endpoint (`/submit`, `POST /api/tracker`, `POST /api/tracker/<id>`) gates requests identically: CSRF token validation, Content-Length bounds-check, then JSON decode. This module centralizes those gates so they are directly unit-testable without HTTP coupling.
21
-
22
- - **__init__.py** Shared mutation gate (`validate_mutation()`). Performs CSRF validation + Content-Length bounds-check (10 KB cap) + JSON decode. Returns `(True, parsed_json)` on success or `(False, (status_code, error_dict))` on gate failure. **Load-bearing rule**: callers must bound the socket read using the same Content-Length header before invoking, to prevent DoS; MAX_BODY_BYTES is re-derived here for validation but socket read is caller's responsibility.
23
- - **submit.py** Inbox-append logic (`append_to_inbox(text)`). Appends one timestamped line to `config.INBOX_FILE`, rejects symlinks (TOCTOU defense), creates file with header if missing.
24
- - **tracker.py** Tracker CRUD handlers (`list_items()`, `create()`, `update()`, `delete()`). Calls `collectors` tracker functions; returns `(status_code, response_dict)` for direct HTTP response writing. **Load-bearing**: all tracker mutations pass through `validate_mutation()` in create; update/delete check CSRF via `csrf.validate_csrf_request()` performed by caller before path is parsed.
25
-
26
- **Key invariant**: All three modules read `config.X` live via `import config` at call time, never `from config import X`. A frozen import goes stale after `config.reload()` (breaks test-fixture isolation). See ui/CLAUDE.md "load-bearing rule" note above.
13
+ - **sse.py** — SSE client registry, bounded broadcast, hash-gated `_maybe_emit`, and the background `collector_loop`. `reset_state()` restores per-import collector isolation (the `sse` module is cached across test re-imports). Extended in wave-14: emits "cost" as a 6th SSE section.
14
+ - **render.py** — Renders `ui/web/dist/index.html` (wave-14 U9 cutover: no fallback), substituting the CSRF token via a unique sentinel (the sentinel persists through Vite's build). Requires `template_path` parameter; legacy fallback removed.
15
+ - **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Wave-14 additions: static serving (`GET /assets/*` from `ui/web/dist/assets/`), `/api/state` consolidated snapshot, `/api/session` for Vite dev server, `/api/cost` scorecard. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
16
+ - **cost.py** — Parser for `state/ledger/OUTCOMES-LEDGER.md` (markdown table); returns per-model and per-day aggregates, verdict scorecards, and optional dollar estimates (only if `aesop.config.json` supplies `pricing` map).
17
+
18
+ ## State Store Integration (Wave-15)
19
+
20
+ The tracker uses a **dual-path mutation model** backed by an event-sourced SQLite WAL store:
21
+
22
+ - **Write path**: Mutations append events to `tracker_events.db` (WAL mode) via the StateAPI layer (`state_store/api.py`). Each mutation (create/update/delete) produces an immutable event in the event log.
23
+ - **Read path**: `tracker.json` is re-rendered as an export (projection) from the event log on every read. This export is committed to git for checkpoint durability.
24
+ - **Location**: `state/tracker_events.db` (SQLite WAL, never committed). `tracker.json` (rendered export, committed).
25
+ - **Render-failure recovery**: If projection rendering fails, the UI falls back to the last-known good `tracker.json`; no data loss. Stale renders are repaired on the next successful mutation (idempotent re-render).
26
+
27
+ This design provides:
28
+ - **Durable event audit trail** (append-only events in WAL)
29
+ - **Git-friendly checkpoints** (rendered tracker.json snapshots)
30
+ - **Atomic mutations** (events append, projection re-renders)
31
+ - **Graceful degradation** (fallback to last-known-good on render failure)
32
+
33
+ **Frontend (React 18 + Vite + TypeScript, wave-14 U1–U8)**:
34
+ - **ui/web/** — Complete React application (TypeScript, Vite, TypeScript strict mode).
35
+ - **src/main.tsx** — Vite entry point; renders `<App />` to `#root`.
36
+ - **src/App.tsx** — App shell: header + hash-routed view slots (/#/, /#/work, /#/activity, /#/cost).
37
+ - **src/styles/tokens.css** + **src/styles/global.css** — Design tokens (light/dark color palettes, spacing, typography) + base resets.
38
+ - **src/views/** — Page-level components (Overview, Work, Activity, Cost) with SSE bindings.
39
+ - **src/components/** — Reusable UI components (HealthHeader, AgentsPanel, TrackerBoard, Timeline, CostChart, etc.).
40
+ - **src/lib/api.ts** — Typed fetch helpers + CSRF header injection + `/api/session` fallback for dev server.
41
+ - **src/lib/useSSE.ts** — EventSource hook with reconnect logic, per-section state, connection status.
42
+ - **src/lib/types.ts** — TypeScript types for all API payloads (contract with backend).
43
+ - **src/lib/sanitizeUrl.ts** — XSS-safe URL parsing (PR links inert on bad schemes).
44
+ - **vite.config.ts** — Vite config with API proxy for `/data`, `/api`, `/events`, `/agent`, `/submit` to :8770.
45
+ - **dist/** — Built static files (committed to git; served by Python handler). Filenames are content-hashed by Vite.
46
+ - **README.md** — User guide and configuration reference (updated for wave-14).
27
47
 
28
48
  ## Configuration & Path Precedence
29
49
 
@@ -62,43 +82,39 @@ Configuration is resolved in this order (first match wins):
62
82
  - CLI tools read token from `state/.ui-session-token` (0600) to submit to /submit endpoint
63
83
  - Legitimate browser clients: token injected into HTML template, sent by JavaScript
64
84
 
85
+ ## API Endpoints (wave-14 wave additions)
86
+
87
+ **Read-only (no CSRF required)**:
88
+ - `GET /` — Renders `ui/web/dist/index.html` with CSRF token substituted (hard 500 if dist missing).
89
+ - `GET /assets/*` — Static files from `ui/web/dist/assets/` (content-hashed, immutable cache headers, path-traversal-safe).
90
+ - `GET /api/state` — Consolidated first-paint snapshot: `{data, backlog, agents, tracker, status, cost}` in one round trip (reuses latest snapshots).
91
+ - `GET /api/session` — Returns `{token}` for Vite dev server; Origin-checked fail-closed (only local origins).
92
+ - `GET /api/cost` — Cost/scorecard summary from `state/ledger/OUTCOMES-LEDGER.md` (per-model, per-day, verdicts, optional pricing estimates).
93
+ - `GET /events` — Server-Sent Events stream (read-only, no CSRF).
94
+
95
+ **Mutations (CSRF-gated)**:
96
+ - `POST /submit` — Append to inbox.
97
+ - `POST /api/tracker`, `POST /api/tracker/<id>` — Tracker CRUD.
98
+
65
99
  ## Server-Sent Events (SSE) Model
66
100
 
67
101
  **Realtime streaming via GET /events**:
68
102
  - ThreadingHTTPServer required (SSE holds one connection per client)
69
- - Streams JSON frames: `event: data|backlog|agents` / `data: <json>`
103
+ - Streams JSON frames: `event: <section>` / `data: <json>`
70
104
  - Keepalive comment-line (`: keepalive`) sent every ~15s to prevent timeout
71
105
  - Read-only stream; no mutations
72
106
 
73
- **Sections emitted** (only on content change):
107
+ **Sections emitted** (only on content change, in order):
74
108
  1. **data** — heartbeat status, daemon state, log tail
75
109
  2. **backlog** — AUDIT-BACKLOG.md parsed into tier buckets (P0/P1/P2/Needs decision)
76
110
  3. **agents** — fleet agent activity (from Claude transcripts directory)
111
+ 4. **tracker** — tracker items (CRUD mutations reflected in realtime)
112
+ 5. **status** — orchestrator phase + activity
113
+ 6. **cost** — cost/scorecard summary (wave-14 addition)
77
114
 
78
115
  ## Background Collector Thread
79
116
 
80
- **Lifecycle**:
81
- - Started idempotently on first HTTP request (via `start_collector_thread()`)
82
- - Runs as daemon thread (terminates when main process exits)
83
- - Single-instance guard via `_collector_lock` (prevents double-start)
84
-
85
- **Polling strategy**:
86
- - Wakes every `COLLECTOR_INTERVAL` seconds (default: 1.0, overridable via `AESOP_UI_COLLECT_INTERVAL`)
87
- - Polls cheap sources: heartbeat file mtimes, log tails, file fingerprints
88
- - Only re-derives sections when underlying input changed (mtime/fingerprint gate)
89
- - Only emits to clients when section content-hash changed (avoids redundant broadcasts)
90
-
91
- **Cached data**:
92
- - Last computed hash per section (data/backlog/agents)
93
- - Last mtime of AUDIT-BACKLOG.md
94
- - Last fingerprint of transcripts directory (to detect agent activity changes)
95
- - Cached parsed backlog and agent snapshot (reused until inputs change)
96
-
97
- **Content-change detection**:
98
- - Mtime-gated: backlog section re-derived only if AUDIT-BACKLOG.md mtime changed
99
- - Fingerprint-gated: agents section re-derived only if transcripts directory content changed
100
- - Hash-gated: broadcast only sent if content-hash differs from last broadcast
101
- - This avoids expensive operations (subprocess for dash-extra.mjs, directory walk) on every tick
117
+ Daemon thread polling heartbeats/logs via mtime/fingerprint gates; re-derives SSE sections only on input change, broadcasts only when content-hash differs (avoids expensive operations on every tick). Started idempotently on first HTTP request via `start_collector_thread()`, runs with single-instance guard `_collector_lock`, wakes every `COLLECTOR_INTERVAL` (default 1.0s, `AESOP_UI_COLLECT_INTERVAL`).
102
118
 
103
119
  ## Invariants & Gotchas
104
120
 
Binary file
Binary file
package/ui/agents.py CHANGED
@@ -55,7 +55,7 @@ _AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
55
55
 
56
56
  def extract_agent_dispatch_prompt(agent_id):
57
57
  """
58
- Extract dispatch prompt and metadata from agent output file.
58
+ Extract dispatch prompt and metadata from agent jsonl transcript.
59
59
  Returns dict with prompt, dispatcher, model, activity times, and message count.
60
60
  Robust: missing/invalid file -> {error: "..."}
61
61
  Security: rejects agent_id containing path-traversal or glob-metacharacter
@@ -64,20 +64,24 @@ def extract_agent_dispatch_prompt(agent_id):
64
64
  carry "invalid": True when the input itself was rejected, so callers can
65
65
  map that to an HTTP 400 rather than a plain 404.
66
66
 
67
- CRITICAL: Use prefix-matching via glob, not exact match. The dashboard supplies
68
- truncated agent IDs; files on disk carry full IDs (e.g., a77b995bcdb953e9c.output).
67
+ CRITICAL: Use prefix-matching via glob, not exact match. The dashboard (dash-extra.mjs)
68
+ scans for agent-*.jsonl files and emits a 13-char truncated ID; files on disk carry
69
+ full IDs (e.g., agent-a77b995bcdb953e9c1234567.jsonl). This function must search for
70
+ the same file format that the dashboard actually finds.
69
71
  """
70
72
  try:
71
73
  if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
72
74
  return {"error": "invalid agent id", "invalid": True}
73
75
 
74
- # Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent_id*.output
76
+ # Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent-agent_id*.jsonl
77
+ # The dashboard (dash-extra.mjs) scans for agent-*.jsonl files and emits
78
+ # a 13-char truncated ID; we must match against the same .jsonl files.
75
79
  if not config.TRANSCRIPTS_ROOT.exists():
76
80
  return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
77
81
 
78
82
  # Glob for matching files (prefix-match handles truncated IDs)
79
83
  matches = sorted(
80
- config.TRANSCRIPTS_ROOT.glob(f"**/{agent_id}*.output"),
84
+ config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
81
85
  key=lambda p: p.stat().st_mtime, reverse=True,
82
86
  )
83
87
  if not matches:
package/ui/api/submit.py CHANGED
@@ -11,13 +11,50 @@ planted at config.INBOX_FILE).
11
11
  Reads config.INBOX_FILE live via `import config` at call time, never
12
12
  `from config import INBOX_FILE` -- a frozen import goes stale after
13
13
  config.reload() (breaks test-fixture isolation). See ui/CLAUDE.md.
14
+
15
+ Detects both POSIX symlinks and Windows junctions/reparse points.
14
16
  """
15
17
  import os
18
+ import stat
16
19
  from datetime import datetime
17
20
 
18
21
  import config
19
22
 
20
23
 
24
+ def _is_link_or_reparse(path):
25
+ """Check if path is a symlink or Windows reparse point (junction/etc).
26
+
27
+ Returns True if:
28
+ - On all platforms: path is a POSIX symlink (detected via st_mode)
29
+ - On Windows: path has the FILE_ATTRIBUTE_REPARSE_POINT attribute
30
+ (which includes junctions, symlinks, and other reparse points)
31
+
32
+ Uses os.lstat() to detect without following links (includes dangling ones).
33
+ On error (e.g., path doesn't exist), returns False to let caller's
34
+ exists() check handle it normally.
35
+ """
36
+ try:
37
+ stat_result = os.lstat(str(path))
38
+
39
+ # On POSIX, check the symlink bit in st_mode
40
+ if stat.S_ISLNK(stat_result.st_mode):
41
+ return True
42
+
43
+ # On Windows, check for reparse point attribute (junctions, symlinks, etc)
44
+ # FILE_ATTRIBUTE_REPARSE_POINT = 0x400 (1024 decimal)
45
+ # st_file_attributes only exists on Windows
46
+ if hasattr(stat_result, 'st_file_attributes'):
47
+ FILE_ATTRIBUTE_REPARSE_POINT = 0x400
48
+ if stat_result.st_file_attributes & FILE_ATTRIBUTE_REPARSE_POINT:
49
+ return True
50
+
51
+ return False
52
+ except (OSError, AttributeError):
53
+ # If we can't stat it, let it fall through to the exists() check
54
+ # (file doesn't exist, which is fine)
55
+ return False
56
+
57
+
21
58
  def append_to_inbox(text):
22
59
  """Append one inbox line for `text` to config.INBOX_FILE.
23
60
 
@@ -35,11 +72,13 @@ def append_to_inbox(text):
35
72
  """
36
73
  inbox_content = f"- [{datetime.now().isoformat()}] {text}\n"
37
74
 
38
- # Security: reject symlinks (TOCTOU defense) FIRST — check islink independent
39
- # of exists(). Path.exists() follows the link, so a DANGLING symlink (target
40
- # not yet created) returns False and would otherwise skip this check, then
41
- # open(...,'w') would follow the link and create the attacker's target.
42
- if os.path.islink(str(config.INBOX_FILE)):
75
+ # Security: reject symlinks and Windows junctions (TOCTOU defense) FIRST —
76
+ # check link/reparse INDEPENDENT of exists(). Path.exists() follows the link,
77
+ # so a DANGLING symlink (target not yet created) returns False and would
78
+ # otherwise skip this check, then open(...,'w') would follow the link and
79
+ # create the attacker's target. Windows junctions are not detected by
80
+ # os.path.islink(), so we use _is_link_or_reparse() instead.
81
+ if _is_link_or_reparse(config.INBOX_FILE):
43
82
  return False, (400, {"error": "Inbox file is a symlink (rejected for security)"})
44
83
 
45
84
  if not config.INBOX_FILE.exists():
package/ui/collectors.py CHANGED
@@ -316,7 +316,27 @@ def get_alerts():
316
316
  return alerts
317
317
 
318
318
  def load_tracker():
319
- """Load tracker.json, return empty tracker if missing or corrupt."""
319
+ """Load tracker.json, return empty tracker if missing or corrupt.
320
+
321
+ Defect 2 fix: Check dirty flag. If render previously failed, the event log
322
+ and tracker.json may be out of sync. Re-render from projection to self-heal.
323
+ """
324
+ # Check dirty flag (indicates previous render failure)
325
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
326
+ if dirty_file.exists():
327
+ try:
328
+ print("[tracker] Detected previous render failure; recovering...", file=sys.stderr)
329
+ api = _tracker_api()
330
+ # Force re-render from projection
331
+ try:
332
+ save_tracker(api.project("tracker"))
333
+ dirty_file.unlink()
334
+ print("[tracker] Recovery render completed", file=sys.stderr)
335
+ except Exception as e:
336
+ print(f"[tracker] Recovery render failed: {e}", file=sys.stderr)
337
+ except Exception as e:
338
+ print(f"[tracker] Failed to recover from dirty flag: {e}", file=sys.stderr)
339
+
320
340
  if not config.TRACKER_FILE.exists():
321
341
  return {"version": 1, "items": []}
322
342
 
@@ -418,9 +438,95 @@ def get_tracker_items(status=None, priority=None):
418
438
 
419
439
  return items
420
440
 
441
+ # --- Event-sourced tracker backing (state_store) -----------------------------
442
+ # The event log is the write-authority + audit trail; tracker.json is kept as
443
+ # the rendered export that reads/SSE/UI consume. Each mutation appends an event
444
+ # and re-renders the file (atomic os.replace via save_tracker), replacing the
445
+ # old load->mutate->save read-modify-write that raced under concurrent writers.
446
+ # The live read path (load_tracker / get_tracker_items / SSE snapshot) is
447
+ # unchanged: it still reads tracker.json, which every write keeps current.
448
+
449
+ def _tracker_api():
450
+ """Return a StateAPI over state/tracker_events.db (lazy import; call-time paths)."""
451
+ try:
452
+ from state_store import StateAPI
453
+ except ImportError:
454
+ from pathlib import Path as _Path
455
+ root = str(_Path(__file__).resolve().parents[1])
456
+ if root not in sys.path:
457
+ sys.path.insert(0, root)
458
+ from state_store import StateAPI
459
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
460
+ return StateAPI(str(config.STATE_DIR / "tracker_events.db"))
461
+
462
+
463
+ def _ensure_tracker_migrated(api):
464
+ """Backfill the event log from the existing tracker.json once (idempotent).
465
+
466
+ Defect 3 fix: Guard migration with a marker event (migration_started with
467
+ version=1). The first caller to append this marker wins and performs the
468
+ backfill. Subsequent callers see the marker and skip the backfill. This
469
+ prevents concurrent callers from polluting the audit log with duplicate
470
+ item_created events.
471
+ """
472
+ events = api.get("tracker")
473
+
474
+ # Check for migration marker (first event of type "migration_started")
475
+ has_migration_marker = any(e.get("type") == "migration_started" and
476
+ e.get("payload", {}).get("version") == 1
477
+ for e in events)
478
+ if has_migration_marker:
479
+ # Migration already completed (or in progress); skip backfill
480
+ return
481
+
482
+ # Append migration marker first to atomically claim the migration
483
+ try:
484
+ api.append("tracker", "migration_started", {"version": 1}, "system")
485
+ except Exception as e:
486
+ print(f"[tracker] Failed to append migration marker: {e}", file=sys.stderr)
487
+ return
488
+
489
+ # Now safe to backfill (other callers will see marker and skip)
490
+ if config.TRACKER_FILE.exists():
491
+ try:
492
+ data = json.loads(config.TRACKER_FILE.read_text(encoding='utf-8'))
493
+ except Exception:
494
+ data = {"items": []}
495
+ for item in data.get("items", []):
496
+ if isinstance(item, dict) and item.get("id"):
497
+ api.append("tracker", "item_created", item, "migration")
498
+
499
+
500
+ def _tracker_items_by_id(api):
501
+ return {it["id"]: it for it in api.project("tracker")["items"]}
502
+
503
+
504
+ def _render_tracker(api):
505
+ """Materialize the projection back to tracker.json (atomic).
506
+
507
+ Defect 2 fix: Wrap render in try/except. On failure, log loudly and mark
508
+ a dirty flag so the next read triggers a recovery re-render. This ensures
509
+ that if the event log has events but tracker.json is stale, the next read
510
+ self-heals by detecting the mismatch and re-rendering.
511
+ """
512
+ try:
513
+ save_tracker(api.project("tracker"))
514
+ except Exception as e:
515
+ print(f"[tracker] CRITICAL: Failed to render tracker after event append: {e}",
516
+ file=sys.stderr)
517
+ # Mark dirty flag for recovery on next read
518
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
519
+ try:
520
+ dirty_file.write_text(str(time()), encoding='utf-8')
521
+ except Exception as e2:
522
+ print(f"[tracker] Failed to write dirty flag: {e2}", file=sys.stderr)
523
+ raise
524
+
525
+
421
526
  def create_tracker_item(data):
422
- """Create a new tracker item."""
423
- tracker = load_tracker()
527
+ """Create a new tracker item (event-sourced; tracker.json re-rendered)."""
528
+ api = _tracker_api()
529
+ _ensure_tracker_migrated(api)
424
530
 
425
531
  item = {
426
532
  "id": secrets.token_hex(6),
@@ -436,39 +542,43 @@ def create_tracker_item(data):
436
542
  "completed_at": None
437
543
  }
438
544
 
439
- tracker["items"].append(item)
440
- save_tracker(tracker)
545
+ api.append("tracker", "item_created", item, item["source"])
546
+ _render_tracker(api)
441
547
  return item
442
548
 
443
549
  def update_tracker_item(item_id, update_data):
444
- """Update a tracker item by id."""
445
- tracker = load_tracker()
550
+ """Update a tracker item by id (event-sourced)."""
551
+ api = _tracker_api()
552
+ _ensure_tracker_migrated(api)
446
553
 
447
- item = next((i for i in tracker["items"] if i["id"] == item_id), None)
448
- if not item:
554
+ current = _tracker_items_by_id(api)
555
+ if item_id not in current:
449
556
  raise Exception(f"404 Item not found: {item_id}")
450
557
 
558
+ patch = {"id": item_id}
451
559
  for key in ["status", "lane", "priority", "notes", "pr_link", "tags"]:
452
560
  if key in update_data:
453
- item[key] = update_data[key]
561
+ patch[key] = update_data[key]
454
562
 
455
- if update_data.get("status") == "done" and not item.get("completed_at"):
456
- item["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
563
+ if update_data.get("status") == "done" and not current[item_id].get("completed_at"):
564
+ patch["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
457
565
 
458
- save_tracker(tracker)
459
- return item
566
+ api.append("tracker", "item_updated", patch, "api")
567
+ _render_tracker(api)
568
+ return _tracker_items_by_id(api)[item_id]
460
569
 
461
570
  def delete_tracker_item(item_id):
462
- """Soft-delete a tracker item (mark as archived)."""
463
- tracker = load_tracker()
571
+ """Soft-delete a tracker item (mark as archived; event-sourced)."""
572
+ api = _tracker_api()
573
+ _ensure_tracker_migrated(api)
464
574
 
465
- item = next((i for i in tracker["items"] if i["id"] == item_id), None)
466
- if not item:
575
+ current = _tracker_items_by_id(api)
576
+ if item_id not in current:
467
577
  raise Exception(f"404 Item not found: {item_id}")
468
578
 
469
- item["status"] = "archived"
470
- save_tracker(tracker)
471
- return item
579
+ api.append("tracker", "item_archived", {"id": item_id}, "api")
580
+ _render_tracker(api)
581
+ return _tracker_items_by_id(api)[item_id]
472
582
 
473
583
  def _snapshot_data():
474
584
  """Everything the 'data' SSE section covers (header, repos, events, alerts, messages)."""
@@ -530,27 +640,66 @@ def _snapshot_orchestrator_status():
530
640
  return {"orchestrators": []}
531
641
 
532
642
  def drain_tracker_inbox():
533
- """Drain .tracker-inbox.jsonl, create items idempotently."""
643
+ """Drain .tracker-inbox.jsonl, create items idempotently.
644
+
645
+ Defect 1 fix: Atomically rename inbox file to unique processing name FIRST
646
+ to ensure only one caller processes it under concurrent access. Strengthen
647
+ dedup to check both tracker.json AND api.project("tracker") so items
648
+ in the event store but not yet rendered are also excluded.
649
+ """
534
650
  inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
535
651
  if not inbox_file.exists():
536
652
  return []
537
-
653
+
538
654
  created = []
539
655
  try:
540
- content = inbox_file.read_text(encoding='utf-8')
656
+ # Defect 1: Atomically rename inbox to unique processing name first.
657
+ # This ensures only one caller wins; others see no file.
658
+ processing_file = inbox_file.with_name(
659
+ f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
660
+ )
661
+ try:
662
+ os.replace(str(inbox_file), str(processing_file))
663
+ except FileNotFoundError:
664
+ # Another caller already renamed it; nothing to process
665
+ return []
666
+ except Exception as e:
667
+ print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
668
+ return []
669
+
670
+ content = processing_file.read_text(encoding='utf-8')
541
671
  if not content.strip():
542
- inbox_file.unlink()
672
+ processing_file.unlink()
543
673
  return []
544
-
674
+
545
675
  lines = content.strip().splitlines()
546
- tracker = load_tracker()
676
+
677
+ # Defect 1: Build dedup hash set from both tracker.json AND event store projection.
678
+ # This catches items in the event store that haven't been rendered to tracker.json yet.
547
679
  existing_hashes = set()
548
- for item in tracker.get("items", []):
680
+
681
+ # Check rendered tracker.json
682
+ tracker_json = load_tracker()
683
+ for item in tracker_json.get("items", []):
549
684
  source = item.get("source", "")
550
685
  title = item.get("title", "")
551
686
  h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
552
687
  existing_hashes.add(h)
553
-
688
+
689
+ # Also check event store projection (catches items in DB but not yet rendered)
690
+ try:
691
+ api = _tracker_api()
692
+ projected = api.project("tracker")
693
+ for item in projected.get("items", []):
694
+ source = item.get("source", "")
695
+ title = item.get("title", "")
696
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
697
+ existing_hashes.add(h)
698
+ except Exception as e:
699
+ print(f"[inbox] Failed to project tracker state: {e}", file=sys.stderr)
700
+ # Fall back to tracker.json only if projection fails
701
+ pass
702
+
554
703
  rejects = []
555
704
  for line in lines:
556
705
  line = line.strip()
@@ -561,11 +710,11 @@ def drain_tracker_inbox():
561
710
  if not isinstance(entry, dict):
562
711
  rejects.append(line)
563
712
  continue
564
-
713
+
565
714
  source = entry.get("source", "")
566
715
  title = entry.get("title", "")
567
716
  h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
568
-
717
+
569
718
  if h not in existing_hashes:
570
719
  item = create_tracker_item(entry)
571
720
  created.append(item)
@@ -574,13 +723,13 @@ def drain_tracker_inbox():
574
723
  rejects.append(line)
575
724
  except Exception as e:
576
725
  rejects.append(line + " # " + str(e))
577
-
726
+
578
727
  if rejects:
579
728
  rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
580
729
  rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
581
-
582
- inbox_file.unlink()
730
+
731
+ processing_file.unlink()
583
732
  except Exception as e:
584
733
  print(f"[inbox] Drain error: {e}", file=sys.stderr)
585
-
734
+
586
735
  return created
package/ui/config.py CHANGED
@@ -28,6 +28,7 @@ def reload():
28
28
  global WATCHDOG_HEARTBEAT, MONITOR_HEARTBEAT, REPOS_JSON, BACKUP_LOG
29
29
  global ALERTS_LOG, INBOX_FILE, AUDIT_BACKLOG_FILE
30
30
  global UI_SESSION_TOKEN_FILE, TRACKER_FILE, ORCH_STATUS_FILE
31
+ global WEB_DIST, LEDGER_FILE
31
32
  global COLLECTOR_INTERVAL, SSE_KEEPALIVE_SECONDS, SSE_MAX_CLIENTS, SSE_QUEUE_MAXSIZE, SSE_WRITE_TIMEOUT
32
33
 
33
34
  # PORT: env PORT > default 8770
@@ -75,6 +76,12 @@ def reload():
75
76
  TRACKER_FILE = STATE_DIR / "tracker.json"
76
77
  ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
77
78
 
79
+ # Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
80
+ # WEB_DIST: the committed Vite build output served at / and /assets/*.
81
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
82
+ # LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
83
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
84
+
78
85
  # Collector and SSE configuration
79
86
  COLLECTOR_INTERVAL = float(os.getenv("AESOP_UI_COLLECT_INTERVAL", "1.0"))
80
87
  SSE_KEEPALIVE_SECONDS = 15
@@ -104,6 +111,8 @@ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
104
111
  UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
105
112
  TRACKER_FILE = STATE_DIR / "tracker.json"
106
113
  ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
114
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
115
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
107
116
  COLLECTOR_INTERVAL = 1.0
108
117
  SSE_KEEPALIVE_SECONDS = 15
109
118
  SSE_MAX_CLIENTS = 100