@saadnvd1/agent-os 0.3.498 → 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.
- package/README.md +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +86 -0
- package/lib/orchestrator/armed.ts +2 -1
- package/lib/orchestrator/deliver.ts +49 -7
- package/lib/orchestrator/events.ts +35 -2
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
package/lib/db/migrations.ts
CHANGED
|
@@ -1507,6 +1507,92 @@ const migrations: Migration[] = [
|
|
|
1507
1507
|
);
|
|
1508
1508
|
},
|
|
1509
1509
|
},
|
|
1510
|
+
{
|
|
1511
|
+
id: 67,
|
|
1512
|
+
name: "orchestrator_event_log_suppressed",
|
|
1513
|
+
up: (db) => {
|
|
1514
|
+
// A capped event is logged as suppressed so a drop leaves a trace.
|
|
1515
|
+
const columns = db
|
|
1516
|
+
.prepare(`PRAGMA table_info(orchestrator_event_log)`)
|
|
1517
|
+
.all() as { name: string }[];
|
|
1518
|
+
if (!columns.some((c) => c.name === "suppressed"))
|
|
1519
|
+
db.exec(
|
|
1520
|
+
`ALTER TABLE orchestrator_event_log ADD COLUMN suppressed TEXT`
|
|
1521
|
+
);
|
|
1522
|
+
},
|
|
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
|
+
},
|
|
1510
1596
|
];
|
|
1511
1597
|
|
|
1512
1598
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -276,7 +276,8 @@ async function merge(
|
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
278
278
|
setArmed(task.id, false);
|
|
279
|
-
// The merged condition would say it again on the next diff
|
|
279
|
+
// The merged condition would say it again on the next diff, so this armed
|
|
280
|
+
// line is the one merged notice. deliver.ts never caps `armed:` events.
|
|
280
281
|
markTold(workspaceId, `merged:${task.id}`, task.id);
|
|
281
282
|
event(
|
|
282
283
|
workspaceId,
|
|
@@ -4,10 +4,13 @@ import {
|
|
|
4
4
|
isAnswer,
|
|
5
5
|
isAwayEvent,
|
|
6
6
|
deliveriesSince,
|
|
7
|
+
type EventRow,
|
|
7
8
|
lastDeliveredAt,
|
|
8
9
|
logDeliveries,
|
|
10
|
+
logSuppressed,
|
|
9
11
|
markDelivered,
|
|
10
12
|
pendingEvents,
|
|
13
|
+
suppressedSince,
|
|
11
14
|
unlogDeliveries,
|
|
12
15
|
} from "./events";
|
|
13
16
|
import { isPaused } from "./pause";
|
|
@@ -20,6 +23,25 @@ export const LOW_HOLD_MS = 10 * 60 * 1000;
|
|
|
20
23
|
export const SUBJECT_CAP = 6;
|
|
21
24
|
const HOUR = 60 * 60 * 1000;
|
|
22
25
|
|
|
26
|
+
// Terminal and decision events are never capped: a merge, a gate refusal or
|
|
27
|
+
// failure, an escalation to Saad, a drop or close, a review verdict, an
|
|
28
|
+
// ask's answer. A finished task raises nothing after them, so a dropped one
|
|
29
|
+
// is gone for good. The cap is for repeating noise (CI green, stack steps).
|
|
30
|
+
const UNCAPPED = [
|
|
31
|
+
"merged:",
|
|
32
|
+
"blocked:",
|
|
33
|
+
"stack:merged:",
|
|
34
|
+
"stack:failed:",
|
|
35
|
+
"stack-status:",
|
|
36
|
+
"once:armed:", // merged, refused, failed, disarmed, closed
|
|
37
|
+
"once:review:",
|
|
38
|
+
"once:ask:",
|
|
39
|
+
"once:conflict:",
|
|
40
|
+
"once:plan-done:",
|
|
41
|
+
];
|
|
42
|
+
export const bypassesCap = (e: Pick<EventRow, "key">) =>
|
|
43
|
+
UNCAPPED.some((p) => e.key.startsWith(p));
|
|
44
|
+
|
|
23
45
|
export const AGENTOS_SENDER = "agentos";
|
|
24
46
|
|
|
25
47
|
export type ChatSender = (
|
|
@@ -47,7 +69,8 @@ const failing = new Map<string, { failures: number; until: number }>();
|
|
|
47
69
|
// Rows are marked delivered before the send so a restart can't send them
|
|
48
70
|
// twice. The cost: if the process dies between marking and the send
|
|
49
71
|
// reaching the worker, those events are lost rather than repeated. That's
|
|
50
|
-
// the cheaper failure for a paid turn
|
|
72
|
+
// the cheaper failure for a paid turn. (A condition that still holds is
|
|
73
|
+
// re-raised by the next change; a one-off event is not.)
|
|
51
74
|
export async function deliverEvents(opts: {
|
|
52
75
|
workspaceId: string;
|
|
53
76
|
orchestratorId: string;
|
|
@@ -62,22 +85,33 @@ export async function deliverEvents(opts: {
|
|
|
62
85
|
if ((failing.get(workspaceId)?.until ?? 0) > now) return null;
|
|
63
86
|
if (now - lastDeliveredAt(workspaceId) < BATCH_WINDOW_MS) return null;
|
|
64
87
|
|
|
65
|
-
// Over a subject's hourly cap: dropped, not sent later
|
|
88
|
+
// Over a subject's hourly cap: dropped, not sent later, but logged as
|
|
89
|
+
// suppressed and counted into the subject's next line. Terminal events
|
|
90
|
+
// skip the cap.
|
|
66
91
|
const counts = new Map<string, number>();
|
|
67
|
-
const capped:
|
|
92
|
+
const capped: EventRow[] = [];
|
|
68
93
|
const pending = pendingEvents(workspaceId).filter((e) => {
|
|
69
|
-
if (!e.subject) return true;
|
|
94
|
+
if (!e.subject || bypassesCap(e)) return true;
|
|
70
95
|
const n =
|
|
71
96
|
counts.get(e.subject) ??
|
|
72
97
|
deliveriesSince(workspaceId, e.subject, now - HOUR);
|
|
73
98
|
if (n >= SUBJECT_CAP) {
|
|
74
|
-
capped.push(e
|
|
99
|
+
capped.push(e);
|
|
75
100
|
return false;
|
|
76
101
|
}
|
|
77
102
|
counts.set(e.subject, n + 1);
|
|
78
103
|
return true;
|
|
79
104
|
});
|
|
80
|
-
if (capped.length)
|
|
105
|
+
if (capped.length) {
|
|
106
|
+
markDelivered(
|
|
107
|
+
capped.map((e) => e.id),
|
|
108
|
+
now
|
|
109
|
+
);
|
|
110
|
+
logSuppressed(workspaceId, capped, now);
|
|
111
|
+
console.warn(
|
|
112
|
+
`[orchestrator] ${capped.length} event(s) over the hourly cap dropped: ${capped.map((e) => e.key).join(", ")}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
81
115
|
if (!pending.length) return null;
|
|
82
116
|
const oldestLow = Math.min(
|
|
83
117
|
...pending.filter((e) => e.low).map((e) => Date.parse(e.created_at))
|
|
@@ -85,7 +119,15 @@ export async function deliverEvents(opts: {
|
|
|
85
119
|
if (pending.every((e) => e.low) && now - oldestLow < LOW_HOLD_MS) return null;
|
|
86
120
|
|
|
87
121
|
const ids = pending.map((e) => e.id);
|
|
88
|
-
const
|
|
122
|
+
const lines = [...new Set(pending.map((e) => e.line))];
|
|
123
|
+
for (const subject of new Set(pending.map((e) => e.subject))) {
|
|
124
|
+
const n = subject ? suppressedSince(workspaceId, subject, now - HOUR) : 0;
|
|
125
|
+
if (n)
|
|
126
|
+
lines.push(
|
|
127
|
+
`(+${n} capped event${n === 1 ? "" : "s"} for ${subject} in the last hour)`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const text = lines.join("\n");
|
|
89
131
|
// Saad's answers to asks, shown in chat as his decisions.
|
|
90
132
|
const decided = [...new Set(pending.filter(isAnswer).map((e) => e.line))];
|
|
91
133
|
const origin: ChatOrigin = {
|
|
@@ -238,6 +238,38 @@ export function logDeliveries(
|
|
|
238
238
|
)();
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// Events held back by the subject cap: logged with the line, never counted
|
|
242
|
+
// as deliveries.
|
|
243
|
+
export function logSuppressed(
|
|
244
|
+
workspaceId: string,
|
|
245
|
+
events: Pick<EventRow, "subject" | "key">[],
|
|
246
|
+
at: number
|
|
247
|
+
): void {
|
|
248
|
+
const stmt = db.prepare(
|
|
249
|
+
`INSERT INTO orchestrator_event_log (workspace_id, subject, delivered_at, suppressed) VALUES (?, ?, ?, ?)`
|
|
250
|
+
);
|
|
251
|
+
db.transaction(() => {
|
|
252
|
+
for (const e of events) stmt.run(workspaceId, e.subject, iso(at), e.key);
|
|
253
|
+
})();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Events suppressed for a subject in the window.
|
|
257
|
+
export function suppressedSince(
|
|
258
|
+
workspaceId: string,
|
|
259
|
+
subject: string,
|
|
260
|
+
since: number
|
|
261
|
+
): number {
|
|
262
|
+
return (
|
|
263
|
+
db
|
|
264
|
+
.prepare(
|
|
265
|
+
`SELECT COUNT(*) AS n FROM orchestrator_event_log
|
|
266
|
+
WHERE workspace_id = ? AND subject = ? AND delivered_at >= ?
|
|
267
|
+
AND suppressed IS NOT NULL`
|
|
268
|
+
)
|
|
269
|
+
.get(workspaceId, subject, iso(since)) as { n: number }
|
|
270
|
+
).n;
|
|
271
|
+
}
|
|
272
|
+
|
|
241
273
|
export function unlogDeliveries(ids: number[]): void {
|
|
242
274
|
const stmt = db.prepare(`DELETE FROM orchestrator_event_log WHERE id = ?`);
|
|
243
275
|
db.transaction(() => ids.forEach((id) => stmt.run(id)))();
|
|
@@ -246,7 +278,7 @@ export function unlogDeliveries(ids: number[]): void {
|
|
|
246
278
|
export function lastDeliveredAt(workspaceId: string): number {
|
|
247
279
|
const row = db
|
|
248
280
|
.prepare(
|
|
249
|
-
`SELECT MAX(delivered_at) AS at FROM orchestrator_event_log WHERE workspace_id =
|
|
281
|
+
`SELECT MAX(delivered_at) AS at FROM orchestrator_event_log WHERE workspace_id = ? AND suppressed IS NULL`
|
|
250
282
|
)
|
|
251
283
|
.get(workspaceId) as { at: string | null };
|
|
252
284
|
return row.at ? Date.parse(row.at) : 0;
|
|
@@ -261,7 +293,8 @@ export function deliveriesSince(
|
|
|
261
293
|
db
|
|
262
294
|
.prepare(
|
|
263
295
|
`SELECT COUNT(*) AS n FROM orchestrator_event_log
|
|
264
|
-
WHERE workspace_id = ? AND subject = ? AND delivered_at >=
|
|
296
|
+
WHERE workspace_id = ? AND subject = ? AND delivered_at >= ?
|
|
297
|
+
AND suppressed IS NULL`
|
|
265
298
|
)
|
|
266
299
|
.get(workspaceId, subject, iso(since)) as { n: number }
|
|
267
300
|
).n;
|
package/lib/security/auth.ts
CHANGED
|
@@ -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.
|
|
155
|
-
// Connect
|
|
156
|
-
if (policy.off && req.remoteAddress)
|
|
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
|
}
|
package/lib/security/gate.ts
CHANGED
|
@@ -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
|
|
48
|
-
// can't be steered by headers the caller chose.
|
|
49
|
-
export const sourceLabel = (remote?: string) =>
|
|
50
|
-
plainAddress(remote) || "
|
|
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
|
-
|
|
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
|
);
|
package/lib/security/reach.ts
CHANGED
package/lib/tasks/index.ts
CHANGED
|
@@ -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
|
+
}
|