@saadnvd1/agent-os 0.3.499 → 0.3.501

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 (47) hide show
  1. package/README.md +70 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Devices/ConnectCard.tsx +10 -12
  4. package/components/Tasks/TaskRow.tsx +2 -0
  5. package/components/Usage/RatchetView.tsx +249 -0
  6. package/components/Usage/UsageDialog.tsx +7 -1
  7. package/components/Usage/WindowCard.tsx +2 -1
  8. package/data/usage/index.ts +28 -0
  9. package/lib/chat/drivers/claude-mapper.ts +1 -0
  10. package/lib/chat/drivers/claude.ts +46 -0
  11. package/lib/chat/events.ts +3 -0
  12. package/lib/chat/worker/host.ts +21 -0
  13. package/lib/chat/worker/protocol.ts +8 -1
  14. package/lib/connect/cli.ts +51 -72
  15. package/lib/connect/cloudflared.ts +172 -0
  16. package/lib/connect/config.ts +75 -26
  17. package/lib/connect/control-api.ts +107 -48
  18. package/lib/connect/enrol.ts +33 -107
  19. package/lib/connect/serve.ts +177 -94
  20. package/lib/connect/supervise.ts +281 -0
  21. package/lib/db/migrations.ts +72 -0
  22. package/lib/security/auth.ts +20 -4
  23. package/lib/security/gate.ts +8 -5
  24. package/lib/security/reach.ts +1 -1
  25. package/lib/tasks/index.ts +8 -0
  26. package/lib/usage/calls.ts +284 -0
  27. package/lib/usage/check-runs.ts +40 -19
  28. package/lib/usage/config.ts +56 -0
  29. package/lib/usage/nightly.ts +157 -0
  30. package/lib/usage/quality.ts +239 -0
  31. package/lib/usage/ratchet.ts +275 -0
  32. package/lib/usage/rate-limits.ts +192 -0
  33. package/lib/usage/rollup.ts +90 -0
  34. package/lib/usage/span.ts +152 -0
  35. package/lib/usage/transcript-import.ts +226 -0
  36. package/lib/usage/weights.ts +64 -0
  37. package/lib/usage/windows.ts +27 -2
  38. package/package.json +2 -4
  39. package/server.ts +7 -4
  40. package/stores/usageUi.ts +3 -3
  41. package/lib/connect/cert-check.ts +0 -32
  42. package/lib/connect/cloudflare-dns.ts +0 -56
  43. package/lib/connect/frames.ts +0 -72
  44. package/lib/connect/identity.ts +0 -98
  45. package/lib/connect/mux.ts +0 -110
  46. package/lib/connect/stream.ts +0 -106
  47. package/lib/connect/tunnel.ts +0 -98
@@ -1521,6 +1521,78 @@ const migrations: Migration[] = [
1521
1521
  );
1522
1522
  },
1523
1523
  },
