@matt82198/aesop 0.1.0-beta.5 → 0.1.0

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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -1,218 +0,0 @@
1
- /**
2
- * Typed API client with CSRF header support.
3
- * Fetches CSRF token from window.__AESOP_CSRF_TOKEN__ (set via sentinel in index.html)
4
- * or falls back to GET /api/session if sentinel is unavailable (e.g., dev server without build).
5
- */
6
-
7
- import type {
8
- FullState,
9
- CostSummary,
10
- DashboardData,
11
- AuditBacklog,
12
- Agent,
13
- AgentDetail,
14
- TrackerItem,
15
- SubmitResponse,
16
- } from './types';
17
-
18
- let _csrfTokenCache: string | null = null;
19
- let _csrfTokenPromise: Promise<string> | null = null;
20
-
21
- /**
22
- * Get the CSRF token from the sentinel-injected global or /api/session endpoint.
23
- * Caches the result to avoid repeated fetches.
24
- */
25
- async function getCSRFToken(): Promise<string> {
26
- // Fast path: check if token is already in the window object
27
- if (typeof window !== 'undefined') {
28
- const sentinel = (window as any).__AESOP_CSRF_TOKEN__;
29
- if (sentinel && typeof sentinel === 'string') {
30
- return sentinel;
31
- }
32
- }
33
-
34
- // Avoid concurrent /api/session calls
35
- if (_csrfTokenPromise) {
36
- return _csrfTokenPromise;
37
- }
38
-
39
- if (_csrfTokenCache) {
40
- return _csrfTokenCache;
41
- }
42
-
43
- _csrfTokenPromise = (async () => {
44
- try {
45
- const res = await fetch('/api/session', {
46
- method: 'GET',
47
- credentials: 'same-origin',
48
- });
49
- if (!res.ok) {
50
- throw new Error(`/api/session returned ${res.status}`);
51
- }
52
- const data = await res.json();
53
- const token = data.token ?? '';
54
- _csrfTokenCache = token;
55
- return token;
56
- } catch (err) {
57
- console.error('Failed to fetch CSRF token:', err);
58
- return '';
59
- }
60
- })();
61
-
62
- return _csrfTokenPromise;
63
- }
64
-
65
- interface FetchOptions extends RequestInit {
66
- requireCSRF?: boolean;
67
- }
68
-
69
- /**
70
- * Typed fetch wrapper that injects CSRF header for mutations.
71
- */
72
- async function typedFetch<T>(
73
- url: string,
74
- options?: FetchOptions
75
- ): Promise<T> {
76
- const opts = { ...options };
77
-
78
- // For POST/PUT/DELETE, inject CSRF header
79
- if (opts.requireCSRF !== false && opts.method && ['POST', 'PUT', 'DELETE'].includes(opts.method)) {
80
- const token = await getCSRFToken();
81
- opts.headers = {
82
- ...(opts.headers as Record<string, string>),
83
- 'X-Aesop-Token': token,
84
- 'Content-Type': 'application/json',
85
- };
86
- }
87
-
88
- const res = await fetch(url, opts);
89
- if (!res.ok) {
90
- const text = await res.text();
91
- throw new Error(`HTTP ${res.status}: ${text}`);
92
- }
93
-
94
- return res.json() as Promise<T>;
95
- }
96
-
97
- /**
98
- * GET /data — dashboard data snapshot
99
- */
100
- export async function fetchData(): Promise<DashboardData> {
101
- return typedFetch('/data', { method: 'GET', requireCSRF: false });
102
- }
103
-
104
- /**
105
- * GET /api/state — consolidated first-paint snapshot (U2 adds this)
106
- */
107
- export async function fetchState(): Promise<FullState> {
108
- return typedFetch('/api/state', { method: 'GET', requireCSRF: false });
109
- }
110
-
111
- /**
112
- * GET /api/backlog — audit backlog
113
- */
114
- export async function fetchBacklog(): Promise<AuditBacklog> {
115
- return typedFetch('/api/backlog', { method: 'GET', requireCSRF: false });
116
- }
117
-
118
- /**
119
- * GET /api/agents — fleet agents list
120
- */
121
- export async function fetchAgents(): Promise<Agent[]> {
122
- return typedFetch('/api/agents', { method: 'GET', requireCSRF: false });
123
- }
124
-
125
- /**
126
- * GET /api/tracker — tracker items with optional filters
127
- */
128
- export async function fetchTrackerItems(
129
- status?: string,
130
- priority?: string
131
- ): Promise<TrackerItem[]> {
132
- const params = new URLSearchParams();
133
- if (status) params.append('status', status);
134
- if (priority) params.append('priority', priority);
135
- const query = params.toString();
136
- const url = `/api/tracker${query ? '?' + query : ''}`;
137
- return typedFetch(url, { method: 'GET', requireCSRF: false });
138
- }
139
-
140
- /**
141
- * GET /agent?id=<id> — agent dispatch prompt and metadata
142
- */
143
- export async function fetchAgent(agentId: string): Promise<AgentDetail> {
144
- const url = `/agent?id=${encodeURIComponent(agentId)}`;
145
- return typedFetch(url, { method: 'GET', requireCSRF: false });
146
- }
147
-
148
- /**
149
- * GET /api/cost — cost summary with token/verdict data (U3)
150
- */
151
- export async function fetchCost(): Promise<CostSummary> {
152
- return typedFetch('/api/cost', { method: 'GET', requireCSRF: false });
153
- }
154
-
155
- /**
156
- * POST /submit — submit text to inbox
157
- */
158
- export async function submitInbox(text: string): Promise<SubmitResponse> {
159
- return typedFetch('/submit', {
160
- method: 'POST',
161
- requireCSRF: true,
162
- body: JSON.stringify({ text }),
163
- });
164
- }
165
-
166
- /**
167
- * POST /api/tracker — create tracker item
168
- */
169
- export async function createTrackerItem(data: {
170
- title: string;
171
- priority?: string;
172
- status?: string;
173
- lane?: string;
174
- source?: string;
175
- tags?: string[];
176
- notes?: string;
177
- pr_link?: string;
178
- }): Promise<TrackerItem> {
179
- return typedFetch('/api/tracker', {
180
- method: 'POST',
181
- requireCSRF: true,
182
- body: JSON.stringify(data),
183
- });
184
- }
185
-
186
- /**
187
- * POST /api/tracker/<id> — update tracker item
188
- */
189
- export async function updateTrackerItem(
190
- itemId: string,
191
- data: Partial<{
192
- status: string;
193
- lane: string;
194
- priority: string;
195
- notes: string;
196
- pr_link: string;
197
- tags: string[];
198
- }>
199
- ): Promise<TrackerItem> {
200
- const url = `/api/tracker/${encodeURIComponent(itemId)}`;
201
- return typedFetch(url, {
202
- method: 'POST',
203
- requireCSRF: true,
204
- body: JSON.stringify(data),
205
- });
206
- }
207
-
208
- /**
209
- * POST /api/tracker/<id>?action=delete — delete (archive) tracker item
210
- */
211
- export async function deleteTrackerItem(itemId: string): Promise<TrackerItem> {
212
- const url = `/api/tracker/${encodeURIComponent(itemId)}?action=delete`;
213
- return typedFetch(url, {
214
- method: 'POST',
215
- requireCSRF: true,
216
- body: '{}',
217
- });
218
- }
@@ -1,89 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { formatAge, formatTokens, formatTimestamp, formatCurrency, formatPercent, capitalize } from './format';
3
-
4
- describe('formatAge', () => {
5
- it('returns unknown for negative ages', () => {
6
- expect(formatAge(-1)).toBe('unknown');
7
- });
8
-
9
- it('formats seconds', () => {
10
- expect(formatAge(0)).toBe('0s');
11
- expect(formatAge(45)).toBe('45s');
12
- });
13
-
14
- it('formats minutes with seconds remainder', () => {
15
- expect(formatAge(90)).toBe('1m 30s');
16
- expect(formatAge(120)).toBe('2m');
17
- });
18
-
19
- it('formats hours with minutes remainder', () => {
20
- expect(formatAge(3600)).toBe('1h');
21
- expect(formatAge(3660)).toBe('1h 1m');
22
- });
23
-
24
- it('formats days with hours remainder', () => {
25
- expect(formatAge(86400)).toBe('1d');
26
- expect(formatAge(86400 + 3 * 3600)).toBe('1d 3h');
27
- });
28
- });
29
-
30
- describe('formatTokens', () => {
31
- it('leaves small counts alone', () => {
32
- expect(formatTokens(0)).toBe('0');
33
- expect(formatTokens(999)).toBe('999');
34
- });
35
-
36
- it('formats thousands', () => {
37
- expect(formatTokens(1000)).toBe('1K');
38
- expect(formatTokens(1200)).toBe('1.2K');
39
- expect(formatTokens(999999)).toBe('999K');
40
- });
41
-
42
- it('formats millions', () => {
43
- expect(formatTokens(1000000)).toBe('1M');
44
- expect(formatTokens(1500000)).toBe('1.5M');
45
- });
46
- });
47
-
48
- describe('formatTimestamp', () => {
49
- it('says now for very recent timestamps', () => {
50
- expect(formatTimestamp(new Date().toISOString())).toBe('now');
51
- });
52
-
53
- it('formats minutes ago', () => {
54
- const t = new Date(Date.now() - 5 * 60 * 1000).toISOString();
55
- expect(formatTimestamp(t)).toBe('5m ago');
56
- });
57
-
58
- it('formats hours ago', () => {
59
- const t = new Date(Date.now() - 3 * 3600 * 1000).toISOString();
60
- expect(formatTimestamp(t)).toBe('3h ago');
61
- });
62
-
63
- it('returns input on unparseable strings', () => {
64
- // new Date('garbage') is Invalid Date; formatter must not throw
65
- const out = formatTimestamp('garbage');
66
- expect(typeof out).toBe('string');
67
- });
68
- });
69
-
70
- describe('formatCurrency', () => {
71
- it('formats dollars with two decimals', () => {
72
- expect(formatCurrency(1.234)).toBe('$1.23');
73
- expect(formatCurrency(0)).toBe('$0.00');
74
- });
75
- });
76
-
77
- describe('formatPercent', () => {
78
- it('formats fraction as percentage with one decimal', () => {
79
- expect(formatPercent(0.456)).toBe('45.6%');
80
- expect(formatPercent(1)).toBe('100.0%');
81
- });
82
- });
83
-
84
- describe('capitalize', () => {
85
- it('capitalizes first letter', () => {
86
- expect(capitalize('running')).toBe('Running');
87
- expect(capitalize('')).toBe('');
88
- });
89
- });
@@ -1,103 +0,0 @@
1
- /**
2
- * Formatting utilities for display values.
3
- */
4
-
5
- /**
6
- * Format age in seconds as human-readable string.
7
- * Examples: "2s", "1m 30s", "2h", "1d 3h"
8
- */
9
- export function formatAge(ageSeconds: number): string {
10
- if (ageSeconds < 0) return 'unknown';
11
- if (ageSeconds < 60) return `${ageSeconds}s`;
12
- if (ageSeconds < 3600) {
13
- const minutes = Math.floor(ageSeconds / 60);
14
- const seconds = ageSeconds % 60;
15
- return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
16
- }
17
- if (ageSeconds < 86400) {
18
- const hours = Math.floor(ageSeconds / 3600);
19
- const minutes = Math.floor((ageSeconds % 3600) / 60);
20
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
21
- }
22
- const days = Math.floor(ageSeconds / 86400);
23
- const hours = Math.floor((ageSeconds % 86400) / 3600);
24
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
25
- }
26
-
27
- /**
28
- * Format token count as human-readable string.
29
- * Examples: "123", "1.2K", "1.5M"
30
- */
31
- export function formatTokens(count: number): string {
32
- if (count < 1000) return String(count);
33
- if (count < 1000000) {
34
- const k = (count / 1000).toFixed(1);
35
- return k.endsWith('.0') ? `${Math.floor(count / 1000)}K` : `${k}K`;
36
- }
37
- const m = (count / 1000000).toFixed(1);
38
- return m.endsWith('.0') ? `${Math.floor(count / 1000000)}M` : `${m}M`;
39
- }
40
-
41
- /**
42
- * Format ISO 8601 timestamp as relative time or date.
43
- * Examples: "now", "2m ago", "Today 14:30", "Jan 15 2026"
44
- */
45
- export function formatTimestamp(isoString: string): string {
46
- try {
47
- const date = new Date(isoString);
48
- const now = new Date();
49
- const diffMs = now.getTime() - date.getTime();
50
- const diffSecs = Math.floor(diffMs / 1000);
51
- const diffMins = Math.floor(diffSecs / 60);
52
- const diffHours = Math.floor(diffMins / 60);
53
- const diffDays = Math.floor(diffHours / 24);
54
-
55
- if (diffSecs < 60) return 'now';
56
- if (diffMins < 60) return `${diffMins}m ago`;
57
- if (diffHours < 24) return `${diffHours}h ago`;
58
- if (diffDays === 0) {
59
- // Today
60
- const hours = String(date.getHours()).padStart(2, '0');
61
- const mins = String(date.getMinutes()).padStart(2, '0');
62
- return `Today ${hours}:${mins}`;
63
- }
64
- if (diffDays < 365) {
65
- // This year: "Jan 15 14:30"
66
- const monthStr = date.toLocaleString('en-US', { month: 'short' });
67
- const day = date.getDate();
68
- const hours = String(date.getHours()).padStart(2, '0');
69
- const mins = String(date.getMinutes()).padStart(2, '0');
70
- return `${monthStr} ${day} ${hours}:${mins}`;
71
- }
72
- // Different year: "Jan 15 2025"
73
- const monthStr = date.toLocaleString('en-US', { month: 'short' });
74
- const day = date.getDate();
75
- const year = date.getFullYear();
76
- return `${monthStr} ${day} ${year}`;
77
- } catch (err) {
78
- return isoString;
79
- }
80
- }
81
-
82
- /**
83
- * Format currency amount.
84
- * Examples: "$1.23", "$0.01", "$123.45"
85
- */
86
- export function formatCurrency(usd: number): string {
87
- return `$${usd.toFixed(2)}`;
88
- }
89
-
90
- /**
91
- * Format percentage with one decimal.
92
- * Examples: "45.6%", "100.0%"
93
- */
94
- export function formatPercent(fraction: number): string {
95
- return `${(fraction * 100).toFixed(1)}%`;
96
- }
97
-
98
- /**
99
- * Capitalize first letter of string.
100
- */
101
- export function capitalize(s: string): string {
102
- return s.charAt(0).toUpperCase() + s.slice(1);
103
- }
@@ -1,84 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { sanitizeUrl } from './sanitizeUrl';
3
-
4
- describe('sanitizeUrl', () => {
5
- it('allows http URLs', () => {
6
- const url = 'http://example.com/path';
7
- expect(sanitizeUrl(url)).toBe(url);
8
- });
9
-
10
- it('allows https URLs', () => {
11
- const url = 'https://github.com/owner/repo/pull/123';
12
- expect(sanitizeUrl(url)).toBe(url);
13
- });
14
-
15
- it('allows https URLs with query params', () => {
16
- const url = 'https://example.com/search?q=test&lang=en';
17
- expect(sanitizeUrl(url)).toBe(url);
18
- });
19
-
20
- it('allows https URLs with explicit ports', () => {
21
- const url = 'https://example.com:8443/path';
22
- expect(sanitizeUrl(url)).toBe(url);
23
- });
24
-
25
- it('allows http URLs on localhost ports', () => {
26
- const url = 'http://localhost:8770/data';
27
- expect(sanitizeUrl(url)).toBe(url);
28
- });
29
-
30
- it('blocks javascript: URLs', () => {
31
- expect(sanitizeUrl('javascript:alert("xss")')).toBeNull();
32
- });
33
-
34
- it('blocks javascript: URLs with mixed case', () => {
35
- expect(sanitizeUrl('JaVaScRiPt:alert(1)')).toBeNull();
36
- });
37
-
38
- it('blocks data: URLs', () => {
39
- expect(sanitizeUrl('data:text/html,<script>alert("xss")</script>')).toBeNull();
40
- });
41
-
42
- it('blocks vbscript: URLs', () => {
43
- expect(sanitizeUrl('vbscript:msgbox("xss")')).toBeNull();
44
- });
45
-
46
- it('blocks mailto: URLs', () => {
47
- expect(sanitizeUrl('mailto:attacker@example.com')).toBeNull();
48
- });
49
-
50
- it('blocks file: URLs', () => {
51
- expect(sanitizeUrl('file:///etc/passwd')).toBeNull();
52
- });
53
-
54
- it('allows relative paths (resolve same-origin, matching old sanitizeURL)', () => {
55
- // Old dashboard.html sanitizeURL parses with base location.href, so a
56
- // relative path resolves to same-origin http and is safe/allowed.
57
- expect(sanitizeUrl('/path/to/page')).toBe('/path/to/page');
58
- expect(sanitizeUrl('relative/page')).toBe('relative/page');
59
- });
60
-
61
- it('returns the original string, not the resolved URL', () => {
62
- expect(sanitizeUrl('/pr/123')).toBe('/pr/123');
63
- });
64
-
65
- it('returns null for empty strings', () => {
66
- expect(sanitizeUrl('')).toBeNull();
67
- expect(sanitizeUrl(' ')).toBeNull();
68
- });
69
-
70
- it('returns null for null/undefined', () => {
71
- expect(sanitizeUrl(null)).toBeNull();
72
- expect(sanitizeUrl(undefined)).toBeNull();
73
- });
74
-
75
- it('returns null for non-string input', () => {
76
- expect(sanitizeUrl(123 as any)).toBeNull();
77
- expect(sanitizeUrl({} as any)).toBeNull();
78
- });
79
-
80
- it('trims whitespace before validating', () => {
81
- const url = ' https://example.com ';
82
- expect(sanitizeUrl(url)).toBe('https://example.com');
83
- });
84
- });
@@ -1,38 +0,0 @@
1
- /**
2
- * URL sanitization for safe rendering in anchor tags.
3
- * Prevents XSS by allowlisting only http: and https: schemes.
4
- *
5
- * Semantics ported from the old dashboard.html sanitizeURL():
6
- * - The URL is parsed with `location.href` as the base, so relative URLs
7
- * resolve same-origin (http/https) and are therefore allowed.
8
- * - Anything whose resolved protocol is not http:/https: (javascript:,
9
- * data:, vbscript:, file:, mailto:, ...) is rejected.
10
- * - Unparseable input is treated as unsafe.
11
- *
12
- * Returns the ORIGINAL string when safe (never the resolved form), or null
13
- * when unsafe so the caller can render an inert, href-less element.
14
- */
15
-
16
- export function sanitizeUrl(url: string | null | undefined): string | null {
17
- if (!url || typeof url !== 'string') {
18
- return null;
19
- }
20
-
21
- const trimmed = url.trim();
22
- if (!trimmed) {
23
- return null;
24
- }
25
-
26
- try {
27
- const base =
28
- typeof location !== 'undefined' ? location.href : 'http://localhost/';
29
- const parsed = new URL(trimmed, base);
30
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
31
- return trimmed;
32
- }
33
- return null;
34
- } catch (err) {
35
- // Unparseable URL: treat as unsafe.
36
- return null;
37
- }
38
- }