@saadnvd1/agent-os 0.3.620 → 0.3.624
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/README.md +1 -0
- package/app/api/health/anomalies/route.ts +45 -0
- package/app/api/health/config/route.ts +31 -0
- package/app/api/health/notes/route.ts +33 -0
- package/components/Health/HealthDot.tsx +35 -0
- package/components/Health/HealthPanel.tsx +412 -0
- package/components/Pane/DesktopTabBar.tsx +3 -0
- package/components/Pane/MobileTabBar.tsx +4 -0
- package/components/Pane/MobileViewPicker.test.ts +8 -0
- package/components/Pane/MobileViewPicker.tsx +4 -1
- package/components/Pane/PaneViewToggle.test.ts +82 -0
- package/components/Pane/PaneViewToggle.tsx +12 -7
- package/components/Pane/git-visibility.ts +9 -0
- package/components/Pane/index.tsx +18 -2
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/data/git/queries.ts +16 -3
- package/data/health/index.ts +111 -0
- package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
- package/lib/health/boots.ts +79 -0
- package/lib/health/ci.ts +90 -0
- package/lib/health/config.ts +79 -0
- package/lib/health/detectors.ts +227 -0
- package/lib/health/errors.ts +90 -0
- package/lib/health/evidence.ts +172 -0
- package/lib/health/facts.ts +159 -0
- package/lib/health/fixtures.ts +30 -0
- package/lib/health/notes.ts +76 -0
- package/lib/health/pieces.ts +120 -0
- package/lib/health/route.ts +113 -0
- package/lib/health/store.ts +212 -0
- package/lib/health/tick.ts +149 -0
- package/lib/health/types.ts +148 -0
- package/lib/health/view.ts +120 -0
- package/lib/health/watchers.ts +176 -0
- package/lib/load/alert.ts +4 -8
- package/lib/models/plan-step.ts +18 -3
- package/lib/models/roles.ts +6 -0
- package/lib/orchestrator/deliver.ts +1 -0
- package/lib/orchestrator/events.ts +13 -4
- package/lib/orchestrator/watcher.ts +12 -0
- package/lib/plans/release.ts +7 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/refresh-base.ts +78 -0
- package/package.json +1 -1
- package/scripts/autodeploy +2 -2
- package/scripts/autodeploy.test.ts +2 -2
- package/server.ts +11 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anomalies: one open row per check and subject. A trip that is already open
|
|
3
|
+
* only bumps it; one that comes back within REOPEN_MS of clearing reopens the
|
|
4
|
+
* same row instead of starting a second one (and so wakes nobody again);
|
|
5
|
+
* only a rise from warn to high is news while open.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import {
|
|
10
|
+
AREA_OF,
|
|
11
|
+
MIN,
|
|
12
|
+
type AnomalyRow,
|
|
13
|
+
type CheckId,
|
|
14
|
+
type Severity,
|
|
15
|
+
type Trip,
|
|
16
|
+
type Verdict,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
// Absent this long before it clears: a blip in a check isn't an end.
|
|
20
|
+
export const CLEAR_AFTER_MS = 5 * MIN;
|
|
21
|
+
export const REOPEN_MS = 30 * MIN;
|
|
22
|
+
export const MUTE_MS = 24 * 60 * MIN;
|
|
23
|
+
|
|
24
|
+
export interface RecordResult {
|
|
25
|
+
opened: AnomalyRow[];
|
|
26
|
+
raised: AnomalyRow[];
|
|
27
|
+
closed: AnomalyRow[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rowOf = (id: number) =>
|
|
31
|
+
db.prepare(`SELECT * FROM health_anomalies WHERE id = ?`).get(id) as
|
|
32
|
+
| AnomalyRow
|
|
33
|
+
| undefined;
|
|
34
|
+
|
|
35
|
+
export function anomaly(id: number): AnomalyRow | null {
|
|
36
|
+
return rowOf(id) ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function record(trip: Trip, now: number, out: RecordResult): void {
|
|
40
|
+
const muted = db
|
|
41
|
+
.prepare(
|
|
42
|
+
`SELECT 1 FROM health_anomalies WHERE check_id = ? AND subject = ?
|
|
43
|
+
AND muted_until > ? LIMIT 1`
|
|
44
|
+
)
|
|
45
|
+
.get(trip.check, trip.subject, now);
|
|
46
|
+
if (muted) return;
|
|
47
|
+
|
|
48
|
+
const open = db
|
|
49
|
+
.prepare(
|
|
50
|
+
`SELECT * FROM health_anomalies WHERE check_id = ? AND subject = ?
|
|
51
|
+
AND closed_at IS NULL`
|
|
52
|
+
)
|
|
53
|
+
.get(trip.check, trip.subject) as AnomalyRow | undefined;
|
|
54
|
+
const detail = JSON.stringify(trip.detail);
|
|
55
|
+
if (open) {
|
|
56
|
+
const rises = open.severity === "warn" && trip.severity === "high";
|
|
57
|
+
db.prepare(
|
|
58
|
+
`UPDATE health_anomalies SET last_seen_at = ?, hits = hits + 1, line = ?,
|
|
59
|
+
detail = ?, severity = ? WHERE id = ?`
|
|
60
|
+
).run(now, trip.line, detail, rises ? "high" : open.severity, open.id);
|
|
61
|
+
if (rises) out.raised.push(rowOf(open.id)!);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const recent = db
|
|
66
|
+
.prepare(
|
|
67
|
+
`SELECT * FROM health_anomalies WHERE check_id = ? AND subject = ?
|
|
68
|
+
AND close_reason = 'cleared' AND closed_at >= ?
|
|
69
|
+
ORDER BY closed_at DESC LIMIT 1`
|
|
70
|
+
)
|
|
71
|
+
.get(trip.check, trip.subject, now - REOPEN_MS) as AnomalyRow | undefined;
|
|
72
|
+
if (recent) {
|
|
73
|
+
db.prepare(
|
|
74
|
+
`UPDATE health_anomalies SET closed_at = NULL, close_reason = NULL,
|
|
75
|
+
last_seen_at = ?, hits = hits + 1, line = ?, detail = ?,
|
|
76
|
+
severity = CASE WHEN severity = 'high' THEN 'high' ELSE ? END
|
|
77
|
+
WHERE id = ?`
|
|
78
|
+
).run(now, trip.line, detail, trip.severity, recent.id);
|
|
79
|
+
const after = rowOf(recent.id)!;
|
|
80
|
+
if (after.severity === "high" && recent.severity === "warn")
|
|
81
|
+
out.raised.push(after);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const info = db
|
|
86
|
+
.prepare(
|
|
87
|
+
`INSERT INTO health_anomalies
|
|
88
|
+
(check_id, subject, area, severity, line, detail, workspace_id,
|
|
89
|
+
opened_at, last_seen_at)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
91
|
+
)
|
|
92
|
+
.run(
|
|
93
|
+
trip.check,
|
|
94
|
+
trip.subject,
|
|
95
|
+
AREA_OF[trip.check],
|
|
96
|
+
trip.severity,
|
|
97
|
+
trip.line,
|
|
98
|
+
detail,
|
|
99
|
+
trip.workspaceId,
|
|
100
|
+
now,
|
|
101
|
+
now
|
|
102
|
+
);
|
|
103
|
+
out.opened.push(rowOf(Number(info.lastInsertRowid))!);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Records what the checks found. `evaluated` is the checks that ran, so an
|
|
107
|
+
// anomaly of a check that didn't run (piece-dup, raised elsewhere) isn't
|
|
108
|
+
// cleared for being absent.
|
|
109
|
+
export function recordTrips(
|
|
110
|
+
trips: Trip[],
|
|
111
|
+
evaluated: readonly CheckId[],
|
|
112
|
+
now: number
|
|
113
|
+
): RecordResult {
|
|
114
|
+
const out: RecordResult = { opened: [], raised: [], closed: [] };
|
|
115
|
+
db.transaction(() => {
|
|
116
|
+
for (const t of trips) record(t, now, out);
|
|
117
|
+
const open = db
|
|
118
|
+
.prepare(`SELECT * FROM health_anomalies WHERE closed_at IS NULL`)
|
|
119
|
+
.all() as AnomalyRow[];
|
|
120
|
+
for (const row of open) {
|
|
121
|
+
if (!evaluated.includes(row.check_id)) continue;
|
|
122
|
+
if (now - row.last_seen_at < CLEAR_AFTER_MS) continue;
|
|
123
|
+
if (row.last_seen_at === now) continue;
|
|
124
|
+
close(row.id, "cleared", now);
|
|
125
|
+
out.closed.push(rowOf(row.id)!);
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function close(id: number, reason: string, now: number): void {
|
|
132
|
+
db.prepare(
|
|
133
|
+
`UPDATE health_anomalies SET closed_at = ?, close_reason = ?
|
|
134
|
+
WHERE id = ? AND closed_at IS NULL`
|
|
135
|
+
).run(now, reason, id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A person's dismissal: closes it and mutes that subject for a day.
|
|
139
|
+
export function dismiss(id: number, now = Date.now()): boolean {
|
|
140
|
+
const row = rowOf(id);
|
|
141
|
+
if (!row) return false;
|
|
142
|
+
db.prepare(
|
|
143
|
+
`UPDATE health_anomalies SET closed_at = COALESCE(closed_at, ?),
|
|
144
|
+
close_reason = COALESCE(close_reason, 'dismissed'), muted_until = ?
|
|
145
|
+
WHERE id = ?`
|
|
146
|
+
).run(now, now + MUTE_MS, id);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const openAnomalies = () =>
|
|
151
|
+
db
|
|
152
|
+
.prepare(
|
|
153
|
+
`SELECT * FROM health_anomalies WHERE closed_at IS NULL
|
|
154
|
+
ORDER BY CASE severity WHEN 'high' THEN 0 ELSE 1 END, opened_at`
|
|
155
|
+
)
|
|
156
|
+
.all() as AnomalyRow[];
|
|
157
|
+
|
|
158
|
+
export const closedSince = (since: number, limit = 200) =>
|
|
159
|
+
db
|
|
160
|
+
.prepare(
|
|
161
|
+
`SELECT * FROM health_anomalies WHERE closed_at IS NOT NULL
|
|
162
|
+
AND closed_at >= ? ORDER BY closed_at DESC LIMIT ?`
|
|
163
|
+
)
|
|
164
|
+
.all(since, limit) as AnomalyRow[];
|
|
165
|
+
|
|
166
|
+
export const allSince = (since: number) =>
|
|
167
|
+
db
|
|
168
|
+
.prepare(`SELECT * FROM health_anomalies WHERE opened_at >= ?`)
|
|
169
|
+
.all(since) as AnomalyRow[];
|
|
170
|
+
|
|
171
|
+
// Whether a watcher should look: never judged, or it got worse since.
|
|
172
|
+
export function needsWake(row: AnomalyRow): boolean {
|
|
173
|
+
if (row.closed_at !== null) return false;
|
|
174
|
+
if (row.woken_severity === null) return true;
|
|
175
|
+
return row.woken_severity === "warn" && row.severity === "high";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function markWoken(id: number, severity: Severity, now: number): void {
|
|
179
|
+
db.prepare(
|
|
180
|
+
`UPDATE health_anomalies SET woken_severity = ?, woken_at = ? WHERE id = ?`
|
|
181
|
+
).run(severity, now, id);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function saveVerdict(
|
|
185
|
+
id: number,
|
|
186
|
+
verdict: Verdict,
|
|
187
|
+
tokens: { input: number; output: number } | null
|
|
188
|
+
): void {
|
|
189
|
+
db.prepare(
|
|
190
|
+
`UPDATE health_anomalies SET verdict = ?,
|
|
191
|
+
tokens_in = COALESCE(tokens_in, 0) + ?,
|
|
192
|
+
tokens_out = COALESCE(tokens_out, 0) + ? WHERE id = ?`
|
|
193
|
+
).run(JSON.stringify(verdict), tokens?.input ?? 0, tokens?.output ?? 0, id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const markDelivered = (id: number, now: number) =>
|
|
197
|
+
db
|
|
198
|
+
.prepare(`UPDATE health_anomalies SET delivered_at = ? WHERE id = ?`)
|
|
199
|
+
.run(now, id);
|
|
200
|
+
|
|
201
|
+
export const markPhoned = (id: number, now: number) =>
|
|
202
|
+
db
|
|
203
|
+
.prepare(`UPDATE health_anomalies SET phoned_at = ? WHERE id = ?`)
|
|
204
|
+
.run(now, id);
|
|
205
|
+
|
|
206
|
+
export const verdictOf = (row: Pick<AnomalyRow, "verdict">): Verdict | null => {
|
|
207
|
+
try {
|
|
208
|
+
return row.verdict ? (JSON.parse(row.verdict) as Verdict) : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The health tick: once a minute, on the orchestrator watcher's own tick,
|
|
3
|
+
* read one snapshot, run the checks, record what they found, and wake a
|
|
4
|
+
* watcher for each anomaly that is new or got worse. All of it but the wake
|
|
5
|
+
* is plain code; with nothing wrong, a tick costs no tokens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runClaude } from "../orchestrator/claude-cli";
|
|
9
|
+
import { leaveHeartbeat } from "./boots";
|
|
10
|
+
import { healthConfig, type HealthConfig } from "./config";
|
|
11
|
+
import { runDetectors } from "./detectors";
|
|
12
|
+
import { gatherFacts, liveBackgroundJobs } from "./facts";
|
|
13
|
+
import { addNote } from "./notes";
|
|
14
|
+
import { closeResolvedPieces } from "./pieces";
|
|
15
|
+
import { deliver, deliverClosed, type RouteDeps, realRoute } from "./route";
|
|
16
|
+
import {
|
|
17
|
+
markWoken,
|
|
18
|
+
needsWake,
|
|
19
|
+
openAnomalies,
|
|
20
|
+
recordTrips,
|
|
21
|
+
saveVerdict,
|
|
22
|
+
anomaly,
|
|
23
|
+
} from "./store";
|
|
24
|
+
import {
|
|
25
|
+
currentUsagePct,
|
|
26
|
+
unjudged,
|
|
27
|
+
wake,
|
|
28
|
+
type WakeResult,
|
|
29
|
+
type WatchDeps,
|
|
30
|
+
} from "./watchers";
|
|
31
|
+
import {
|
|
32
|
+
CHECKS,
|
|
33
|
+
MIN,
|
|
34
|
+
type AnomalyRow,
|
|
35
|
+
type CheckId,
|
|
36
|
+
type HealthFacts,
|
|
37
|
+
} from "./types";
|
|
38
|
+
|
|
39
|
+
export const EVERY_MS = MIN;
|
|
40
|
+
// Watchers running at once; the rest wait for the next tick.
|
|
41
|
+
export const MAX_WAKES = 2;
|
|
42
|
+
// A wake that never came back (a restart cut it off) is given up on.
|
|
43
|
+
export const WAKE_GIVE_UP_MS = 3 * MIN;
|
|
44
|
+
|
|
45
|
+
// The checks the tick evaluates: piece-dup is raised where a piece starts.
|
|
46
|
+
export const TICK_CHECKS: readonly CheckId[] = CHECKS.filter(
|
|
47
|
+
(c) => c !== "piece-dup"
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export interface TickDeps {
|
|
51
|
+
config: () => HealthConfig;
|
|
52
|
+
facts: (now: number, c: HealthConfig) => HealthFacts;
|
|
53
|
+
watch: WatchDeps;
|
|
54
|
+
route: RouteDeps;
|
|
55
|
+
wake: typeof wake;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const realTick: TickDeps = {
|
|
59
|
+
config: healthConfig,
|
|
60
|
+
facts: gatherFacts,
|
|
61
|
+
watch: { run: runClaude, usagePct: currentUsagePct },
|
|
62
|
+
route: realRoute,
|
|
63
|
+
wake,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const inFlight = new Set<number>();
|
|
67
|
+
let last = 0;
|
|
68
|
+
|
|
69
|
+
export function resetTick(): void {
|
|
70
|
+
last = 0;
|
|
71
|
+
inFlight.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function finish(
|
|
75
|
+
row: AnomalyRow,
|
|
76
|
+
result: WakeResult,
|
|
77
|
+
now: number,
|
|
78
|
+
deps: TickDeps
|
|
79
|
+
): void {
|
|
80
|
+
saveVerdict(row.id, result.verdict, result.tokens);
|
|
81
|
+
const fresh = anomaly(row.id);
|
|
82
|
+
if (!fresh) return;
|
|
83
|
+
// A verdict that says it's normal teaches the area what normal is.
|
|
84
|
+
if (!result.verdict.real && result.verdict.note)
|
|
85
|
+
addNote(row.area, result.verdict.note, "watcher", now);
|
|
86
|
+
deliver(fresh, result.verdict, now, deps.route);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function wakeOne(
|
|
90
|
+
row: AnomalyRow,
|
|
91
|
+
facts: HealthFacts,
|
|
92
|
+
c: HealthConfig,
|
|
93
|
+
deps: TickDeps
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
inFlight.add(row.id);
|
|
96
|
+
try {
|
|
97
|
+
const result = await deps.wake(row, facts, c, deps.watch);
|
|
98
|
+
finish(row, result, Date.now(), deps);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(`[health] watcher for #${row.id} failed:`, error);
|
|
101
|
+
finish(
|
|
102
|
+
row,
|
|
103
|
+
{ verdict: unjudged(row, "the watcher failed"), tokens: null },
|
|
104
|
+
Date.now(),
|
|
105
|
+
deps
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
inFlight.delete(row.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Anomalies whose wake was cut off get their own words delivered.
|
|
113
|
+
function giveUpStale(now: number, deps: TickDeps): void {
|
|
114
|
+
for (const row of openAnomalies()) {
|
|
115
|
+
if (row.woken_at === null || row.verdict !== null) continue;
|
|
116
|
+
if (inFlight.has(row.id) || now - row.woken_at < WAKE_GIVE_UP_MS) continue;
|
|
117
|
+
finish(
|
|
118
|
+
row,
|
|
119
|
+
{ verdict: unjudged(row, "the watcher was cut off"), tokens: null },
|
|
120
|
+
now,
|
|
121
|
+
deps
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Returns once the synchronous part is done; wakes run on in the background.
|
|
127
|
+
export function healthTick(
|
|
128
|
+
now = Date.now(),
|
|
129
|
+
deps: TickDeps = realTick
|
|
130
|
+
): Promise<void>[] {
|
|
131
|
+
if (now - last < EVERY_MS) return [];
|
|
132
|
+
last = now;
|
|
133
|
+
const c = deps.config();
|
|
134
|
+
const facts = deps.facts(now, c);
|
|
135
|
+
const found = recordTrips(runDetectors(facts, c), TICK_CHECKS, now);
|
|
136
|
+
for (const row of found.closed) deliverClosed(row, now, deps.route);
|
|
137
|
+
closeResolvedPieces(now);
|
|
138
|
+
leaveHeartbeat(now, liveBackgroundJobs());
|
|
139
|
+
giveUpStale(now, deps);
|
|
140
|
+
|
|
141
|
+
const running: Promise<void>[] = [];
|
|
142
|
+
for (const row of openAnomalies()) {
|
|
143
|
+
if (!needsWake(row) || inFlight.has(row.id)) continue;
|
|
144
|
+
if (inFlight.size >= MAX_WAKES) break;
|
|
145
|
+
markWoken(row.id, row.severity, now);
|
|
146
|
+
running.push(wakeOne(row, facts, c, deps));
|
|
147
|
+
}
|
|
148
|
+
return running;
|
|
149
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// What the health watcher knows and says. Plain data, no I/O: the detectors
|
|
2
|
+
// are pure functions of HealthFacts, and a trip is what one returns.
|
|
3
|
+
|
|
4
|
+
export const AREAS = ["scheduler", "pipeline", "tasks"] as const;
|
|
5
|
+
export type Area = (typeof AREAS)[number];
|
|
6
|
+
|
|
7
|
+
export const AREA_LABELS: Record<Area, string> = {
|
|
8
|
+
scheduler: "Scheduler",
|
|
9
|
+
pipeline: "Pipeline",
|
|
10
|
+
tasks: "Tasks",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const CHECKS = [
|
|
14
|
+
"load-red",
|
|
15
|
+
"queue-held",
|
|
16
|
+
"bg-quiet",
|
|
17
|
+
"piece-dup",
|
|
18
|
+
"restarts",
|
|
19
|
+
"ci-slow",
|
|
20
|
+
"armed-stuck",
|
|
21
|
+
"server-errors",
|
|
22
|
+
] as const;
|
|
23
|
+
export type CheckId = (typeof CHECKS)[number];
|
|
24
|
+
|
|
25
|
+
export const AREA_OF: Record<CheckId, Area> = {
|
|
26
|
+
"load-red": "scheduler",
|
|
27
|
+
"queue-held": "scheduler",
|
|
28
|
+
"bg-quiet": "tasks",
|
|
29
|
+
"piece-dup": "pipeline",
|
|
30
|
+
restarts: "pipeline",
|
|
31
|
+
"ci-slow": "pipeline",
|
|
32
|
+
"armed-stuck": "pipeline",
|
|
33
|
+
"server-errors": "pipeline",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// High can only be reached from warn while an anomaly is open.
|
|
37
|
+
export type Severity = "warn" | "high";
|
|
38
|
+
|
|
39
|
+
export interface Trip {
|
|
40
|
+
check: CheckId;
|
|
41
|
+
// What it is about: a machine, queue row, session and task, PR, piece or
|
|
42
|
+
// "server". One open anomaly per check and subject.
|
|
43
|
+
subject: string;
|
|
44
|
+
severity: Severity;
|
|
45
|
+
line: string;
|
|
46
|
+
detail: Record<string, unknown>;
|
|
47
|
+
workspaceId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MachineFact {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
level: "green" | "amber" | "red" | null;
|
|
54
|
+
// When it turned red, if it still is.
|
|
55
|
+
redSince: number | null;
|
|
56
|
+
load1?: number;
|
|
57
|
+
cores?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface QueueFact {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string | null;
|
|
63
|
+
projectId: string;
|
|
64
|
+
workspaceId: string | null;
|
|
65
|
+
createdAt: number;
|
|
66
|
+
// Why it hasn't started, in words.
|
|
67
|
+
heldBy: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BgFact {
|
|
71
|
+
sessionId: string;
|
|
72
|
+
sessionName: string;
|
|
73
|
+
workspaceId: string | null;
|
|
74
|
+
taskId: string;
|
|
75
|
+
description: string;
|
|
76
|
+
startedAt: number;
|
|
77
|
+
// Null when the task has printed nothing at all.
|
|
78
|
+
outputAt: number | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ArmedFact {
|
|
82
|
+
sessionId: string;
|
|
83
|
+
name: string;
|
|
84
|
+
workspaceId: string | null;
|
|
85
|
+
armedAt: number;
|
|
86
|
+
prNumber: number | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CiFact {
|
|
90
|
+
projectId: string;
|
|
91
|
+
workspaceId: string | null;
|
|
92
|
+
prNumber: number;
|
|
93
|
+
head: string;
|
|
94
|
+
pendingSince: number;
|
|
95
|
+
// Settled durations of this project's recent runs, ms.
|
|
96
|
+
baseline: number[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface HealthFacts {
|
|
100
|
+
now: number;
|
|
101
|
+
machines: MachineFact[];
|
|
102
|
+
queue: QueueFact[];
|
|
103
|
+
bg: BgFact[];
|
|
104
|
+
armed: ArmedFact[];
|
|
105
|
+
ci: CiFact[];
|
|
106
|
+
// Server start times inside the restart window.
|
|
107
|
+
boots: number[];
|
|
108
|
+
errors: { recent: number; baselineMean: number };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Verdict {
|
|
112
|
+
real: boolean;
|
|
113
|
+
cause: string;
|
|
114
|
+
action: string;
|
|
115
|
+
saadOnly: boolean;
|
|
116
|
+
// Said by the model, so shown as its word, never as a fact.
|
|
117
|
+
probe?: string | null;
|
|
118
|
+
note?: string | null;
|
|
119
|
+
// Set when no model judged it, and why.
|
|
120
|
+
unjudged?: boolean;
|
|
121
|
+
why?: string | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AnomalyRow {
|
|
125
|
+
id: number;
|
|
126
|
+
check_id: CheckId;
|
|
127
|
+
subject: string;
|
|
128
|
+
area: Area;
|
|
129
|
+
severity: Severity;
|
|
130
|
+
line: string;
|
|
131
|
+
detail: string;
|
|
132
|
+
workspace_id: string | null;
|
|
133
|
+
opened_at: number;
|
|
134
|
+
last_seen_at: number;
|
|
135
|
+
closed_at: number | null;
|
|
136
|
+
close_reason: string | null;
|
|
137
|
+
muted_until: number | null;
|
|
138
|
+
hits: number;
|
|
139
|
+
woken_severity: Severity | null;
|
|
140
|
+
woken_at: number | null;
|
|
141
|
+
verdict: string | null;
|
|
142
|
+
tokens_in: number | null;
|
|
143
|
+
tokens_out: number | null;
|
|
144
|
+
delivered_at: number | null;
|
|
145
|
+
phoned_at: number | null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const MIN = 60 * 1000;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// What the UI gets of an anomaly. Plain data, no Node imports: the panel and
|
|
2
|
+
// the routes share it.
|
|
3
|
+
|
|
4
|
+
import type { AnomalyRow, Area, CheckId, Severity, Verdict } from "./types";
|
|
5
|
+
|
|
6
|
+
// "11m", "2h 5m".
|
|
7
|
+
export function duration(ms: number): string {
|
|
8
|
+
const m = Math.round(ms / 60_000);
|
|
9
|
+
return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AnomalyView {
|
|
13
|
+
id: number;
|
|
14
|
+
check: CheckId;
|
|
15
|
+
area: Area;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
subject: string;
|
|
18
|
+
line: string;
|
|
19
|
+
openedAt: number;
|
|
20
|
+
closedAt: number | null;
|
|
21
|
+
closeReason: string | null;
|
|
22
|
+
hits: number;
|
|
23
|
+
verdict: Verdict | null;
|
|
24
|
+
// A watcher was woken and hasn't answered yet.
|
|
25
|
+
judging: boolean;
|
|
26
|
+
tokens: number;
|
|
27
|
+
needsYou: boolean;
|
|
28
|
+
phoned: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CheckSummary {
|
|
32
|
+
check: CheckId;
|
|
33
|
+
opened: number;
|
|
34
|
+
real: number;
|
|
35
|
+
medianOpenMs: number | null;
|
|
36
|
+
wakes: number;
|
|
37
|
+
tokens: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HealthView {
|
|
41
|
+
// When this was read, so "open so far" is computed from the server's clock.
|
|
42
|
+
now: number;
|
|
43
|
+
open: AnomalyView[];
|
|
44
|
+
history: AnomalyView[];
|
|
45
|
+
summary: CheckSummary[];
|
|
46
|
+
needsYou: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parse = (raw: string | null): Verdict | null => {
|
|
50
|
+
try {
|
|
51
|
+
return raw ? (JSON.parse(raw) as Verdict) : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function toView(row: AnomalyRow): AnomalyView {
|
|
58
|
+
const verdict = parse(row.verdict);
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
check: row.check_id,
|
|
62
|
+
area: row.area,
|
|
63
|
+
severity: row.severity,
|
|
64
|
+
subject: row.subject,
|
|
65
|
+
line: row.line,
|
|
66
|
+
openedAt: row.opened_at,
|
|
67
|
+
closedAt: row.closed_at,
|
|
68
|
+
closeReason: row.close_reason,
|
|
69
|
+
hits: row.hits,
|
|
70
|
+
verdict,
|
|
71
|
+
judging:
|
|
72
|
+
row.woken_at !== null && verdict === null && row.closed_at === null,
|
|
73
|
+
tokens: (row.tokens_in ?? 0) + (row.tokens_out ?? 0),
|
|
74
|
+
needsYou: verdict?.real === true && verdict.saadOnly === true,
|
|
75
|
+
phoned: row.phoned_at !== null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const median = (xs: number[]) => {
|
|
80
|
+
if (!xs.length) return null;
|
|
81
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
82
|
+
return s[Math.floor(s.length / 2)];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function summarize(rows: AnomalyRow[]): CheckSummary[] {
|
|
86
|
+
const by = new Map<CheckId, AnomalyRow[]>();
|
|
87
|
+
for (const r of rows) by.set(r.check_id, [...(by.get(r.check_id) ?? []), r]);
|
|
88
|
+
return [...by.entries()]
|
|
89
|
+
.map(([check, rs]) => ({
|
|
90
|
+
check,
|
|
91
|
+
opened: rs.length,
|
|
92
|
+
real: rs.filter((r) => parse(r.verdict)?.real === true).length,
|
|
93
|
+
medianOpenMs: median(
|
|
94
|
+
rs
|
|
95
|
+
.filter((r) => r.closed_at !== null)
|
|
96
|
+
.map((r) => (r.closed_at as number) - r.opened_at)
|
|
97
|
+
),
|
|
98
|
+
wakes: rs.filter((r) => r.woken_at !== null).length,
|
|
99
|
+
tokens: rs.reduce(
|
|
100
|
+
(n, r) => n + (r.tokens_in ?? 0) + (r.tokens_out ?? 0),
|
|
101
|
+
0
|
|
102
|
+
),
|
|
103
|
+
}))
|
|
104
|
+
.sort((a, b) => b.opened - a.opened);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function healthView(
|
|
108
|
+
open: AnomalyRow[],
|
|
109
|
+
closed: AnomalyRow[],
|
|
110
|
+
week: AnomalyRow[]
|
|
111
|
+
): HealthView {
|
|
112
|
+
const views = open.map(toView);
|
|
113
|
+
return {
|
|
114
|
+
now: Date.now(),
|
|
115
|
+
open: views,
|
|
116
|
+
history: closed.map(toView),
|
|
117
|
+
summary: summarize(week),
|
|
118
|
+
needsYou: views.filter((v) => v.needsYou).length,
|
|
119
|
+
};
|
|
120
|
+
}
|