1524
+ {
1525
+ id: 68,
1526
+ name: "usage_calls_and_rollups",
1527
+ up: (db) => {
1528
+ // One row per Claude response (or per one-shot run), the daily rollups
1529
+ // computed from them, the ratchet's baseline and the limit readings.
1530
+ db.transaction(() => {
1531
+ db.exec(`
1532
+ CREATE TABLE IF NOT EXISTS usage_calls (
1533
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1534
+ call_key TEXT NOT NULL UNIQUE,
1535
+ at INTEGER NOT NULL,
1536
+ session_id TEXT,
1537
+ task_id TEXT,
1538
+ workspace_id TEXT,
1539
+ project_id TEXT,
1540
+ session_name TEXT,
1541
+ role TEXT,
1542
+ kind TEXT NOT NULL,
1543
+ agent_type TEXT,
1544
+ model TEXT,
1545
+ provider TEXT,
1546
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1547
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1548
+ cache_write_5m_tokens INTEGER NOT NULL DEFAULT 0,
1549
+ cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
1550
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1551
+ ctx_tokens INTEGER,
1552
+ source TEXT NOT NULL
1553
+ );
1554
+ CREATE INDEX IF NOT EXISTS idx_usage_calls_at ON usage_calls(at);
1555
+ CREATE INDEX IF NOT EXISTS idx_usage_calls_task ON usage_calls(task_id);
1556
+ CREATE INDEX IF NOT EXISTS idx_usage_calls_session ON usage_calls(session_id);
1557
+ CREATE TABLE IF NOT EXISTS rate_limit_readings (
1558
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1559
+ at INTEGER NOT NULL,
1560
+ window TEXT NOT NULL,
1561
+ utilization REAL NOT NULL,
1562
+ raw REAL,
1563
+ resets_at INTEGER,
1564
+ status TEXT,
1565
+ session_id TEXT,
1566
+ source TEXT NOT NULL
1567
+ );
1568
+ CREATE INDEX IF NOT EXISTS idx_rate_limit_readings ON rate_limit_readings(window, at);
1569
+ CREATE TABLE IF NOT EXISTS usage_rollups (
1570
+ day TEXT PRIMARY KEY,
1571
+ data TEXT NOT NULL,
1572
+ final INTEGER NOT NULL DEFAULT 0,
1573
+ computed_at INTEGER NOT NULL
1574
+ );
1575
+ CREATE TABLE IF NOT EXISTS usage_baseline (
1576
+ metric TEXT PRIMARY KEY,
1577
+ value REAL NOT NULL,
1578
+ set_at INTEGER NOT NULL,
1579
+ supporting TEXT
1580
+ );
1581
+ CREATE TABLE IF NOT EXISTS usage_alerts (
1582
+ kind TEXT PRIMARY KEY,
1583
+ opened_at INTEGER NOT NULL,
1584
+ cleared_at INTEGER,
1585
+ message TEXT NOT NULL
1586
+ );
1587
+ CREATE TABLE IF NOT EXISTS usage_import_offsets (
1588
+ path TEXT PRIMARY KEY,
1589
+ offset INTEGER NOT NULL,
1590
+ mtime INTEGER NOT NULL
1591
+ );
1592
+ `);
1593
+ })();
1594
+ },
1595
+ },
1524
1596
  ];
1525
1597
 
1526
1598
  // `upTo`: stop after this id (tests that start from an older database).
@@ -2,6 +2,11 @@
2
2
  * Who may use AgentOS once a request has passed the host/origin checks in
3
3
  * net.ts. Loopback and the tailnet are trusted as before; anything else
4
4
  * (Wi-Fi, a reverse proxy, Connect) needs a paired device's token.
5
+ *
6
+ * Connect's tunnel arrives from cloudflared on loopback, so its listener
7
+ * marks every socket it accepts: a marked socket is never loopback, never
8
+ * the tailnet and never let in by AGENTOS_AUTH=off, whatever its address,
9
+ * Host or headers say.
5
10
  */
6
11
 
7
12
  import type { IncomingHttpHeaders } from "http";
@@ -23,8 +28,18 @@ export interface AuthRequest {
23
28
  headers: IncomingHttpHeaders;
24
29
  remoteAddress?: string;
25
30
  localAddress?: string;
31
+ /** Came through Connect's tunnel listener: a paired device or nothing. */
32
+ tunnel?: boolean;
26
33
  }
27
34
 
35
+ const TUNNEL_SOCKETS = new WeakSet<object>();
36
+ /** Called by the tunnel listener for every socket, before any request. */
37
+ export const markTunnelSocket = (socket: object) => {
38
+ TUNNEL_SOCKETS.add(socket);
39
+ };
40
+ export const isTunnelSocket = (socket: object | null | undefined) =>
41
+ !!socket && TUNNEL_SOCKETS.has(socket);
42
+
28
43
  export interface AuthPolicy {
29
44
  /** This machine's own Tailscale addresses. */
30
45
  tailnet: string[];
@@ -146,13 +161,14 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
146
161
  }
147
162
 
148
163
  function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
149
- const trusted = networkTrust(req, policy);
164
+ const trusted = req.tunnel ? null : networkTrust(req, policy);
150
165
  if (trusted) return { ok: true, via: trusted };
