@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript types for Aesop UI API contracts.
|
|
3
|
+
* These types are imported by U4–U7 components and must remain stable across the wave.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface HeartbeatStatus {
|
|
7
|
+
alive: 'ALIVE' | 'STALE' | 'UNKNOWN' | 'unknown' | 'not running';
|
|
8
|
+
age: number; // seconds, bucketed to 3-second intervals; -1 if unknown
|
|
9
|
+
threshold: number; // seconds until considered stale
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One fleet agent — the JSON emitted by dash/dash-extra.mjs --json
|
|
14
|
+
* (served via GET /api/agents and the `agents` SSE section; also embedded
|
|
15
|
+
* as `agents` inside GET /data).
|
|
16
|
+
* `status`: 'running' | 'idle' age-derived, overridden by security-log
|
|
17
|
+
* severities 'SUSPICIOUS' | 'HIGH' | 'DRIFT' | 'MED'.
|
|
18
|
+
* ui/agents.py de-dupes colliding 13-char ids by suffixing "-2", "-3", ...
|
|
19
|
+
*/
|
|
20
|
+
export interface Agent {
|
|
21
|
+
id: string;
|
|
22
|
+
project: string;
|
|
23
|
+
status: 'running' | 'idle' | 'SUSPICIOUS' | 'HIGH' | 'DRIFT' | 'MED' | string;
|
|
24
|
+
age_s: number; // seconds since transcript mtime
|
|
25
|
+
hint: string; // label, capped at 60 chars
|
|
26
|
+
startedAt: string | null; // ISO 8601 transcript timestamp
|
|
27
|
+
lastActivity: string | null; // ISO 8601 transcript timestamp
|
|
28
|
+
runtimeSeconds?: number;
|
|
29
|
+
tokensUsed?: number;
|
|
30
|
+
taskLabel: string; // first prompt line, capped at 80 chars
|
|
31
|
+
promptFull?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* GET /agent?id=<id> — dispatch prompt + metadata
|
|
36
|
+
* (ui/agents.py extract_agent_dispatch_prompt).
|
|
37
|
+
* Error responses are {error: string} with 400 (invalid id) or 404 (no transcript).
|
|
38
|
+
*/
|
|
39
|
+
export interface AgentDetail {
|
|
40
|
+
id: string;
|
|
41
|
+
dispatch_prompt: string;
|
|
42
|
+
dispatcher: 'main thread' | 'parent agent';
|
|
43
|
+
model: string; // "unknown" when not found in transcript
|
|
44
|
+
message_count: number;
|
|
45
|
+
first_seen: number; // epoch seconds (file mtime)
|
|
46
|
+
last_activity: number; // epoch seconds (file mtime)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GET /api/session — CSRF token for same-origin JS (U2 adds this). */
|
|
50
|
+
export interface SessionResponse {
|
|
51
|
+
token: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Uniform error payload shape for non-2xx JSON responses. */
|
|
55
|
+
export interface ApiError {
|
|
56
|
+
error: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Alert {
|
|
60
|
+
count: number;
|
|
61
|
+
lines: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TrackerItem {
|
|
65
|
+
id: string;
|
|
66
|
+
title: string;
|
|
67
|
+
priority: 'P0' | 'P1' | 'P2';
|
|
68
|
+
status: 'todo' | 'done' | 'in-progress' | 'archived';
|
|
69
|
+
lane: 'proposed' | 'ranked' | 'in-progress' | 'done';
|
|
70
|
+
source: string;
|
|
71
|
+
tags: string[];
|
|
72
|
+
notes: string | null;
|
|
73
|
+
pr_link: string | null;
|
|
74
|
+
created_at: string; // ISO 8601
|
|
75
|
+
completed_at: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `tracker` SSE section and the tracker slice of GET /api/state.
|
|
80
|
+
* NOTE: GET /api/tracker returns a BARE TrackerItem[] array (no wrapper).
|
|
81
|
+
*/
|
|
82
|
+
export interface TrackerSnapshot {
|
|
83
|
+
items: TrackerItem[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AuditBacklogItem {
|
|
87
|
+
status: '✅' | '🔵' | '⬜' | '⏸';
|
|
88
|
+
tag: string; // e.g., "[sec]"
|
|
89
|
+
title: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AuditBacklogTier {
|
|
93
|
+
tier: 'P0' | 'P1' | 'P2' | 'Needs decision';
|
|
94
|
+
items: AuditBacklogItem[];
|
|
95
|
+
done: number;
|
|
96
|
+
inflight: number;
|
|
97
|
+
todo: number;
|
|
98
|
+
total: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AuditBacklog {
|
|
102
|
+
tiers: AuditBacklogTier[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Message {
|
|
106
|
+
role: 'user' | 'assistant';
|
|
107
|
+
text: string;
|
|
108
|
+
timestamp: string; // ISO 8601
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface OrchestratorEntry {
|
|
112
|
+
id?: string;
|
|
113
|
+
role?: string;
|
|
114
|
+
activity?: string;
|
|
115
|
+
phase?: string;
|
|
116
|
+
age_seconds: number;
|
|
117
|
+
stale: boolean;
|
|
118
|
+
updated_at?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface OrchestratorStatus {
|
|
122
|
+
orchestrators: OrchestratorEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Repo status entry from .watchdog-repos.json (list passthrough, or
|
|
127
|
+
* {repo, state} pairs when the file holds an object).
|
|
128
|
+
*/
|
|
129
|
+
export interface RepoStatus {
|
|
130
|
+
repo?: string;
|
|
131
|
+
state?: unknown;
|
|
132
|
+
[key: string]: unknown;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* GET /data response AND the `data` SSE section.
|
|
137
|
+
* Note: `agents` is present on GET /data only; the SSE `data` section
|
|
138
|
+
* (collectors._snapshot_data) omits it — agents arrive on their own
|
|
139
|
+
* `agents` SSE section.
|
|
140
|
+
*/
|
|
141
|
+
export interface DashboardData {
|
|
142
|
+
watchdog: HeartbeatStatus;
|
|
143
|
+
monitor: HeartbeatStatus;
|
|
144
|
+
agents?: Agent[];
|
|
145
|
+
repos: RepoStatus[];
|
|
146
|
+
events: string[];
|
|
147
|
+
alerts: Alert;
|
|
148
|
+
messages: Message[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** POST /submit success response. */
|
|
152
|
+
export interface SubmitResponse {
|
|
153
|
+
ok: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface FullState {
|
|
157
|
+
data: DashboardData;
|
|
158
|
+
backlog: AuditBacklog;
|
|
159
|
+
agents: Agent[];
|
|
160
|
+
tracker: TrackerSnapshot;
|
|
161
|
+
status: OrchestratorStatus;
|
|
162
|
+
cost?: CostSummary;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Cost summary from GET /api/cost.
|
|
167
|
+
* Mirrors ui/cost.py get_cost_summary() docstring on branch
|
|
168
|
+
* feat/wave14-u3-cost-collector — verbatim shape, NOT provisional.
|
|
169
|
+
*/
|
|
170
|
+
export interface CostModelStats {
|
|
171
|
+
runs: number;
|
|
172
|
+
tokens_in: number;
|
|
173
|
+
tokens_out: number;
|
|
174
|
+
verdicts: {
|
|
175
|
+
OK: number;
|
|
176
|
+
FAILED: number;
|
|
177
|
+
EMPTY: number;
|
|
178
|
+
HUNG: number;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface CostDailyTotal {
|
|
183
|
+
tokens_in: number;
|
|
184
|
+
tokens_out: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface CostOverallScorecard {
|
|
188
|
+
total_runs: number;
|
|
189
|
+
ok_count: number;
|
|
190
|
+
failed_count: number;
|
|
191
|
+
empty_count: number;
|
|
192
|
+
hung_count: number;
|
|
193
|
+
ok_rate: number; // 0.0-1.0
|
|
194
|
+
failed_rate: number;
|
|
195
|
+
empty_rate: number;
|
|
196
|
+
hung_rate: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CostEstimate {
|
|
200
|
+
input_cost: number; // dollars
|
|
201
|
+
output_cost: number; // dollars
|
|
202
|
+
total_cost: number; // dollars
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface CostSummary {
|
|
206
|
+
models: Record<string, CostModelStats>; // keyed by model id
|
|
207
|
+
daily_totals: Record<string, CostDailyTotal>; // keyed by "YYYY-MM-DD"
|
|
208
|
+
overall_scorecard: CostOverallScorecard;
|
|
209
|
+
skipped_lines: number;
|
|
210
|
+
has_pricing: boolean;
|
|
211
|
+
estimates_by_model: Record<string, CostEstimate>; // empty when has_pricing is false
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* SSE event sections emitted by GET /events.
|
|
216
|
+
* Initial sections: data, backlog, agents, tracker, status
|
|
217
|
+
* Added in U3: cost
|
|
218
|
+
*/
|
|
219
|
+
export type SSESection =
|
|
220
|
+
| 'data'
|
|
221
|
+
| 'backlog'
|
|
222
|
+
| 'agents'
|
|
223
|
+
| 'tracker'
|
|
224
|
+
| 'status'
|
|
225
|
+
| 'cost';
|
|
226
|
+
|
|
227
|
+
export interface SSEConnectionStatus {
|
|
228
|
+
status: 'live' | 'reconnecting' | 'error';
|
|
229
|
+
lastError?: string;
|
|
230
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { useHashRoute, normalizeHash, ROUTES } from './useHashRoute';
|
|
4
|
+
|
|
5
|
+
function setHash(hash: string) {
|
|
6
|
+
window.location.hash = hash;
|
|
7
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
window.location.hash = '';
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('normalizeHash', () => {
|
|
15
|
+
it('passes through known routes', () => {
|
|
16
|
+
for (const r of ROUTES) {
|
|
17
|
+
expect(normalizeHash(r)).toBe(r);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('normalizes empty hash to #/', () => {
|
|
22
|
+
expect(normalizeHash('')).toBe('#/');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('normalizes unknown hashes to #/', () => {
|
|
26
|
+
expect(normalizeHash('#/bogus')).toBe('#/');
|
|
27
|
+
expect(normalizeHash('#work')).toBe('#/');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('useHashRoute', () => {
|
|
32
|
+
it('returns #/ initially with no hash', () => {
|
|
33
|
+
const { result } = renderHook(() => useHashRoute());
|
|
34
|
+
expect(result.current).toBe('#/');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('follows hashchange events', () => {
|
|
38
|
+
const { result } = renderHook(() => useHashRoute());
|
|
39
|
+
act(() => setHash('#/work'));
|
|
40
|
+
expect(result.current).toBe('#/work');
|
|
41
|
+
act(() => setHash('#/cost'));
|
|
42
|
+
expect(result.current).toBe('#/cost');
|
|
43
|
+
act(() => setHash('#/activity'));
|
|
44
|
+
expect(result.current).toBe('#/activity');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('normalizes unknown hash changes to #/', () => {
|
|
48
|
+
const { result } = renderHook(() => useHashRoute());
|
|
49
|
+
act(() => setHash('#/work'));
|
|
50
|
+
expect(result.current).toBe('#/work');
|
|
51
|
+
act(() => setHash('#/nonsense'));
|
|
52
|
+
expect(result.current).toBe('#/');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reads the initial hash on mount', () => {
|
|
56
|
+
window.location.hash = '#/cost';
|
|
57
|
+
const { result } = renderHook(() => useHashRoute());
|
|
58
|
+
expect(result.current).toBe('#/cost');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHashRoute — ~10-line hash-tab routing hook (deliberately NOT react-router; see plan D1).
|
|
3
|
+
* Routes: '#/' (overview), '#/work', '#/activity', '#/cost'.
|
|
4
|
+
* Unknown/empty hashes normalize to '#/'.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
export const ROUTES = ['#/', '#/work', '#/activity', '#/cost'] as const;
|
|
9
|
+
export type Route = (typeof ROUTES)[number];
|
|
10
|
+
|
|
11
|
+
export function normalizeHash(hash: string): Route {
|
|
12
|
+
return (ROUTES as readonly string[]).includes(hash) ? (hash as Route) : '#/';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useHashRoute(): Route {
|
|
16
|
+
const [route, setRoute] = useState<Route>(() => normalizeHash(window.location.hash));
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const onHashChange = () => setRoute(normalizeHash(window.location.hash));
|
|
19
|
+
window.addEventListener('hashchange', onHashChange);
|
|
20
|
+
return () => window.removeEventListener('hashchange', onHashChange);
|
|
21
|
+
}, []);
|
|
22
|
+
return route;
|
|
23
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSSE hook — EventSource wrapper with reconnect backoff, per-section state, and connection status.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { data, backlog, agents, tracker, status, cost, connectionStatus } = useSSE();
|
|
6
|
+
*
|
|
7
|
+
* Emits on sections: data, backlog, agents, tracker, status, cost
|
|
8
|
+
* Handles connection errors and automatic reconnect with exponential backoff (1s → 10s max).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
12
|
+
import type { DashboardData, AuditBacklog, Agent, TrackerSnapshot, OrchestratorStatus, CostSummary, SSEConnectionStatus } from './types';
|
|
13
|
+
|
|
14
|
+
export interface SSEState {
|
|
15
|
+
data: DashboardData | null;
|
|
16
|
+
backlog: AuditBacklog | null;
|
|
17
|
+
agents: Agent[] | null;
|
|
18
|
+
tracker: TrackerSnapshot | null;
|
|
19
|
+
status: OrchestratorStatus | null;
|
|
20
|
+
cost: CostSummary | null;
|
|
21
|
+
connectionStatus: SSEConnectionStatus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const initialState: SSEState = {
|
|
25
|
+
data: null,
|
|
26
|
+
backlog: null,
|
|
27
|
+
agents: null,
|
|
28
|
+
tracker: null,
|
|
29
|
+
status: null,
|
|
30
|
+
cost: null,
|
|
31
|
+
connectionStatus: { status: 'reconnecting' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function useSSE() {
|
|
35
|
+
const [state, setState] = useState<SSEState>(initialState);
|
|
36
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
37
|
+
const reconnectAttemptRef = useRef(0);
|
|
38
|
+
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
|
|
40
|
+
const getReconnectDelay = useCallback(() => {
|
|
41
|
+
const baseDelay = 1000; // 1 second
|
|
42
|
+
const maxDelay = 10000; // 10 seconds
|
|
43
|
+
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttemptRef.current), maxDelay);
|
|
44
|
+
return delay;
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const connect = useCallback(() => {
|
|
48
|
+
if (eventSourceRef.current) {
|
|
49
|
+
return; // Already connected or connecting
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const eventSource = new EventSource('/events');
|
|
53
|
+
|
|
54
|
+
eventSource.addEventListener('data', (e) => {
|
|
55
|
+
try {
|
|
56
|
+
const payload = JSON.parse(e.data);
|
|
57
|
+
setState((prev) => ({
|
|
58
|
+
...prev,
|
|
59
|
+
data: payload,
|
|
60
|
+
connectionStatus: { status: 'live' },
|
|
61
|
+
}));
|
|
62
|
+
reconnectAttemptRef.current = 0;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('Failed to parse SSE data:', err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
eventSource.addEventListener('backlog', (e) => {
|
|
69
|
+
try {
|
|
70
|
+
const payload = JSON.parse(e.data);
|
|
71
|
+
setState((prev) => ({
|
|
72
|
+
...prev,
|
|
73
|
+
backlog: payload,
|
|
74
|
+
connectionStatus: { status: 'live' },
|
|
75
|
+
}));
|
|
76
|
+
reconnectAttemptRef.current = 0;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('Failed to parse SSE backlog:', err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
eventSource.addEventListener('agents', (e) => {
|
|
83
|
+
try {
|
|
84
|
+
const payload = JSON.parse(e.data);
|
|
85
|
+
setState((prev) => ({
|
|
86
|
+
...prev,
|
|
87
|
+
agents: Array.isArray(payload) ? payload : [],
|
|
88
|
+
connectionStatus: { status: 'live' },
|
|
89
|
+
}));
|
|
90
|
+
reconnectAttemptRef.current = 0;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('Failed to parse SSE agents:', err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
eventSource.addEventListener('tracker', (e) => {
|
|
97
|
+
try {
|
|
98
|
+
const payload = JSON.parse(e.data);
|
|
99
|
+
setState((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
tracker: payload,
|
|
102
|
+
connectionStatus: { status: 'live' },
|
|
103
|
+
}));
|
|
104
|
+
reconnectAttemptRef.current = 0;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('Failed to parse SSE tracker:', err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
eventSource.addEventListener('status', (e) => {
|
|
111
|
+
try {
|
|
112
|
+
const payload = JSON.parse(e.data);
|
|
113
|
+
setState((prev) => ({
|
|
114
|
+
...prev,
|
|
115
|
+
status: payload,
|
|
116
|
+
connectionStatus: { status: 'live' },
|
|
117
|
+
}));
|
|
118
|
+
reconnectAttemptRef.current = 0;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('Failed to parse SSE status:', err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
eventSource.addEventListener('cost', (e) => {
|
|
125
|
+
try {
|
|
126
|
+
const payload = JSON.parse(e.data);
|
|
127
|
+
setState((prev) => ({
|
|
128
|
+
...prev,
|
|
129
|
+
cost: payload,
|
|
130
|
+
connectionStatus: { status: 'live' },
|
|
131
|
+
}));
|
|
132
|
+
reconnectAttemptRef.current = 0;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('Failed to parse SSE cost:', err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
eventSource.addEventListener('error', (err) => {
|
|
139
|
+
console.error('EventSource error:', err);
|
|
140
|
+
eventSource.close();
|
|
141
|
+
eventSourceRef.current = null;
|
|
142
|
+
setState((prev) => ({
|
|
143
|
+
...prev,
|
|
144
|
+
connectionStatus: {
|
|
145
|
+
status: 'reconnecting',
|
|
146
|
+
lastError: 'Connection lost',
|
|
147
|
+
},
|
|
148
|
+
}));
|
|
149
|
+
reconnectAttemptRef.current += 1;
|
|
150
|
+
const delay = getReconnectDelay();
|
|
151
|
+
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
eventSourceRef.current = eventSource;
|
|
155
|
+
setState((prev) => ({
|
|
156
|
+
...prev,
|
|
157
|
+
connectionStatus: { status: 'reconnecting' },
|
|
158
|
+
}));
|
|
159
|
+
}, [getReconnectDelay]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
connect();
|
|
163
|
+
|
|
164
|
+
return () => {
|
|
165
|
+
if (reconnectTimeoutRef.current) {
|
|
166
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
167
|
+
}
|
|
168
|
+
if (eventSourceRef.current) {
|
|
169
|
+
eventSourceRef.current.close();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}, [connect]);
|
|
173
|
+
|
|
174
|
+
return state;
|
|
175
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aesop global styles — reset, focus-visible rings, sr-only, reduced motion,
|
|
3
|
+
* app-shell layout. Component-specific styles belong with components (U4–U7);
|
|
4
|
+
* only shared shell + utility styles live here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
@import './theme.css';
|
|
8
|
+
|
|
9
|
+
/* ---- Reset ---- */
|
|
10
|
+
*,
|
|
11
|
+
*::before,
|
|
12
|
+
*::after {
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
margin: 0;
|
|
15
|
+
padding: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html {
|
|
19
|
+
-webkit-text-size-adjust: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
font-family: var(--font-sans);
|
|
24
|
+
font-size: var(--font-size-md);
|
|
25
|
+
line-height: var(--line-height-normal);
|
|
26
|
+
color: var(--color-text);
|
|
27
|
+
background: var(--color-bg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
img,
|
|
31
|
+
svg {
|
|
32
|
+
max-width: 100%;
|
|
33
|
+
display: block;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
button,
|
|
37
|
+
input,
|
|
38
|
+
select,
|
|
39
|
+
textarea {
|
|
40
|
+
font: inherit;
|
|
41
|
+
color: inherit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
a {
|
|
45
|
+
color: var(--color-accent);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---- Focus-visible rings (D5: visible focus, never removed) ---- */
|
|
49
|
+
:focus {
|
|
50
|
+
outline: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:focus-visible {
|
|
54
|
+
outline: 2px solid var(--color-focus-ring);
|
|
55
|
+
outline-offset: 2px;
|
|
56
|
+
border-radius: var(--radius-sm);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ---- Screen-reader-only utility ---- */
|
|
60
|
+
.sr-only {
|
|
61
|
+
position: absolute;
|
|
62
|
+
width: 1px;
|
|
63
|
+
height: 1px;
|
|
64
|
+
padding: 0;
|
|
65
|
+
margin: -1px;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
clip: rect(0, 0, 0, 0);
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
border: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ---- Reduced motion (D5) ---- */
|
|
73
|
+
@media (prefers-reduced-motion: reduce) {
|
|
74
|
+
*,
|
|
75
|
+
*::before,
|
|
76
|
+
*::after {
|
|
77
|
+
animation-duration: 0.01ms !important;
|
|
78
|
+
animation-iteration-count: 1 !important;
|
|
79
|
+
transition-duration: 0.01ms !important;
|
|
80
|
+
scroll-behavior: auto !important;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ---- App shell ---- */
|
|
85
|
+
.app-header {
|
|
86
|
+
position: sticky;
|
|
87
|
+
top: 0;
|
|
88
|
+
z-index: 100;
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: var(--space-3);
|
|
92
|
+
min-height: var(--header-height);
|
|
93
|
+
padding: 0 var(--space-4);
|
|
94
|
+
background: var(--color-bg-subtle);
|
|
95
|
+
border-bottom: 1px solid var(--color-border);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.app-title {
|
|
99
|
+
font-size: var(--font-size-md);
|
|
100
|
+
font-weight: var(--font-weight-bold);
|
|
101
|
+
white-space: nowrap;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.app-nav {
|
|
105
|
+
display: flex;
|
|
106
|
+
gap: var(--space-1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.app-nav a {
|
|
110
|
+
display: inline-block;
|
|
111
|
+
padding: var(--space-2) var(--space-3);
|
|
112
|
+
color: var(--color-text-muted);
|
|
113
|
+
text-decoration: none;
|
|
114
|
+
font-size: var(--font-size-sm);
|
|
115
|
+
font-weight: var(--font-weight-medium);
|
|
116
|
+
border-bottom: 2px solid transparent;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.app-nav a[aria-current='page'] {
|
|
120
|
+
color: var(--color-text);
|
|
121
|
+
border-bottom-color: var(--color-accent);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.app-nav a:hover {
|
|
125
|
+
color: var(--color-text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.app-main {
|
|
129
|
+
max-width: var(--content-max-width);
|
|
130
|
+
margin: 0 auto;
|
|
131
|
+
padding: var(--space-4);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.view-placeholder {
|
|
135
|
+
padding: var(--space-6);
|
|
136
|
+
border: 1px dashed var(--color-border);
|
|
137
|
+
border-radius: var(--radius-md);
|
|
138
|
+
color: var(--color-text-muted);
|
|
139
|
+
text-align: center;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.theme-toggle {
|
|
143
|
+
margin-left: auto;
|
|
144
|
+
padding: var(--space-1) var(--space-2);
|
|
145
|
+
background: transparent;
|
|
146
|
+
border: 1px solid var(--color-border);
|
|
147
|
+
border-radius: var(--radius-sm);
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
font-size: var(--font-size-sm);
|
|
150
|
+
color: var(--color-text-muted);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.theme-toggle:hover {
|
|
154
|
+
color: var(--color-text);
|
|
155
|
+
background: var(--color-bg-inset);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.sse-status {
|
|
159
|
+
font-size: var(--font-size-xs);
|
|
160
|
+
font-weight: var(--font-weight-medium);
|
|
161
|
+
padding: var(--space-1) var(--space-2);
|
|
162
|
+
border-radius: var(--radius-sm);
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.sse-status[data-status='live'] {
|
|
167
|
+
color: var(--color-status-ok);
|
|
168
|
+
background: var(--color-status-ok-bg);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.sse-status[data-status='reconnecting'] {
|
|
172
|
+
color: var(--color-status-warn);
|
|
173
|
+
background: var(--color-status-warn-bg);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.sse-status[data-status='error'] {
|
|
177
|
+
color: var(--color-status-error);
|
|
178
|
+
background: var(--color-status-error-bg);
|
|
179
|
+
}
|