@contraagent/api 0.0.0-stage → 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.
- package/README.md +102 -2
- package/dist/client.d.ts +54 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +68 -0
- package/dist/client.js.map +1 -0
- package/dist/derive.d.ts +81 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +89 -0
- package/dist/derive.js.map +1 -0
- package/dist/export.d.ts +36 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +130 -0
- package/dist/export.js.map +1 -0
- package/dist/feed.d.ts +27 -0
- package/dist/feed.d.ts.map +1 -0
- package/dist/feed.js +39 -0
- package/dist/feed.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/outcomes.d.ts +117 -0
- package/dist/outcomes.d.ts.map +1 -0
- package/dist/outcomes.js +201 -0
- package/dist/outcomes.js.map +1 -0
- package/dist/panels.d.ts +31 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/panels.js +12 -0
- package/dist/panels.js.map +1 -0
- package/dist/quota.d.ts +32 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +46 -0
- package/dist/quota.js.map +1 -0
- package/dist/reasons.d.ts +25 -0
- package/dist/reasons.d.ts.map +1 -0
- package/dist/reasons.js +46 -0
- package/dist/reasons.js.map +1 -0
- package/dist/run-state.d.ts +62 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +109 -0
- package/dist/run-state.js.map +1 -0
- package/dist/schema.d.ts +3718 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +3 -0
- package/dist/schema.js.map +1 -0
- package/dist/spend.d.ts +46 -0
- package/dist/spend.d.ts.map +1 -0
- package/dist/spend.js +68 -0
- package/dist/spend.js.map +1 -0
- package/dist/store.d.ts +121 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +286 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -4
- package/src/client.ts +119 -0
- package/src/derive.ts +155 -0
- package/src/export.ts +158 -0
- package/src/feed.ts +81 -0
- package/src/index.ts +12 -0
- package/src/outcomes.ts +267 -0
- package/src/panels.ts +51 -0
- package/src/quota.ts +78 -0
- package/src/reasons.ts +57 -0
- package/src/run-state.ts +166 -0
- package/src/schema.ts +3719 -0
- package/src/spend.ts +108 -0
- package/src/store.ts +338 -0
- package/src/types.ts +165 -0
package/src/spend.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What the coding agents spent: tokens and cost, summed per attempt, task, kind
|
|
2
|
+
// and run from the journal. A row without usage or cost (a Python task, a task
|
|
3
|
+
// that never started, an agent that reports none) adds nothing and is not
|
|
4
|
+
// counted as zero. Pure.
|
|
5
|
+
import type { JournalRow, ModelUsage } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface Spend {
|
|
8
|
+
input: number;
|
|
9
|
+
output: number;
|
|
10
|
+
/** Input served from the prompt cache. */
|
|
11
|
+
cache_read: number;
|
|
12
|
+
/** Input written to the prompt cache. */
|
|
13
|
+
cache_creation: number;
|
|
14
|
+
/** API calls. */
|
|
15
|
+
calls: number;
|
|
16
|
+
/** USD as the coding agents report it; null when no row reported a cost. */
|
|
17
|
+
cost: number | null;
|
|
18
|
+
/** Rows that reported tokens or a cost. */
|
|
19
|
+
reported: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const emptySpend = (): Spend => ({ input: 0, output: 0, cache_read: 0, cache_creation: 0, calls: 0, cost: null, reported: 0 });
|
|
23
|
+
|
|
24
|
+
/** Every token of a spend: input, output and both cache kinds. */
|
|
25
|
+
export const totalTokens = (s: Spend): number => s.input + s.output + s.cache_read + s.cache_creation;
|
|
26
|
+
|
|
27
|
+
/** Add one row's usage and cost to `s` (in place); returns `s`. */
|
|
28
|
+
export function addRow(s: Spend, r: JournalRow): Spend {
|
|
29
|
+
const u = r.usage;
|
|
30
|
+
if (!u && (r.cost_usd === null || r.cost_usd === undefined)) return s;
|
|
31
|
+
if (u) {
|
|
32
|
+
s.input += u.input ?? 0;
|
|
33
|
+
s.output += u.output ?? 0;
|
|
34
|
+
s.cache_read += u.cache_read ?? 0;
|
|
35
|
+
s.cache_creation += u.cache_creation ?? 0;
|
|
36
|
+
s.calls += u.calls ?? 0;
|
|
37
|
+
}
|
|
38
|
+
if (r.cost_usd !== null && r.cost_usd !== undefined) s.cost = (s.cost ?? 0) + r.cost_usd;
|
|
39
|
+
s.reported++;
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The spend of some rows; null when none of them reported any. */
|
|
44
|
+
export function spendOf(rows: readonly JournalRow[]): Spend | null {
|
|
45
|
+
const s = emptySpend();
|
|
46
|
+
for (const r of rows) addRow(s, r);
|
|
47
|
+
return s.reported ? s : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One row's spend; null when it reported none. */
|
|
51
|
+
export const rowSpend = (r: JournalRow): Spend | null => spendOf([r]);
|
|
52
|
+
|
|
53
|
+
export interface SpendGroup {
|
|
54
|
+
/** A task key, or a kind. */
|
|
55
|
+
id: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
unit: string;
|
|
58
|
+
spend: Spend;
|
|
59
|
+
/** Fraction (0..1) of the run's reported cost; null when the run reported none. */
|
|
60
|
+
share: number | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function group(journal: readonly JournalRow[], idOf: (r: JournalRow) => string): SpendGroup[] {
|
|
64
|
+
const by = new Map<string, SpendGroup>();
|
|
65
|
+
const total = emptySpend();
|
|
66
|
+
for (const r of journal) {
|
|
67
|
+
addRow(total, r);
|
|
68
|
+
const id = idOf(r);
|
|
69
|
+
let g = by.get(id);
|
|
70
|
+
if (!g) by.set(id, (g = { id, kind: r.kind, unit: r.unit ?? '', spend: emptySpend(), share: null }));
|
|
71
|
+
addRow(g.spend, r);
|
|
72
|
+
}
|
|
73
|
+
const out = [...by.values()].filter((g) => g.spend.reported);
|
|
74
|
+
for (const g of out) g.share = total.cost ? (g.spend.cost ?? 0) / total.cost : null;
|
|
75
|
+
// Most expensive first; by tokens when no cost was reported.
|
|
76
|
+
return out.sort((a, b) => (b.spend.cost ?? 0) - (a.spend.cost ?? 0) || totalTokens(b.spend) - totalTokens(a.spend));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Spend per task (key), most expensive first. */
|
|
80
|
+
export const spendByTask = (journal: readonly JournalRow[]): SpendGroup[] => group(journal, (r) => r.key);
|
|
81
|
+
|
|
82
|
+
/** Spend per kind, most expensive first. */
|
|
83
|
+
export const spendByKind = (journal: readonly JournalRow[]): SpendGroup[] =>
|
|
84
|
+
group(journal, (r) => r.kind).map((g) => ({ ...g, unit: '' }));
|
|
85
|
+
|
|
86
|
+
/** What a model's use is measured by: its cost, its tokens, its time, its agent sessions. */
|
|
87
|
+
export type ModelMeasure = 'cost' | 'tokens' | 'time' | 'sessions';
|
|
88
|
+
|
|
89
|
+
/** Every token a model's sessions used: input, output and both cache kinds. */
|
|
90
|
+
export const modelTokens = (m: ModelUsage): number => (m.input ?? 0) + (m.output ?? 0) + (m.cache_read ?? 0) + (m.cache_creation ?? 0);
|
|
91
|
+
|
|
92
|
+
/** A model's use by `measure`; null for a cost no session on it reported. */
|
|
93
|
+
export function modelValue(m: ModelUsage, measure: ModelMeasure): number | null {
|
|
94
|
+
switch (measure) {
|
|
95
|
+
case 'cost':
|
|
96
|
+
return m.cost_usd ?? null;
|
|
97
|
+
case 'tokens':
|
|
98
|
+
return modelTokens(m);
|
|
99
|
+
case 'time':
|
|
100
|
+
return m.wall_s ?? 0;
|
|
101
|
+
case 'sessions':
|
|
102
|
+
return m.sessions;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The measures some of `models` has a value for, in the order a reader picks them. */
|
|
107
|
+
export const modelMeasures = (models: readonly ModelUsage[]): ModelMeasure[] =>
|
|
108
|
+
(['cost', 'tokens', 'time', 'sessions'] as const).filter((x) => models.some((m) => (modelValue(m, x) ?? 0) > 0));
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// The monitor's data, live: the run list, the selected run's feed folded into a
|
|
2
|
+
// `RunState`, and a change notification. No UI framework: a Lit controller, a
|
|
3
|
+
// React hook or plain code can subscribe alike.
|
|
4
|
+
import { ApiError, createMonitorClient, type MonitorClient } from './client.js';
|
|
5
|
+
import type { RunFeedOpener } from './export.js';
|
|
6
|
+
import { openRunFeed, type FeedOptions, type Unsubscribe } from './feed.js';
|
|
7
|
+
import { emptyRunState, reduceRun, rowId, type RunAction, type RunState } from './run-state.js';
|
|
8
|
+
import { isRunPanel } from './panels.js';
|
|
9
|
+
import type { Hosts, Info, JournalRow, JsonObject, RunFeedMessage, RunSummary, Trends, TrendsQuery } from './types.js';
|
|
10
|
+
|
|
11
|
+
export interface MonitorSnapshot {
|
|
12
|
+
info: Info;
|
|
13
|
+
runs: RunSummary[];
|
|
14
|
+
/** The hosts behind the runs, on a hub (`Info.hosts`); null elsewhere and until they arrive. */
|
|
15
|
+
hosts: Hosts | null;
|
|
16
|
+
/** The run whose feed is open; '' for none. */
|
|
17
|
+
run: string;
|
|
18
|
+
state: RunState;
|
|
19
|
+
/** The feed is connected. */
|
|
20
|
+
connected: boolean;
|
|
21
|
+
/** Journal rows (by `rowId`) that arrived in the last few seconds, after the replay. */
|
|
22
|
+
fresh: ReadonlySet<string>;
|
|
23
|
+
/** The run list has been answered: `runs` is the server's, not the empty start. */
|
|
24
|
+
runsLoaded: boolean;
|
|
25
|
+
/** Why the last request for `info`, `runs` or (on a hub) `hosts` got no answer; null while all are answered. */
|
|
26
|
+
error: ApiError | null;
|
|
27
|
+
/** The trends of the period last asked for (`loadTrends`); null until one is asked for. */
|
|
28
|
+
trends: TrendsView | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The trends of one period, and the trend panels' bodies over it. */
|
|
32
|
+
export interface TrendsView {
|
|
33
|
+
query: TrendsQuery;
|
|
34
|
+
/** null until the first answer for this query. */
|
|
35
|
+
data: Trends | null;
|
|
36
|
+
/** Each trend panel's body (`Panel(scope="trends")`), by name, as it arrives. */
|
|
37
|
+
panels: Readonly<Record<string, JsonObject>>;
|
|
38
|
+
loading: boolean;
|
|
39
|
+
/** Why the last request for the trends got no answer. */
|
|
40
|
+
error: ApiError | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StoreOptions {
|
|
44
|
+
baseUrl?: string;
|
|
45
|
+
client?: MonitorClient;
|
|
46
|
+
feed?: FeedOptions;
|
|
47
|
+
/** Opens a run's feed instead of the server's `/events` (an export's: `openExportFeed`). */
|
|
48
|
+
openRunFeed?: RunFeedOpener;
|
|
49
|
+
/** How often the run list (and on a hub, the host list) is refreshed; 0: never (an export). */
|
|
50
|
+
runsEveryMs?: number;
|
|
51
|
+
/** Feed messages are folded in batches this far apart. */
|
|
52
|
+
batchMs?: number;
|
|
53
|
+
/** How long a new journal row counts as fresh. */
|
|
54
|
+
freshMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_INFO: Info = { title: 'contraagent', panels: [], api_version: '' };
|
|
58
|
+
|
|
59
|
+
/** A client given in the options may throw something else: it counts as no response from `url`. */
|
|
60
|
+
const asApiError = (e: unknown, url: string): ApiError => (e instanceof ApiError ? e : new ApiError(url, 'network'));
|
|
61
|
+
|
|
62
|
+
export class MonitorStore {
|
|
63
|
+
readonly client: MonitorClient;
|
|
64
|
+
private snap: MonitorSnapshot = {
|
|
65
|
+
info: DEFAULT_INFO,
|
|
66
|
+
runs: [],
|
|
67
|
+
hosts: null,
|
|
68
|
+
run: '',
|
|
69
|
+
state: emptyRunState(),
|
|
70
|
+
connected: false,
|
|
71
|
+
fresh: new Set(),
|
|
72
|
+
runsLoaded: false,
|
|
73
|
+
error: null,
|
|
74
|
+
trends: null,
|
|
75
|
+
};
|
|
76
|
+
/** The trends asked for last (`loadTrends`), kept fresh with the run list; null: none. */
|
|
77
|
+
private trendsQuery: TrendsQuery | null = null;
|
|
78
|
+
/** Counts the trend requests: an answer to an older one is dropped. */
|
|
79
|
+
private trendsAsked = 0;
|
|
80
|
+
/** The run list's records (`runsKey`) the trends shown were asked over: a refresh asks again only when they changed. */
|
|
81
|
+
private trendsOver: string | null = null;
|
|
82
|
+
/** A refresh is under way: a call meanwhile does nothing, and the next one is scheduled after it ends. */
|
|
83
|
+
private refreshing = false;
|
|
84
|
+
/** A trends request a refresh started is under way: no second one until it ends. */
|
|
85
|
+
private trendsBusy = false;
|
|
86
|
+
/** Why the last request for `info`, for `runs`, and for `hosts` got no answer. */
|
|
87
|
+
private infoError: ApiError | null = null;
|
|
88
|
+
private runsError: ApiError | null = null;
|
|
89
|
+
private hostsError: ApiError | null = null;
|
|
90
|
+
private infoLoaded = false;
|
|
91
|
+
private stopped = false;
|
|
92
|
+
private readonly listeners = new Set<(s: MonitorSnapshot) => void>();
|
|
93
|
+
private readonly rowListeners = new Set<(row: JournalRow) => void>();
|
|
94
|
+
private closeFeed: Unsubscribe | null = null;
|
|
95
|
+
private queue: RunAction[] = [];
|
|
96
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
97
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
98
|
+
private readonly opts: Required<Pick<StoreOptions, 'runsEveryMs' | 'batchMs' | 'freshMs'>> & StoreOptions;
|
|
99
|
+
|
|
100
|
+
constructor(opts: StoreOptions = {}) {
|
|
101
|
+
this.opts = { runsEveryMs: 5000, batchMs: 50, freshMs: 4000, ...opts };
|
|
102
|
+
this.client = opts.client ?? createMonitorClient(opts.baseUrl === undefined ? {} : { baseUrl: opts.baseUrl });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get snapshot(): MonitorSnapshot {
|
|
106
|
+
return this.snap;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Called on every change, with the new snapshot. */
|
|
110
|
+
subscribe(fn: (s: MonitorSnapshot) => void): Unsubscribe {
|
|
111
|
+
this.listeners.add(fn);
|
|
112
|
+
return () => this.listeners.delete(fn);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Called for each journal row that arrives after the replay: a task that just finished. */
|
|
116
|
+
onNewRow(fn: (row: JournalRow) => void): Unsubscribe {
|
|
117
|
+
this.rowListeners.add(fn);
|
|
118
|
+
return () => this.rowListeners.delete(fn);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load the title, panels and run list, and keep the run list fresh. A request
|
|
123
|
+
* that gets no answer sets `error`; the title and panels are asked for again
|
|
124
|
+
* with each refresh until they arrive.
|
|
125
|
+
*/
|
|
126
|
+
async start(): Promise<void> {
|
|
127
|
+
this.stopped = false;
|
|
128
|
+
await this.refresh();
|
|
129
|
+
this.schedule();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The next refresh, `runsEveryMs` after this one ended: refreshes never overlap, however slow the answers. */
|
|
133
|
+
private schedule(): void {
|
|
134
|
+
if (this.stopped || this.opts.runsEveryMs <= 0 || this.pollTimer) return;
|
|
135
|
+
this.pollTimer = setTimeout(() => {
|
|
136
|
+
this.pollTimer = null;
|
|
137
|
+
void this.refresh().finally(() => this.schedule());
|
|
138
|
+
}, this.opts.runsEveryMs);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async refresh(): Promise<void> {
|
|
142
|
+
if (this.refreshing) return;
|
|
143
|
+
this.refreshing = true;
|
|
144
|
+
try {
|
|
145
|
+
if (!this.infoLoaded) await this.loadInfo();
|
|
146
|
+
await this.refreshRuns();
|
|
147
|
+
} finally {
|
|
148
|
+
this.refreshing = false;
|
|
149
|
+
}
|
|
150
|
+
// The trends are asked for again only when the runs' records or the trend panels (a
|
|
151
|
+
// panel list that arrived after them) changed, and not awaited here: a slow trend
|
|
152
|
+
// panel holds back no run list.
|
|
153
|
+
const query = this.trendsQuery;
|
|
154
|
+
if (query && !this.trendsBusy && !this.stopped && this.trendsKey() !== this.trendsOver) {
|
|
155
|
+
this.trendsBusy = true;
|
|
156
|
+
void this.fetchTrends(query).finally(() => (this.trendsBusy = false));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the trends are made of, as far as the run list says: each run and its last record; and the trend panels asked for over them. */
|
|
161
|
+
private trendsKey(): string {
|
|
162
|
+
const runs = this.snap.runs.map((r) => `${r.run}\t${r.last_ts}\t${r.attempts}`).join('\n');
|
|
163
|
+
return `${runs}\n\n${this.trendPanelNames().join('\n')}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private trendPanelNames(): string[] {
|
|
167
|
+
return this.snap.info.panels.filter((p) => p.scope === 'trends').map((p) => p.name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Load the trends of `query` (`/api/trends`) and the trend panels over it, and keep them
|
|
172
|
+
* fresh with the run list until `closeTrends`. The trends of another query stay shown
|
|
173
|
+
* until the new ones arrive (`loading`).
|
|
174
|
+
*/
|
|
175
|
+
async loadTrends(query: TrendsQuery): Promise<void> {
|
|
176
|
+
const same = this.snap.trends && JSON.stringify(this.snap.trends.query) === JSON.stringify(query);
|
|
177
|
+
this.trendsQuery = query;
|
|
178
|
+
this.set({ trends: { query, data: same ? this.snap.trends!.data : (this.snap.trends?.data ?? null), panels: same ? this.snap.trends!.panels : {}, loading: true, error: null } });
|
|
179
|
+
await this.fetchTrends(query);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Stop keeping the trends fresh (the page left them). */
|
|
183
|
+
closeTrends(): void {
|
|
184
|
+
this.trendsQuery = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Ask for the trends of `query` and each trend panel over it. Each answer is shown as it
|
|
189
|
+
* arrives, so a slow panel holds back neither the trends nor another panel; an answer to
|
|
190
|
+
* an older request is dropped.
|
|
191
|
+
*/
|
|
192
|
+
private async fetchTrends(query: TrendsQuery): Promise<void> {
|
|
193
|
+
const asked = ++this.trendsAsked;
|
|
194
|
+
this.trendsOver = this.trendsKey();
|
|
195
|
+
const view = (): TrendsView | null => (asked === this.trendsAsked && !this.stopped ? this.snap.trends : null);
|
|
196
|
+
const names = this.trendPanelNames();
|
|
197
|
+
const trends = this.client.trends(query).then(
|
|
198
|
+
(data) => {
|
|
199
|
+
const v = view();
|
|
200
|
+
if (v) this.set({ trends: { ...v, query, data, loading: false, error: null } });
|
|
201
|
+
},
|
|
202
|
+
(e: unknown) => {
|
|
203
|
+
const v = view();
|
|
204
|
+
if (v) this.set({ trends: { ...v, query, loading: false, error: asApiError(e, `${this.client.baseUrl}/api/trends`) } });
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
const panel = (name: string, body: JsonObject) => {
|
|
208
|
+
const v = view();
|
|
209
|
+
if (v) this.set({ trends: { ...v, panels: { ...v.panels, [name]: body } } });
|
|
210
|
+
};
|
|
211
|
+
const bodies = names.map((n) =>
|
|
212
|
+
this.client.trendsPanel(n, query).then(
|
|
213
|
+
(body) => panel(n, body),
|
|
214
|
+
(e: unknown) => panel(n, { error: String(e) }),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
await Promise.allSettled([trends, ...bodies]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async loadInfo(): Promise<void> {
|
|
221
|
+
try {
|
|
222
|
+
const before = this.panelNames();
|
|
223
|
+
const info = await this.client.info();
|
|
224
|
+
if (this.stopped) return;
|
|
225
|
+
this.infoLoaded = true;
|
|
226
|
+
this.infoError = null;
|
|
227
|
+
this.set({ info });
|
|
228
|
+
// A run opened before the panel list arrived listens for no panel: open it again.
|
|
229
|
+
if (this.snap.run && this.panelNames() !== before) this.reopen();
|
|
230
|
+
} catch (e) {
|
|
231
|
+
this.infoError = asApiError(e, `${this.client.baseUrl}/api/info`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Fetch the run list, and on a hub the host list beside it: both refresh together, so a run's host reads the same in both. */
|
|
236
|
+
async refreshRuns(): Promise<void> {
|
|
237
|
+
const [runs, hosts] = await Promise.allSettled([this.client.runs(), this.snap.info.hosts ? this.client.hosts() : Promise.resolve(null)]);
|
|
238
|
+
if (this.stopped) return;
|
|
239
|
+
// A failed request keeps the last list.
|
|
240
|
+
const patch: Partial<MonitorSnapshot> = {};
|
|
241
|
+
if (runs.status === 'fulfilled') {
|
|
242
|
+
patch.runs = runs.value;
|
|
243
|
+
patch.runsLoaded = true;
|
|
244
|
+
this.runsError = null;
|
|
245
|
+
} else this.runsError = asApiError(runs.reason, `${this.client.baseUrl}/api/runs`);
|
|
246
|
+
if (hosts.status === 'fulfilled') {
|
|
247
|
+
patch.hosts = hosts.value;
|
|
248
|
+
this.hostsError = null;
|
|
249
|
+
} else this.hostsError = asApiError(hosts.reason, `${this.client.baseUrl}/api/hosts`);
|
|
250
|
+
const error = this.infoError ?? this.runsError ?? this.hostsError;
|
|
251
|
+
// The same failure again keeps the same `error`.
|
|
252
|
+
if (error?.message !== this.snap.error?.message) patch.error = error;
|
|
253
|
+
if (Object.keys(patch).length) this.set(patch);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private panelNames(): string {
|
|
257
|
+
return this.snap.info.panels.filter(isRunPanel).map((p) => p.name).join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Open the current run's feed again (the server replays it from the start). */
|
|
261
|
+
private reopen(): void {
|
|
262
|
+
const run = this.snap.run;
|
|
263
|
+
this.closeFeed?.();
|
|
264
|
+
this.closeFeed = null;
|
|
265
|
+
this.selectRun(run);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Open `run`'s feed (closing the previous one); '' closes it. */
|
|
269
|
+
selectRun(run: string): void {
|
|
270
|
+
if (run === this.snap.run && (this.closeFeed || !run)) return;
|
|
271
|
+
this.closeFeed?.();
|
|
272
|
+
this.closeFeed = null;
|
|
273
|
+
this.queue = [];
|
|
274
|
+
this.set({ run, state: emptyRunState(), connected: false, fresh: new Set() });
|
|
275
|
+
if (!run) return;
|
|
276
|
+
const panels = this.snap.info.panels.filter(isRunPanel).map((p) => p.name);
|
|
277
|
+
const on = {
|
|
278
|
+
open: () => {
|
|
279
|
+
this.push({ event: 'connected' });
|
|
280
|
+
this.set({ connected: true });
|
|
281
|
+
},
|
|
282
|
+
error: () => this.set({ connected: false }),
|
|
283
|
+
message: (msg: RunFeedMessage) => this.push(msg),
|
|
284
|
+
};
|
|
285
|
+
this.closeFeed = this.opts.openRunFeed
|
|
286
|
+
? this.opts.openRunFeed(run, panels, on)
|
|
287
|
+
: openRunFeed(run, panels, on, { ...(this.opts.baseUrl === undefined ? {} : { baseUrl: this.opts.baseUrl }), ...this.opts.feed });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Close the feed and stop refreshing; an answer that arrives later is dropped. */
|
|
291
|
+
stop(): void {
|
|
292
|
+
this.stopped = true;
|
|
293
|
+
this.closeFeed?.();
|
|
294
|
+
this.closeFeed = null;
|
|
295
|
+
if (this.pollTimer) clearTimeout(this.pollTimer);
|
|
296
|
+
this.pollTimer = null;
|
|
297
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
298
|
+
this.flushTimer = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private push(a: RunAction): void {
|
|
302
|
+
this.queue.push(a);
|
|
303
|
+
this.flushTimer ??= setTimeout(() => this.flush(), this.opts.batchMs);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private flush(): void {
|
|
307
|
+
this.flushTimer = null;
|
|
308
|
+
const batch = this.queue;
|
|
309
|
+
this.queue = [];
|
|
310
|
+
// Rows that arrive once the replay is over are new: announce them and mark them fresh.
|
|
311
|
+
let ready = this.snap.state.ready;
|
|
312
|
+
const fresh: JournalRow[] = [];
|
|
313
|
+
for (const a of batch) {
|
|
314
|
+
if (a.event === 'connected') ready = false;
|
|
315
|
+
else if (a.event === 'ready') ready = true;
|
|
316
|
+
else if (a.event === 'journal' && ready) fresh.push(a.data);
|
|
317
|
+
}
|
|
318
|
+
const state = reduceRun(this.snap.state, batch);
|
|
319
|
+
if (!fresh.length) {
|
|
320
|
+
this.set({ state });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const ids = new Set(this.snap.fresh);
|
|
324
|
+
for (const row of fresh) ids.add(rowId(row));
|
|
325
|
+
this.set({ state, fresh: ids });
|
|
326
|
+
for (const row of fresh) for (const fn of this.rowListeners) fn(row);
|
|
327
|
+
setTimeout(() => {
|
|
328
|
+
const left = new Set(this.snap.fresh);
|
|
329
|
+
for (const row of fresh) left.delete(rowId(row));
|
|
330
|
+
this.set({ fresh: left });
|
|
331
|
+
}, this.opts.freshMs);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private set(patch: Partial<MonitorSnapshot>): void {
|
|
335
|
+
this.snap = { ...this.snap, ...patch };
|
|
336
|
+
for (const fn of this.listeners) fn(this.snap);
|
|
337
|
+
}
|
|
338
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Names for the generated schema types. `schema.ts` is generated from the
|
|
2
|
+
// monitor's OpenAPI document (`pnpm gen`); everything here is an alias of it.
|
|
3
|
+
import type { components } from './schema.js';
|
|
4
|
+
import type { PanelBody } from './panels.js';
|
|
5
|
+
|
|
6
|
+
type S = components['schemas'];
|
|
7
|
+
|
|
8
|
+
export type { components, paths } from './schema.js';
|
|
9
|
+
|
|
10
|
+
/** A JSON object whose keys a domain chooses (a panel's body, a contract file's content). */
|
|
11
|
+
export type JsonObject = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export type Info = S['Info'];
|
|
14
|
+
/** What an export holds (`Info.export`): set only when the monitor reads one. */
|
|
15
|
+
export type ExportInfo = S['ExportInfo'];
|
|
16
|
+
export type PanelInfo = S['PanelInfo'];
|
|
17
|
+
export type RunSummary = S['RunSummary'];
|
|
18
|
+
export type Hosts = S['Hosts'];
|
|
19
|
+
export type HostSummary = S['HostSummary'];
|
|
20
|
+
export type AccountQuota = S['AccountQuota'];
|
|
21
|
+
/** What the runs of a period add up to (`/api/trends`). */
|
|
22
|
+
export type Trends = S['Trends'];
|
|
23
|
+
export type TrendKind = S['TrendKind'];
|
|
24
|
+
export type TrendQuota = S['TrendQuota'];
|
|
25
|
+
export type TrendRun = S['TrendRun'];
|
|
26
|
+
/** Which runs `/api/trends` sums: days `from` to `to` (YYYY-MM-DD) in the time zone `tz`, of one `run_group`. */
|
|
27
|
+
export interface TrendsQuery {
|
|
28
|
+
from?: string;
|
|
29
|
+
to?: string;
|
|
30
|
+
tz?: string;
|
|
31
|
+
run_group?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type JournalRow = S['JournalRow'];
|
|
35
|
+
/** How one attempt ended (`JournalRow.stop`): a `Stop` of the Python package, or `skipped`. */
|
|
36
|
+
export type Stop = JournalRow['stop'];
|
|
37
|
+
export type Hop = S['Hop'];
|
|
38
|
+
export type SkippedHop = S['SkippedHop'];
|
|
39
|
+
/** Why a credential rests (a cap or the quota rule): what a `SkippedHop` and a `CredentialRest` share. */
|
|
40
|
+
export type Rest = Pick<S['SkippedHop'], 'credential' | 'window' | 'pct' | 'cap' | 'rule' | 'until'>;
|
|
41
|
+
export type TokenUsage = S['TokenUsage'];
|
|
42
|
+
export type QuotaReading = S['QuotaReading'];
|
|
43
|
+
export type JournalPage = S['JournalPage'];
|
|
44
|
+
|
|
45
|
+
export type Executor = NonNullable<S['PlanNode']['executor']>;
|
|
46
|
+
|
|
47
|
+
export type Plan = S['Plan'];
|
|
48
|
+
export type PlanNode = S['PlanNode'];
|
|
49
|
+
export type PlanGroup = S['PlanGroup'];
|
|
50
|
+
export type DeferralCause = S['DeferralCause'];
|
|
51
|
+
export type QuotaCap = S['QuotaCap'];
|
|
52
|
+
export type CredentialRest = S['CredentialRest'];
|
|
53
|
+
export type ModelUsage = S['ModelUsage'];
|
|
54
|
+
export type PreflightRecord = S['PreflightRecord'];
|
|
55
|
+
export type DroppedRoute = S['DroppedRoute'];
|
|
56
|
+
export type Substitution = S['Substitution'];
|
|
57
|
+
export type KeptKind = S['KeptKind'];
|
|
58
|
+
export type TaskState = PlanNode['state'];
|
|
59
|
+
export type Frame = S['Frame'];
|
|
60
|
+
export type Loop = S['Loop'];
|
|
61
|
+
export type FlowCallNode = S['FlowCallNode'];
|
|
62
|
+
export type FlowState = FlowCallNode['state'];
|
|
63
|
+
export type TimeBoxRecord = S['TimeBoxRecord'];
|
|
64
|
+
|
|
65
|
+
export type Outputs = S['Outputs'];
|
|
66
|
+
export type OutputTally = S['OutputTally'];
|
|
67
|
+
export type ContractRecord = S['ContractRecord'];
|
|
68
|
+
export type ContractSpec = S['ContractSpec'];
|
|
69
|
+
export type ContractField = S['ContractField'];
|
|
70
|
+
export type ContractStatus = NonNullable<ContractSpec['status']>;
|
|
71
|
+
export type FileView = S['FileView'];
|
|
72
|
+
|
|
73
|
+
export type TaskDefinition = S['TaskDefinition'];
|
|
74
|
+
export type DefinitionFile = S['DefinitionFile'];
|
|
75
|
+
|
|
76
|
+
export type CursorView = S['CursorView'];
|
|
77
|
+
export type UnitPosition = S['UnitPosition'];
|
|
78
|
+
|
|
79
|
+
export type Procs = S['Procs'];
|
|
80
|
+
export type ProcNode = S['ProcNode'];
|
|
81
|
+
export type AgentProcess = S['AgentProcess'];
|
|
82
|
+
export type InflightPythonTask = S['InflightPythonTask'];
|
|
83
|
+
|
|
84
|
+
export type TaskDetail = S['TaskDetail'];
|
|
85
|
+
export type TaskLaunch = S['TaskLaunch'];
|
|
86
|
+
export type TaskIO = S['TaskIO'];
|
|
87
|
+
export type UpstreamTask = S['UpstreamTask'];
|
|
88
|
+
export type DownstreamTask = S['DownstreamTask'];
|
|
89
|
+
|
|
90
|
+
// --- transcripts -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export type StreamPage = S['StreamPage'];
|
|
93
|
+
export type LaunchEvent = S['LaunchEvent'];
|
|
94
|
+
export type MessageUpdateEvent = S['MessageUpdateEvent'];
|
|
95
|
+
export type ToolStartEvent = S['ToolStartEvent'];
|
|
96
|
+
export type ToolEndEvent = S['ToolEndEvent'];
|
|
97
|
+
export type ToolCallEchoEvent = S['ToolCallEchoEvent'];
|
|
98
|
+
export type ToolResultEchoEvent = S['ToolResultEchoEvent'];
|
|
99
|
+
/** Named apart from the DOM's `MessageEvent`. */
|
|
100
|
+
export type TranscriptMessageEvent = S['MessageEvent'];
|
|
101
|
+
export type NoticeEvent = S['NoticeEvent'];
|
|
102
|
+
export type RetryEvent = S['RetryEvent'];
|
|
103
|
+
export type OtherEvent = S['OtherEvent'];
|
|
104
|
+
|
|
105
|
+
/** One transcript event as the wire carries it: a known model, or an `OtherEvent`. */
|
|
106
|
+
export type TranscriptEvent = StreamPage['events'][number];
|
|
107
|
+
|
|
108
|
+
/** The transcript events the monitor renders, discriminated by `type`. */
|
|
109
|
+
export type KnownTranscriptEvent =
|
|
110
|
+
| LaunchEvent
|
|
111
|
+
| MessageUpdateEvent
|
|
112
|
+
| ToolStartEvent
|
|
113
|
+
| ToolEndEvent
|
|
114
|
+
| ToolCallEchoEvent
|
|
115
|
+
| ToolResultEchoEvent
|
|
116
|
+
| TranscriptMessageEvent
|
|
117
|
+
| NoticeEvent
|
|
118
|
+
| RetryEvent;
|
|
119
|
+
|
|
120
|
+
const KNOWN_TYPES: ReadonlySet<string> = new Set<KnownTranscriptEvent['type']>([
|
|
121
|
+
'launch',
|
|
122
|
+
'message_update',
|
|
123
|
+
'tool_execution_start',
|
|
124
|
+
'tool_execution_update',
|
|
125
|
+
'tool_execution_end',
|
|
126
|
+
'acall',
|
|
127
|
+
'aresult',
|
|
128
|
+
'message_start',
|
|
129
|
+
'message_end',
|
|
130
|
+
'turn_end',
|
|
131
|
+
'notice',
|
|
132
|
+
'auto_retry_start',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
/** The event as one of the known models, or null for any other `type`. */
|
|
136
|
+
export function knownEvent(e: TranscriptEvent): KnownTranscriptEvent | null {
|
|
137
|
+
return KNOWN_TYPES.has(e.type) ? (e as KnownTranscriptEvent) : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- live feeds --------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** One message of `/events?run=`: `event` is the SSE event name, `data` its parsed JSON. */
|
|
143
|
+
export type RunMessage = S['RunEvent'];
|
|
144
|
+
// `PanelMessage` is the one whose `event` is a plain string; every other row
|
|
145
|
+
// would also fit its shape, so it is told apart by that, not with `Exclude`.
|
|
146
|
+
type FixedRunMessage = RunMessage extends infer M
|
|
147
|
+
? M extends { event: infer E }
|
|
148
|
+
? string extends E
|
|
149
|
+
? never
|
|
150
|
+
: M
|
|
151
|
+
: never
|
|
152
|
+
: never;
|
|
153
|
+
export type PanelEventName = `panel:${string}`;
|
|
154
|
+
/** A panel's message, with its event name typed. */
|
|
155
|
+
export interface PanelMessage {
|
|
156
|
+
event: PanelEventName;
|
|
157
|
+
data: PanelBody;
|
|
158
|
+
}
|
|
159
|
+
/** A run feed message with every `event` name a literal (or `panel:<name>`). */
|
|
160
|
+
export type RunFeedMessage = FixedRunMessage | PanelMessage;
|
|
161
|
+
/** The SSE event names with a fixed meaning, and what each one carries. */
|
|
162
|
+
export type RunEventData = { [M in FixedRunMessage as M['event']]: M['data'] };
|
|
163
|
+
export type RunEventName = keyof RunEventData;
|
|
164
|
+
|
|
165
|
+
export type StreamMessage = S['StreamMessage'];
|