151
166
  const token = readToken(req.headers);
152
167
  const device = token ? policy.lookup(token) : null;
153
168
  if (device) return { ok: true, via: "device", deviceId: device.id };
154
- // AGENTOS_AUTH=off is for a login proxy on this side of the network. A
155
- // Connect stream has no socket address and must never be let in by it.
156
- if (policy.off && req.remoteAddress) return { ok: true, via: "open" };
169
+ // AGENTOS_AUTH=off is for a login proxy on this side of the network.
170
+ // Connect's tunnel must never be let in by it.
171
+ if (policy.off && !req.tunnel && req.remoteAddress)
172
+ return { ok: true, via: "open" };
157
173
  return { ok: false };
158
174
  }
@@ -9,6 +9,7 @@ import {
9
9
  authorize,
10
10
  cookieToken,
11
11
  isPublicPath,
12
+ isTunnelSocket,
12
13
  plainAddress,
13
14
  DEVICE_HEADER,
14
15
  TRUST_HEADER,
@@ -44,22 +45,24 @@ export function authPolicy(env = process.env): AuthPolicy {
44
45
  };
45
46
  }
46
47
 
47
- // Connect streams have no address. Give them a fixed label so rate limits
48
- // can't be steered by headers the caller chose.
49
- export const sourceLabel = (remote?: string) =>
50
- plainAddress(remote) || "connect";
48
+ // Everything through Connect arrives from cloudflared on loopback. Give it
49
+ // a fixed label so rate limits can't be steered by headers the caller chose.
50
+ export const sourceLabel = (remote?: string, tunnel = false) =>
51
+ tunnel ? "connect" : plainAddress(remote) || "unknown";
51
52
 
52
53
  function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
53
54
  // Never trust a client-supplied verdict.
54
55
  delete req.headers[DEVICE_HEADER];
55
56
  delete req.headers[TRUST_HEADER];
56
- req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress);
57
+ const tunnel = isTunnelSocket(req.socket);
58
+ req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
57
59
  const result = authorize(
58
60
  {
59
61
  url: req.url,
60
62
  headers: req.headers,
61
63
  remoteAddress: req.socket.remoteAddress,
62
64
  localAddress: req.socket.localAddress,
65
+ tunnel,
63
66
  },
64
67
  policy
65
68
  );
@@ -44,7 +44,7 @@ export async function reachableAt(): Promise<Reach[]> {
44
44
  await tailscaleStatus(),
45
45
  lanEnabled() ? lanAddresses() : [],
46
46
  port,
47
- loadConnect()?.config.hostname ?? null,
47
+ loadConnect()?.config.hostname || null,
48
48
  tailnetHttpsUrl()
49
49
  );
50
50
  }
@@ -4,6 +4,7 @@
4
4
  * drops it.
5
5
  */
6
6
 
7
+ import { taskCosts } from "../usage/calls";
7
8
  import {
8
9
  parseOverride,
9
10
  resolveRole,
@@ -100,6 +101,8 @@ export interface TaskView {
100
101
  plan?: { planned: boolean; reason: string | null } | null;
101
102
  // A quick change: the orchestrator session that makes it (no agent).
102
103
  quickOf?: string | null;
104
+ // What it has cost so far in weighted tokens (lib/usage/weights).
105
+ weighted?: number;
103
106
  }
104
107
 
105
108
  export async function createTask(opts: {
@@ -415,6 +418,11 @@ export async function listTasks(): Promise<TaskView[]> {
415
418
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
416
419
  remoteTaskViews(sessions.filter(isMirror)),
417
420
  ]);
421
+ const costs = taskCosts(local.map((t) => t.id));
422
+ for (const t of local) {
423
+ const w = costs.get(t.id);
424
+ if (w) t.weighted = w;
425
+ }
418
426
  return [...local, ...remote].sort((a, b) =>
419
427
  b.createdAt.localeCompare(a.createdAt)
420
428
  );
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The per-response usage ledger (usage_calls): one row per Claude API
3
+ * response, keyed by its message id. Claude Code writes one assistant message
4
+ * per content block under the same id, with usage growing, so a row is
5
+ * upserted keeping the highest of each field.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { ANTHROPIC, sessionRole } from "../models/roles";
10
+ import { ctxOf, weighted, type TokenRow } from "./weights";
11
+
12
+ export type CallKind = "main" | "sub" | "review" | "gate" | "helper";
13
+ export type CallSource = "live" | "transcript" | "check_run" | "helper";
14
+
15
+ export interface CallRow {
16
+ callKey: string;
17
+ at: number;
18
+ sessionId: string | null;
19
+ taskId: string | null;
20
+ workspaceId: string | null;
21
+ projectId: string | null;
22
+ sessionName: string | null;
23
+ role: string | null;
24
+ kind: CallKind;
25
+ agentType: string | null;
26
+ model: string | null;
27
+ provider: string;
28
+ input: number;
29
+ cacheRead: number;
30
+ cacheWrite5m: number;
31
+ cacheWrite1h: number;
32
+ output: number;
33
+ ctx: number | null;
34
+ source: CallSource;
35
+ }
36
+
37
+ // What a driver reports for one response, before it's tied to a session.
38
+ export type CallUsage = Pick<
39
+ CallRow,
40
+ | "callKey"
41
+ | "at"
42
+ | "kind"
43
+ | "agentType"
44
+ | "model"
45
+ | "input"
46
+ | "cacheRead"
47
+ | "cacheWrite5m"
48
+ | "cacheWrite1h"
49
+ | "output"
50
+ >;
51
+
52
+ interface RawUsage {
53
+ input_tokens?: number;
54
+ output_tokens?: number;
55
+ cache_read_input_tokens?: number;
56
+ cache_creation_input_tokens?: number;
57
+ cache_creation?: {
58
+ ephemeral_5m_input_tokens?: number;
59
+ ephemeral_1h_input_tokens?: number;
60
+ } | null;
61
+ }
62
+
63
+ const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
64
+
65
+ // The usage on an assistant message, or null for a synthetic one (an error
66
+ // the CLI wrote itself) or one with no id.
67
+ export function callFromAssistant(
68
+ message: { id?: string; model?: string; usage?: RawUsage } | undefined,
69
+ kind: CallKind,
70
+ agentType: string | null,
71
+ at = Date.now()
72
+ ): CallUsage | null {
73
+ if (!message?.id || !message.usage) return null;
74
+ if (!message.model || message.model === "<synthetic>") return null;
75
+ const u = message.usage;
76
+ const split = u.cache_creation;
77
+ const w1h = n(split?.ephemeral_1h_input_tokens);
78
+ // Without the split, the whole write counts as the 5-minute kind.
79
+ const w5m = split
80
+ ? n(split.ephemeral_5m_input_tokens)
81
+ : n(u.cache_creation_input_tokens);
82
+ return {
83
+ callKey: message.id,
84
+ at,
85
+ kind,
86
+ agentType,
87
+ model: message.model,
88
+ input: n(u.input_tokens),
89
+ cacheRead: n(u.cache_read_input_tokens),
90
+ cacheWrite5m: w5m,
91
+ cacheWrite1h: w1h,
92
+ output: n(u.output_tokens),
93
+ };
94
+ }
95
+
96
+ export function upsertCall(r: CallRow): void {
97
+ db.prepare(
98
+ `INSERT INTO usage_calls (call_key, at, session_id, task_id, workspace_id, project_id,
99
+ session_name, role, kind, agent_type, model, provider, input_tokens, cache_read_tokens,
100
+ cache_write_5m_tokens, cache_write_1h_tokens, output_tokens, ctx_tokens, source)
101
+ VALUES (@callKey, @at, @sessionId, @taskId, @workspaceId, @projectId, @sessionName, @role,
102
+ @kind, @agentType, @model, @provider, @input, @cacheRead, @cacheWrite5m, @cacheWrite1h,
103
+ @output, @ctx, @source)
104
+ ON CONFLICT(call_key) DO UPDATE SET
105
+ input_tokens = MAX(input_tokens, excluded.input_tokens),
106
+ cache_read_tokens = MAX(cache_read_tokens, excluded.cache_read_tokens),
107
+ cache_write_5m_tokens = MAX(cache_write_5m_tokens, excluded.cache_write_5m_tokens),
108
+ cache_write_1h_tokens = MAX(cache_write_1h_tokens, excluded.cache_write_1h_tokens),
109
+ output_tokens = MAX(output_tokens, excluded.output_tokens),
110
+ ctx_tokens = MAX(COALESCE(ctx_tokens, 0), COALESCE(excluded.ctx_tokens, 0)),
111
+ session_id = COALESCE(session_id, excluded.session_id),
112
+ task_id = COALESCE(task_id, excluded.task_id),
113
+ workspace_id = COALESCE(workspace_id, excluded.workspace_id),
114
+ project_id = COALESCE(project_id, excluded.project_id),
115
+ session_name = COALESCE(session_name, excluded.session_name),
116
+ role = COALESCE(role, excluded.role)`
117
+ ).run(r);
118
+ }
119
+
120
+ export interface SessionContext {
121
+ sessionId: string;
122
+ taskId: string | null;
123
+ workspaceId: string | null;
124
+ projectId: string | null;
125
+ sessionName: string | null;
126
+ role: string | null;
127
+ provider: string;
128
+ }
129
+
130
+ // Who a session's calls belong to. Read per call: a plan becomes
131
+ // implementation, and a name changes.
132
+ export function sessionContext(sessionId: string): SessionContext {
133
+ const s = db
134
+ .prepare(
135
+ `SELECT name, workspace_id, project_id, model_provider, role, task_status,
136
+ plan_phase, started_by FROM sessions WHERE id = ?`
137
+ )
138
+ .get(sessionId) as
139
+ | {
140
+ name: string;
141
+ workspace_id: string | null;
142
+ project_id: string | null;
143
+ model_provider: string | null;
144
+ role: string | null;
145
+ task_status: string | null;
146
+ plan_phase: string | null;
147
+ started_by: string | null;
148
+ }
149
+ | undefined;
150
+ return {
151
+ sessionId,
152
+ taskId: s?.task_status ? sessionId : null,
153
+ workspaceId: s?.workspace_id ?? null,
154
+ projectId: s?.project_id ?? null,
155
+ sessionName: s?.name ?? null,
156
+ role: s ? sessionRole(s) : null,
157
+ provider: s?.model_provider ?? ANTHROPIC,
158
+ };
159
+ }
160
+
161
+ export function recordCall(
162
+ call: CallUsage,
163
+ ctx: SessionContext,
164
+ source: CallSource
165
+ ): void {
166
+ const row: CallRow = {
167
+ ...call,
168
+ sessionId: ctx.sessionId,
169
+ taskId: ctx.taskId,
170
+ workspaceId: ctx.workspaceId,
171
+ projectId: ctx.projectId,
172
+ sessionName: ctx.sessionName,
173
+ role: ctx.role,
174
+ provider: ctx.provider,
175
+ ctx: 0,
176
+ source,
177
+ };
178
+ row.ctx = ctxOf({
179
+ model: row.model,
180
+ input_tokens: row.input,
181
+ cache_read_tokens: row.cacheRead,
182
+ cache_write_5m_tokens: row.cacheWrite5m,
183
+ cache_write_1h_tokens: row.cacheWrite1h,
184
+ output_tokens: row.output,
185
+ });
186
+ upsertCall(row);
187
+ }
188
+
189
+ // A call that never goes through a conversation (a check run).
190
+ export function recordLooseCall(
191
+ r: Omit<CallRow, "ctx" | "provider"> & { provider?: string }
192
+ ): void {
193
+ upsertCall({ ...r, provider: r.provider ?? ANTHROPIC, ctx: null });
194
+ }
195
+
196
+ // check_runs rows not yet in the ledger, as calls of their own.
197
+ export function backfillCheckRuns(): number {
198
+ const rows = db
199
+ .prepare(
200
+ `SELECT c.id, c.workspace_id, c.session_id, c.label, c.kind, c.model, c.provider,
201
+ c.input_tokens, c.cache_write_tokens, c.cache_read_tokens, c.output_tokens, c.at,
202
+ s.project_id FROM check_runs c LEFT JOIN sessions s ON s.id = c.session_id
203
+ WHERE NOT EXISTS (SELECT 1 FROM usage_calls u WHERE u.call_key = 'check_run:' || c.id)`
204
+ )
205
+ .all() as {
206
+ id: number;
207
+ workspace_id: string;
208
+ session_id: string;
209
+ label: string;
210
+ kind: string;
211
+ model: string | null;
212
+ provider: string | null;
213
+ input_tokens: number;
214
+ cache_write_tokens: number;
215
+ cache_read_tokens: number;
216
+ output_tokens: number;
217
+ at: number;
218
+ project_id: string | null;
219
+ }[];
220
+ db.transaction(() => {
221
+ for (const c of rows) recordCheckRunCall(c.id, c);
222
+ })();
223
+ return rows.length;
224
+ }
225
+
226
+ export function recordCheckRunCall(
227
+ id: number,
228
+ c: {
229
+ workspace_id: string;
230
+ session_id: string;
231
+ label: string;
232
+ kind: string;
233
+ model: string | null;
234
+ provider: string | null;
235
+ input_tokens: number;
236
+ cache_write_tokens: number;
237
+ cache_read_tokens: number;
238
+ output_tokens: number;
239
+ at: number;
240
+ project_id?: string | null;
241
+ }
242
+ ): void {
243
+ recordLooseCall({
244
+ callKey: `check_run:${id}`,
245
+ at: c.at,
246
+ sessionId: c.session_id,
247
+ taskId: c.session_id ? sessionContext(c.session_id).taskId : null,
248
+ workspaceId: c.workspace_id,
249
+ projectId: c.project_id ?? null,
250
+ sessionName: c.label,
251
+ role: `gate-${c.kind}`,
252
+ kind: "gate",
253
+ agentType: null,
254
+ model: c.model,
255
+ provider: c.provider ?? ANTHROPIC,
256
+ input: c.input_tokens,
257
+ cacheRead: c.cache_read_tokens,
258
+ cacheWrite5m: c.cache_write_tokens,
259
+ cacheWrite1h: 0,
260
+ output: c.output_tokens,
261
+ source: "check_run",
262
+ });
263
+ }
264
+
265
+ // What each task cost so far in weighted tokens: its own session, its
266
+ // subagents and its gate reviews. One grouped query for the whole list.
267
+ export function taskCosts(ids: string[]): Map<string, number> {
268
+ const out = new Map<string, number>();
269
+ if (!ids.length) return out;
270
+ const marks = ids.map(() => "?").join(",");
271
+ const rows = db
272
+ .prepare(
273
+ `SELECT task_id, model, provider, SUM(input_tokens) AS input_tokens,
274
+ SUM(cache_read_tokens) AS cache_read_tokens,
275
+ SUM(cache_write_5m_tokens) AS cache_write_5m_tokens,
276
+ SUM(cache_write_1h_tokens) AS cache_write_1h_tokens,
277
+ SUM(output_tokens) AS output_tokens
278
+ FROM usage_calls WHERE task_id IN (${marks}) GROUP BY task_id, model, provider`
279
+ )
280
+ .all(...ids) as (TokenRow & { task_id: string })[];
281
+ for (const r of rows)
282
+ out.set(r.task_id, (out.get(r.task_id) ?? 0) + weighted(r));
283
+ return out;
284
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { db } from "../db";
9
+ import { recordCheckRunCall } from "./calls";
9
10
  import { formatTokens } from "../chat/context";
10
11
 
11
12
  export interface RunUsage {
@@ -93,27 +94,47 @@ export function recordCheckRun(
93
94
  u: RunUsage,
94
95
  at = Date.now()
95
96
  ): void {
96
- db.prepare(
97
- `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
97
+ const info = db
98
+ .prepare(
99
+ `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
98
100
  input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
99
101
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
100
- ).run(
101
- target.workspaceId,
102
- target.sessionId,
103
- target.label,
104
- target.prNumber,
105
- target.sha,
106
- target.kind,
107
- u.model,
108
- u.provider ?? "anthropic",
109
- u.inputTokens,
110
- u.cacheWriteTokens,
111
- u.cacheReadTokens,
112
- u.outputTokens,
113
- u.costUsd,
114
- u.durationMs,
115
- at
116
- );
102
+ )
103
+ .run(
104
+ target.workspaceId,
105
+ target.sessionId,
106
+ target.label,
107
+ target.prNumber,
108
+ target.sha,
109
+ target.kind,
110
+ u.model,
111
+ u.provider ?? "anthropic",
112
+ u.inputTokens,
113
+ u.cacheWriteTokens,
114
+ u.cacheReadTokens,
115
+ u.outputTokens,
116
+ u.costUsd,
117
+ u.durationMs,
118
+ at
119
+ );
120
+ try {
121
+ recordCheckRunCall(Number(info.lastInsertRowid), {
122
+ workspace_id: target.workspaceId,
123
+ session_id: target.sessionId,
124
+ label: target.label,
125
+ kind: target.kind,
126
+ model: u.model,
127
+ provider: u.provider ?? "anthropic",
128
+ input_tokens: u.inputTokens,
129
+ cache_write_tokens: u.cacheWriteTokens,
130
+ cache_read_tokens: u.cacheReadTokens,
131
+ output_tokens: u.outputTokens,
132
+ at,
133
+ });
134
+ } catch (e) {
135
+ // The nightly backfill picks it up.
136
+ console.error(`[check-runs] ledger ${target.label}:`, e);
137
+ }
117
138
  }
118
139
 
119
140
  // A recorder for one check, that never lets a write break the check.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The ratchet's thresholds, kept in the settings table so they can change
3
+ * without a deploy. Each falls back to its default for a missing or invalid
4
+ * value.
5
+ */
6
+
7
+ import { db } from "../db";
8
+
9
+ export const USAGE_KEYS = {
10
+ costPct: "usage.alert_cost_pct",
11
+ cleanPoints: "usage.alert_clean_points",
12
+ baselineDays: "usage.baseline_days",
13
+ avgDays: "usage.avg_days",
14
+ minMerges: "usage.alert_min_merges",
15
+ weeklyStaleHours: "usage.weekly_stale_hours",
16
+ } as const;
17
+
18
+ export interface UsageConfig {
19
+ // Alert when the 3-day cost per merged PR is this many percent over baseline.
20
+ costPct: number;
21
+ // Alert when the clean-merge rate is this many points under baseline.
22
+ cleanPoints: number;
23
+ baselineDays: number;
24
+ avgDays: number;
25
+ // Fewer merges than this over the average window: too little to judge.
26
+ minMerges: number;
27
+ weeklyStaleHours: number;
28
+ }
29
+
30
+ export const USAGE_DEFAULTS: UsageConfig = {
31
+ costPct: 15,
32
+ cleanPoints: 10,
33
+ baselineDays: 7,
34
+ avgDays: 3,
35
+ minMerges: 3,
36
+ weeklyStaleHours: 6,
37
+ };
38
+
39
+ const DAY_COUNTS = new Set<keyof UsageConfig>(["baselineDays", "avgDays"]);
40
+
41
+ export function readUsageConfig(): UsageConfig {
42
+ const out: UsageConfig = { ...USAGE_DEFAULTS };
43
+ for (const [name, key] of Object.entries(USAGE_KEYS)) {
44
+ const row = db
45
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
46
+ .get(key) as { value: string } | undefined;
47
+ const v = row ? Number(row.value) : NaN;
48
+ const key2 = name as keyof UsageConfig;
49
+ // A day count is a whole number of days, at least one.
50
+ const ok = DAY_COUNTS.has(key2)
51
+ ? Number.isInteger(v) && v >= 1
52
+ : Number.isFinite(v) && v >= 0;
53
+ if (ok) out[key2] = v;
54
+ }
55
+ return out;
56
+ }