@hasna/economy 0.3.6 → 0.3.8
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/CHANGELOG.md +31 -0
- package/README.md +73 -60
- package/dashboard/README.md +17 -64
- package/dist/cli/commands/extras.d.ts.map +1 -1
- package/dist/cli/commands/todos.d.ts +3 -0
- package/dist/cli/commands/todos.d.ts.map +1 -1
- package/dist/cli/index.js +914 -199
- package/dist/db/cloud.d.ts +3 -4
- package/dist/db/cloud.d.ts.map +1 -1
- package/dist/db/database.d.ts +10 -3
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/dialect.d.ts +13 -0
- package/dist/db/dialect.d.ts.map +1 -0
- package/dist/db/pg-migrate.d.ts +30 -0
- package/dist/db/pg-migrate.d.ts.map +1 -0
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/sqlite-adapter.d.ts +36 -0
- package/dist/db/sqlite-adapter.d.ts.map +1 -0
- package/dist/db/sync-pg.d.ts +1 -1
- package/dist/db/sync-pg.d.ts.map +1 -1
- package/dist/index.js +309 -59
- package/dist/ingest/billing.d.ts +1 -1
- package/dist/ingest/billing.d.ts.map +1 -1
- package/dist/ingest/claude-quota.d.ts +1 -1
- package/dist/ingest/claude-quota.d.ts.map +1 -1
- package/dist/ingest/claude.d.ts +1 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex-quota.d.ts +1 -1
- package/dist/ingest/codex-quota.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts +1 -1
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/cursor.d.ts +1 -1
- package/dist/ingest/cursor.d.ts.map +1 -1
- package/dist/ingest/gemini.d.ts +1 -1
- package/dist/ingest/gemini.d.ts.map +1 -1
- package/dist/ingest/hermes.d.ts +1 -1
- package/dist/ingest/hermes.d.ts.map +1 -1
- package/dist/ingest/loops.d.ts +6 -0
- package/dist/ingest/loops.d.ts.map +1 -0
- package/dist/ingest/opencode.d.ts +1 -1
- package/dist/ingest/opencode.d.ts.map +1 -1
- package/dist/ingest/otel.d.ts +16 -3
- package/dist/ingest/otel.d.ts.map +1 -1
- package/dist/ingest/pi.d.ts +1 -1
- package/dist/ingest/pi.d.ts.map +1 -1
- package/dist/ingest/plugin.d.ts +1 -1
- package/dist/ingest/plugin.d.ts.map +1 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/analytics.d.ts +1 -1
- package/dist/lib/analytics.d.ts.map +1 -1
- package/dist/lib/billing-diff.d.ts +1 -1
- package/dist/lib/billing-diff.d.ts.map +1 -1
- package/dist/lib/brief.d.ts +1 -1
- package/dist/lib/brief.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts +10 -2
- package/dist/lib/open-projects.d.ts.map +1 -1
- package/dist/lib/pricing.d.ts +1 -1
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/savings.d.ts +1 -1
- package/dist/lib/savings.d.ts.map +1 -1
- package/dist/lib/spikes.d.ts +1 -1
- package/dist/lib/spikes.d.ts.map +1 -1
- package/dist/lib/store/index.d.ts +9 -1
- package/dist/lib/store/index.d.ts.map +1 -1
- package/dist/lib/sync-all.d.ts +3 -1
- package/dist/lib/sync-all.d.ts.map +1 -1
- package/dist/lib/webhooks.d.ts +1 -1
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/mcp/index.js +757 -166
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/otel/index.js +262 -27
- package/dist/server/index.js +683 -135
- package/dist/server/serve.d.ts +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +37 -4
- package/dist/types/index.d.ts.map +1 -1
- package/docs/README.md +12 -0
- package/docs/cli.md +88 -0
- package/docs/configuration.md +86 -0
- package/docs/ingestion.md +47 -0
- package/docs/mcp.md +65 -0
- package/docs/otel.md +53 -0
- package/docs/rest-api.md +89 -0
- package/package.json +4 -5
- package/dist/lib/cloud-sync.d.ts +0 -35
- package/dist/lib/cloud-sync.d.ts.map +0 -1
- package/dist/lib/fleet-sync.d.ts +0 -61
- package/dist/lib/fleet-sync.d.ts.map +0 -1
- package/dist/lib/peer-sync.d.ts +0 -21
- package/dist/lib/peer-sync.d.ts.map +0 -1
- package/dist/storage.js +0 -2746
package/dist/server/index.js
CHANGED
|
@@ -17,6 +17,62 @@ var __export = (target, all) => {
|
|
|
17
17
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
18
|
var __require = import.meta.require;
|
|
19
19
|
|
|
20
|
+
// src/db/sqlite-adapter.ts
|
|
21
|
+
import { Database } from "bun:sqlite";
|
|
22
|
+
|
|
23
|
+
class SqliteAdapter {
|
|
24
|
+
db;
|
|
25
|
+
constructor(path) {
|
|
26
|
+
this.db = new Database(path, { create: true });
|
|
27
|
+
this.db.exec("PRAGMA journal_mode=WAL");
|
|
28
|
+
this.db.exec("PRAGMA foreign_keys=ON");
|
|
29
|
+
}
|
|
30
|
+
run(sql, ...params) {
|
|
31
|
+
const result = this.db.prepare(sql).run(...params);
|
|
32
|
+
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
|
|
33
|
+
}
|
|
34
|
+
get(sql, ...params) {
|
|
35
|
+
return this.db.prepare(sql).get(...params);
|
|
36
|
+
}
|
|
37
|
+
all(sql, ...params) {
|
|
38
|
+
return this.db.prepare(sql).all(...params);
|
|
39
|
+
}
|
|
40
|
+
exec(sql) {
|
|
41
|
+
this.db.exec(sql);
|
|
42
|
+
}
|
|
43
|
+
query(sql) {
|
|
44
|
+
return this.db.query(sql);
|
|
45
|
+
}
|
|
46
|
+
prepare(sql) {
|
|
47
|
+
const stmt = this.db.prepare(sql);
|
|
48
|
+
return {
|
|
49
|
+
run(...params) {
|
|
50
|
+
const r = stmt.run(...params);
|
|
51
|
+
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
|
|
52
|
+
},
|
|
53
|
+
get(...params) {
|
|
54
|
+
return stmt.get(...params);
|
|
55
|
+
},
|
|
56
|
+
all(...params) {
|
|
57
|
+
return stmt.all(...params);
|
|
58
|
+
},
|
|
59
|
+
finalize() {
|
|
60
|
+
stmt.finalize();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
close() {
|
|
65
|
+
this.db.close();
|
|
66
|
+
}
|
|
67
|
+
transaction(fn) {
|
|
68
|
+
return this.db.transaction(fn)();
|
|
69
|
+
}
|
|
70
|
+
get raw() {
|
|
71
|
+
return this.db;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
var init_sqlite_adapter = () => {};
|
|
75
|
+
|
|
20
76
|
// src/lib/pricing.ts
|
|
21
77
|
var exports_pricing = {};
|
|
22
78
|
__export(exports_pricing, {
|
|
@@ -535,7 +591,6 @@ var init_pricing = __esm(() => {
|
|
|
535
591
|
});
|
|
536
592
|
|
|
537
593
|
// src/db/database.ts
|
|
538
|
-
import { SqliteAdapter as Database } from "@hasna/cloud";
|
|
539
594
|
import { execFileSync } from "child_process";
|
|
540
595
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
541
596
|
import { hostname, platform } from "os";
|
|
@@ -628,7 +683,7 @@ function openDatabase(dbPath, skipSeed = false) {
|
|
|
628
683
|
mkdirSync(dir, { recursive: true });
|
|
629
684
|
}
|
|
630
685
|
const db = withSqliteBusyRetry(() => {
|
|
631
|
-
const opened = new
|
|
686
|
+
const opened = new SqliteAdapter(path);
|
|
632
687
|
try {
|
|
633
688
|
opened.exec("PRAGMA busy_timeout = 10000");
|
|
634
689
|
opened.exec("PRAGMA journal_mode = WAL");
|
|
@@ -669,6 +724,16 @@ function addColumnIfMissing(db, table, column, definition) {
|
|
|
669
724
|
}
|
|
670
725
|
function initSchema(db) {
|
|
671
726
|
db.exec(`
|
|
727
|
+
CREATE TABLE IF NOT EXISTS cost_centers (
|
|
728
|
+
id TEXT PRIMARY KEY,
|
|
729
|
+
kind TEXT NOT NULL,
|
|
730
|
+
name TEXT NOT NULL,
|
|
731
|
+
repo_path TEXT,
|
|
732
|
+
labels_json TEXT DEFAULT '{}',
|
|
733
|
+
created_at TEXT NOT NULL,
|
|
734
|
+
updated_at TEXT NOT NULL
|
|
735
|
+
);
|
|
736
|
+
|
|
672
737
|
CREATE TABLE IF NOT EXISTS requests (
|
|
673
738
|
id TEXT PRIMARY KEY,
|
|
674
739
|
agent TEXT NOT NULL,
|
|
@@ -685,6 +750,7 @@ function initSchema(db) {
|
|
|
685
750
|
timestamp TEXT NOT NULL,
|
|
686
751
|
source_request_id TEXT,
|
|
687
752
|
machine_id TEXT DEFAULT '',
|
|
753
|
+
cost_center_id TEXT,
|
|
688
754
|
account_key TEXT DEFAULT '',
|
|
689
755
|
account_tool TEXT DEFAULT '',
|
|
690
756
|
account_name TEXT DEFAULT '',
|
|
@@ -703,6 +769,7 @@ function initSchema(db) {
|
|
|
703
769
|
total_tokens INTEGER DEFAULT 0,
|
|
704
770
|
request_count INTEGER DEFAULT 0,
|
|
705
771
|
machine_id TEXT DEFAULT '',
|
|
772
|
+
cost_center_id TEXT,
|
|
706
773
|
account_key TEXT DEFAULT '',
|
|
707
774
|
account_tool TEXT DEFAULT '',
|
|
708
775
|
account_name TEXT DEFAULT '',
|
|
@@ -723,6 +790,7 @@ function initSchema(db) {
|
|
|
723
790
|
id TEXT PRIMARY KEY,
|
|
724
791
|
project_path TEXT,
|
|
725
792
|
agent TEXT,
|
|
793
|
+
cost_center_id TEXT,
|
|
726
794
|
period TEXT NOT NULL,
|
|
727
795
|
limit_usd REAL NOT NULL,
|
|
728
796
|
alert_at_percent INTEGER DEFAULT 80,
|
|
@@ -753,6 +821,7 @@ function initSchema(db) {
|
|
|
753
821
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
754
822
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
|
|
755
823
|
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
|
|
824
|
+
CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
|
|
756
825
|
|
|
757
826
|
CREATE TABLE IF NOT EXISTS model_pricing (
|
|
758
827
|
model TEXT PRIMARY KEY,
|
|
@@ -839,6 +908,12 @@ function initSchema(db) {
|
|
|
839
908
|
`);
|
|
840
909
|
addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
|
|
841
910
|
addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
|
|
911
|
+
if (addColumnIfMissing(db, "cost_centers", "updated_at", `TEXT DEFAULT ''`)) {
|
|
912
|
+
db.exec(`UPDATE cost_centers SET updated_at = created_at WHERE updated_at = '' OR updated_at IS NULL`);
|
|
913
|
+
}
|
|
914
|
+
addColumnIfMissing(db, "requests", "cost_center_id", `TEXT`);
|
|
915
|
+
addColumnIfMissing(db, "sessions", "cost_center_id", `TEXT`);
|
|
916
|
+
addColumnIfMissing(db, "budgets", "cost_center_id", `TEXT`);
|
|
842
917
|
if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
|
|
843
918
|
db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
|
|
844
919
|
}
|
|
@@ -867,6 +942,10 @@ function initSchema(db) {
|
|
|
867
942
|
CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
|
|
868
943
|
CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
|
|
869
944
|
CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
|
|
945
|
+
CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id);
|
|
946
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id);
|
|
947
|
+
CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id);
|
|
948
|
+
CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
|
|
870
949
|
`);
|
|
871
950
|
}
|
|
872
951
|
function periodWhere(period) {
|
|
@@ -917,6 +996,14 @@ function requestPeriodWhere(period) {
|
|
|
917
996
|
return "1=1";
|
|
918
997
|
}
|
|
919
998
|
}
|
|
999
|
+
function upsertCostCenter(db, center) {
|
|
1000
|
+
const updatedAt = center.updated_at ?? center.created_at;
|
|
1001
|
+
db.prepare(`
|
|
1002
|
+
INSERT OR REPLACE INTO cost_centers
|
|
1003
|
+
(id, kind, name, repo_path, labels_json, created_at, updated_at)
|
|
1004
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1005
|
+
`).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
|
|
1006
|
+
}
|
|
920
1007
|
function upsertRequest(db, req) {
|
|
921
1008
|
const now = req.updated_at ?? new Date().toISOString();
|
|
922
1009
|
db.prepare(`
|
|
@@ -924,10 +1011,10 @@ function upsertRequest(db, req) {
|
|
|
924
1011
|
(id, agent, session_id, model, input_tokens, output_tokens,
|
|
925
1012
|
cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
|
|
926
1013
|
cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
|
|
927
|
-
source_request_id, machine_id, attribution_tag, account_key, account_tool,
|
|
1014
|
+
source_request_id, machine_id, cost_center_id, attribution_tag, account_key, account_tool,
|
|
928
1015
|
account_name, account_email, account_source, updated_at, synced_at)
|
|
929
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
930
|
-
`).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
|
|
1016
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1017
|
+
`).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.cost_center_id ?? null, req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
|
|
931
1018
|
}
|
|
932
1019
|
function upsertSession(db, session) {
|
|
933
1020
|
const now = session.updated_at ?? new Date().toISOString();
|
|
@@ -935,9 +1022,9 @@ function upsertSession(db, session) {
|
|
|
935
1022
|
INSERT OR REPLACE INTO sessions
|
|
936
1023
|
(id, agent, project_path, project_name, started_at, ended_at,
|
|
937
1024
|
total_cost_usd, total_tokens, request_count, machine_id, attribution_tag,
|
|
938
|
-
account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
|
|
939
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
940
|
-
`).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
|
|
1025
|
+
cost_center_id, account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
|
|
1026
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1027
|
+
`).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", session.cost_center_id ?? null, session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
|
|
941
1028
|
}
|
|
942
1029
|
function rollupSession(db, sessionId) {
|
|
943
1030
|
db.prepare(`
|
|
@@ -963,9 +1050,12 @@ function rollupSession(db, sessionId) {
|
|
|
963
1050
|
ELSE account_email END,
|
|
964
1051
|
account_source = CASE WHEN account_source = '' OR account_source IS NULL
|
|
965
1052
|
THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
966
|
-
ELSE account_source END
|
|
1053
|
+
ELSE account_source END,
|
|
1054
|
+
cost_center_id = CASE WHEN cost_center_id = '' OR cost_center_id IS NULL
|
|
1055
|
+
THEN COALESCE((SELECT cost_center_id FROM requests WHERE session_id = ? AND cost_center_id IS NOT NULL AND cost_center_id != '' ORDER BY timestamp DESC LIMIT 1), NULL)
|
|
1056
|
+
ELSE cost_center_id END
|
|
967
1057
|
WHERE id = ?
|
|
968
|
-
`).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
|
|
1058
|
+
`).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
|
|
969
1059
|
}
|
|
970
1060
|
function querySessions(db, filter = {}) {
|
|
971
1061
|
const conditions = [];
|
|
@@ -1181,9 +1271,12 @@ function labelForPath(projectPath, projectName) {
|
|
|
1181
1271
|
function groupKeyForPath(projectPath, projectName) {
|
|
1182
1272
|
return labelForPath(projectPath, projectName).trim().toLowerCase();
|
|
1183
1273
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
|
|
1274
|
+
function laterTimestamp(current, candidate) {
|
|
1275
|
+
if (!candidate)
|
|
1276
|
+
return current;
|
|
1277
|
+
return candidate > current ? candidate : current;
|
|
1278
|
+
}
|
|
1279
|
+
function queryProjectBreakdownScoped(db, requestWhere, sessionWhere, requestScopeParams = [], sessionScopeParams = [], machine) {
|
|
1187
1280
|
const sessionMachineClause = machine ? " AND (machine_id = ? OR id IN (SELECT DISTINCT session_id FROM requests WHERE machine_id = ?))" : "";
|
|
1188
1281
|
const requestMachineClause = machine ? " AND machine_id = ?" : "";
|
|
1189
1282
|
const sessionMachineParams = machine ? [machine, machine] : [];
|
|
@@ -1191,67 +1284,89 @@ function queryProjectBreakdown(db, period = "all", machine) {
|
|
|
1191
1284
|
const sessionOnlyMachineClause = machine ? " AND machine_id = ?" : "";
|
|
1192
1285
|
const sessionOnlyMachineParams = machine ? [machine] : [];
|
|
1193
1286
|
const sessions = db.prepare(`
|
|
1194
|
-
SELECT id, project_path, project_name
|
|
1287
|
+
SELECT id, project_path, project_name
|
|
1195
1288
|
FROM sessions
|
|
1196
1289
|
WHERE (project_path != '' OR project_name != '')${sessionMachineClause}
|
|
1197
1290
|
`).all(...sessionMachineParams);
|
|
1198
1291
|
const groups = new Map;
|
|
1292
|
+
const groupKeyBySession = new Map;
|
|
1199
1293
|
for (const s of sessions) {
|
|
1200
1294
|
const label = labelForPath(s.project_path, s.project_name);
|
|
1201
1295
|
if (!label)
|
|
1202
1296
|
continue;
|
|
1203
1297
|
const key = groupKeyForPath(s.project_path, s.project_name);
|
|
1204
|
-
const g = groups.get(key) ?? { label,
|
|
1205
|
-
g.sessionIds.push(s.id);
|
|
1298
|
+
const g = groups.get(key) ?? { label, samplePath: s.project_path, sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: "" };
|
|
1206
1299
|
if (!g.samplePath)
|
|
1207
1300
|
g.samplePath = s.project_path;
|
|
1208
1301
|
groups.set(key, g);
|
|
1302
|
+
groupKeyBySession.set(s.id, key);
|
|
1303
|
+
}
|
|
1304
|
+
const groupFor = (sessionId) => {
|
|
1305
|
+
const key = groupKeyBySession.get(sessionId);
|
|
1306
|
+
return key === undefined ? undefined : groups.get(key);
|
|
1307
|
+
};
|
|
1308
|
+
const requestRows = db.prepare(`
|
|
1309
|
+
SELECT session_id,
|
|
1310
|
+
COUNT(*) as requests,
|
|
1311
|
+
COALESCE(SUM(cost_usd), 0) as cost_usd,
|
|
1312
|
+
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
|
|
1313
|
+
MAX(timestamp) as last_active
|
|
1314
|
+
FROM requests
|
|
1315
|
+
WHERE ${requestWhere}${requestMachineClause}
|
|
1316
|
+
GROUP BY session_id
|
|
1317
|
+
`).all(...requestScopeParams, ...requestMachineParams);
|
|
1318
|
+
for (const row of requestRows) {
|
|
1319
|
+
const g = groupFor(row.session_id);
|
|
1320
|
+
if (!g)
|
|
1321
|
+
continue;
|
|
1322
|
+
g.sessions += 1;
|
|
1323
|
+
g.requests += row.requests;
|
|
1324
|
+
g.cost_usd += row.cost_usd;
|
|
1325
|
+
g.total_tokens += row.total_tokens;
|
|
1326
|
+
g.last_active = laterTimestamp(g.last_active, row.last_active);
|
|
1327
|
+
}
|
|
1328
|
+
const sessionsWithRequests = new Set(db.prepare(`SELECT DISTINCT session_id FROM requests`).all().map((row) => row.session_id));
|
|
1329
|
+
const sessionOnlyRows = db.prepare(`
|
|
1330
|
+
SELECT id,
|
|
1331
|
+
COALESCE(request_count, 0) as request_count,
|
|
1332
|
+
COALESCE(total_tokens, 0) as total_tokens,
|
|
1333
|
+
COALESCE(total_cost_usd, 0) as total_cost_usd,
|
|
1334
|
+
started_at
|
|
1335
|
+
FROM sessions
|
|
1336
|
+
WHERE ${sessionWhere}${sessionOnlyMachineClause}
|
|
1337
|
+
`).all(...sessionScopeParams, ...sessionOnlyMachineParams);
|
|
1338
|
+
for (const row of sessionOnlyRows) {
|
|
1339
|
+
if (sessionsWithRequests.has(row.id))
|
|
1340
|
+
continue;
|
|
1341
|
+
const g = groupFor(row.id);
|
|
1342
|
+
if (!g)
|
|
1343
|
+
continue;
|
|
1344
|
+
g.sessions += 1;
|
|
1345
|
+
g.requests += row.request_count;
|
|
1346
|
+
g.total_tokens += row.total_tokens;
|
|
1347
|
+
g.cost_usd += row.total_cost_usd;
|
|
1348
|
+
g.last_active = laterTimestamp(g.last_active, row.started_at);
|
|
1209
1349
|
}
|
|
1210
1350
|
const result = [];
|
|
1211
1351
|
for (const g of groups.values()) {
|
|
1212
|
-
|
|
1213
|
-
const reqStats = placeholders.length ? db.prepare(`
|
|
1214
|
-
SELECT
|
|
1215
|
-
COUNT(DISTINCT session_id) as sessions,
|
|
1216
|
-
COUNT(*) as requests,
|
|
1217
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd,
|
|
1218
|
-
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
|
|
1219
|
-
MAX(timestamp) as last_active
|
|
1220
|
-
FROM requests
|
|
1221
|
-
WHERE session_id IN (${placeholders})
|
|
1222
|
-
AND ${requestWhere}
|
|
1223
|
-
${requestMachineClause}
|
|
1224
|
-
`).get(...g.sessionIds, ...requestMachineParams) : { sessions: 0, requests: 0, cost_usd: 0, total_tokens: 0, last_active: null };
|
|
1225
|
-
const sessionOnlyStats = placeholders.length ? db.prepare(`
|
|
1226
|
-
SELECT
|
|
1227
|
-
COUNT(*) as sessions,
|
|
1228
|
-
COALESCE(SUM(request_count), 0) as requests,
|
|
1229
|
-
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
|
1230
|
-
COALESCE(SUM(total_cost_usd), 0) as cost_usd,
|
|
1231
|
-
MAX(started_at) as last_active
|
|
1232
|
-
FROM sessions
|
|
1233
|
-
WHERE id IN (${placeholders})
|
|
1234
|
-
AND ${sessionWhere}
|
|
1235
|
-
${sessionOnlyMachineClause}
|
|
1236
|
-
AND id NOT IN (SELECT DISTINCT session_id FROM requests)
|
|
1237
|
-
`).get(...g.sessionIds, ...sessionOnlyMachineParams) : { sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: null };
|
|
1238
|
-
const totalSessions = reqStats.sessions + sessionOnlyStats.sessions;
|
|
1239
|
-
if (totalSessions === 0)
|
|
1352
|
+
if (g.sessions === 0)
|
|
1240
1353
|
continue;
|
|
1241
|
-
const lastActive = [reqStats.last_active, sessionOnlyStats.last_active].filter(Boolean).sort().at(-1) ?? "";
|
|
1242
1354
|
result.push({
|
|
1243
1355
|
project_path: g.samplePath,
|
|
1244
1356
|
project_name: g.label,
|
|
1245
|
-
sessions:
|
|
1246
|
-
requests:
|
|
1247
|
-
total_tokens:
|
|
1248
|
-
cost_usd:
|
|
1249
|
-
last_active:
|
|
1357
|
+
sessions: g.sessions,
|
|
1358
|
+
requests: g.requests,
|
|
1359
|
+
total_tokens: g.total_tokens,
|
|
1360
|
+
cost_usd: g.cost_usd,
|
|
1361
|
+
last_active: g.last_active
|
|
1250
1362
|
});
|
|
1251
1363
|
}
|
|
1252
1364
|
result.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
1253
1365
|
return result;
|
|
1254
1366
|
}
|
|
1367
|
+
function queryProjectBreakdown(db, period = "all", machine) {
|
|
1368
|
+
return queryProjectBreakdownScoped(db, requestPeriodWhere(period), sessionPeriodWhere(period), [], [], machine);
|
|
1369
|
+
}
|
|
1255
1370
|
function normalizeAccountEmail(email) {
|
|
1256
1371
|
return (email ?? "").trim().toLowerCase();
|
|
1257
1372
|
}
|
|
@@ -1392,6 +1507,142 @@ function queryAccountBreakdown(db, period = "all", machine) {
|
|
|
1392
1507
|
result.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
1393
1508
|
return result;
|
|
1394
1509
|
}
|
|
1510
|
+
function kindFromCostCenterId(id) {
|
|
1511
|
+
const prefix = id.split(":", 1)[0] ?? "";
|
|
1512
|
+
return ["loop", "app", "repo", "service", "team"].includes(prefix) ? prefix : "";
|
|
1513
|
+
}
|
|
1514
|
+
function addCostCenterBreakdownRow(groups, row, sessionOnly) {
|
|
1515
|
+
if (!row.cost_center_id)
|
|
1516
|
+
return;
|
|
1517
|
+
const derivedKind = row.kind || kindFromCostCenterId(row.cost_center_id) || "service";
|
|
1518
|
+
const group = groups.get(row.cost_center_id) ?? {
|
|
1519
|
+
cost_center_id: row.cost_center_id,
|
|
1520
|
+
kind: derivedKind,
|
|
1521
|
+
name: row.name || row.cost_center_id,
|
|
1522
|
+
repo_path: row.repo_path ?? null,
|
|
1523
|
+
labels_json: row.labels_json || "{}",
|
|
1524
|
+
sessionIds: new Set,
|
|
1525
|
+
requests: 0,
|
|
1526
|
+
total_tokens: 0,
|
|
1527
|
+
api_equivalent_usd: 0,
|
|
1528
|
+
metered_api_usd: 0,
|
|
1529
|
+
subscription_included_usd: 0,
|
|
1530
|
+
estimated_usd: 0,
|
|
1531
|
+
unknown_usd: 0,
|
|
1532
|
+
last_active: ""
|
|
1533
|
+
};
|
|
1534
|
+
group.kind = derivedKind;
|
|
1535
|
+
if (row.name)
|
|
1536
|
+
group.name = row.name;
|
|
1537
|
+
if (row.repo_path && !group.repo_path)
|
|
1538
|
+
group.repo_path = row.repo_path;
|
|
1539
|
+
if (row.labels_json && row.labels_json !== "{}")
|
|
1540
|
+
group.labels_json = row.labels_json;
|
|
1541
|
+
if (row.session_id)
|
|
1542
|
+
group.sessionIds.add(row.session_id);
|
|
1543
|
+
group.requests += row.requests;
|
|
1544
|
+
group.total_tokens += row.total_tokens;
|
|
1545
|
+
group.api_equivalent_usd += row.cost_usd;
|
|
1546
|
+
if (sessionOnly) {
|
|
1547
|
+
group.estimated_usd += row.cost_usd;
|
|
1548
|
+
} else if (row.cost_basis === "metered_api") {
|
|
1549
|
+
group.metered_api_usd += row.cost_usd;
|
|
1550
|
+
} else if (row.cost_basis === "subscription_included") {
|
|
1551
|
+
group.subscription_included_usd += row.cost_usd;
|
|
1552
|
+
} else if (row.cost_basis === "unknown") {
|
|
1553
|
+
group.unknown_usd += row.cost_usd;
|
|
1554
|
+
} else {
|
|
1555
|
+
group.estimated_usd += row.cost_usd;
|
|
1556
|
+
}
|
|
1557
|
+
if (!group.last_active || row.last_active > group.last_active)
|
|
1558
|
+
group.last_active = row.last_active;
|
|
1559
|
+
groups.set(row.cost_center_id, group);
|
|
1560
|
+
}
|
|
1561
|
+
function queryCostCenterBreakdown(db, period = "all", filter = {}) {
|
|
1562
|
+
const requestWhere = filter.since ? "r.timestamp >= ?" : requestPeriodWhere(period);
|
|
1563
|
+
const sessionWhere = filter.since ? "s.started_at >= ?" : sessionPeriodWhere(period);
|
|
1564
|
+
const requestMachineClause = filter.machine ? " AND r.machine_id = ?" : "";
|
|
1565
|
+
const sessionMachineClause = filter.machine ? " AND s.machine_id = ?" : "";
|
|
1566
|
+
const requestParams = [];
|
|
1567
|
+
const sessionParams = [];
|
|
1568
|
+
if (filter.since) {
|
|
1569
|
+
requestParams.push(filter.since);
|
|
1570
|
+
sessionParams.push(filter.since);
|
|
1571
|
+
}
|
|
1572
|
+
if (filter.machine) {
|
|
1573
|
+
requestParams.push(filter.machine);
|
|
1574
|
+
sessionParams.push(filter.machine);
|
|
1575
|
+
}
|
|
1576
|
+
const groups = new Map;
|
|
1577
|
+
const requestRows = db.prepare(`
|
|
1578
|
+
SELECT
|
|
1579
|
+
COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) as cost_center_id,
|
|
1580
|
+
COALESCE(cc.kind, '') as kind,
|
|
1581
|
+
COALESCE(cc.name, COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))) as name,
|
|
1582
|
+
cc.repo_path as repo_path,
|
|
1583
|
+
COALESCE(cc.labels_json, '{}') as labels_json,
|
|
1584
|
+
r.session_id as session_id,
|
|
1585
|
+
1 as requests,
|
|
1586
|
+
COALESCE(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens, 0) as total_tokens,
|
|
1587
|
+
COALESCE(r.cost_usd, 0) as cost_usd,
|
|
1588
|
+
COALESCE(NULLIF(r.cost_basis, ''), 'estimated') as cost_basis,
|
|
1589
|
+
r.timestamp as last_active
|
|
1590
|
+
FROM requests r
|
|
1591
|
+
LEFT JOIN sessions s ON s.id = r.session_id
|
|
1592
|
+
LEFT JOIN cost_centers cc ON cc.id = COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))
|
|
1593
|
+
WHERE ${requestWhere}${requestMachineClause}
|
|
1594
|
+
AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) IS NOT NULL
|
|
1595
|
+
AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) != ''
|
|
1596
|
+
`).all(...requestParams);
|
|
1597
|
+
for (const row of requestRows)
|
|
1598
|
+
addCostCenterBreakdownRow(groups, row, false);
|
|
1599
|
+
const sessionOnlyRows = db.prepare(`
|
|
1600
|
+
SELECT
|
|
1601
|
+
s.cost_center_id as cost_center_id,
|
|
1602
|
+
COALESCE(cc.kind, '') as kind,
|
|
1603
|
+
COALESCE(cc.name, s.cost_center_id) as name,
|
|
1604
|
+
cc.repo_path as repo_path,
|
|
1605
|
+
COALESCE(cc.labels_json, '{}') as labels_json,
|
|
1606
|
+
s.id as session_id,
|
|
1607
|
+
COALESCE(s.request_count, 0) as requests,
|
|
1608
|
+
COALESCE(s.total_tokens, 0) as total_tokens,
|
|
1609
|
+
COALESCE(s.total_cost_usd, 0) as cost_usd,
|
|
1610
|
+
'estimated' as cost_basis,
|
|
1611
|
+
s.started_at as last_active
|
|
1612
|
+
FROM sessions s
|
|
1613
|
+
LEFT JOIN cost_centers cc ON cc.id = s.cost_center_id
|
|
1614
|
+
WHERE ${sessionWhere}${sessionMachineClause}
|
|
1615
|
+
AND s.id NOT IN (SELECT DISTINCT session_id FROM requests)
|
|
1616
|
+
AND s.cost_center_id IS NOT NULL
|
|
1617
|
+
AND s.cost_center_id != ''
|
|
1618
|
+
`).all(...sessionParams);
|
|
1619
|
+
for (const row of sessionOnlyRows)
|
|
1620
|
+
addCostCenterBreakdownRow(groups, row, true);
|
|
1621
|
+
const result = [...groups.values()].map((group) => ({
|
|
1622
|
+
cost_center_id: group.cost_center_id,
|
|
1623
|
+
kind: group.kind,
|
|
1624
|
+
name: group.name,
|
|
1625
|
+
repo_path: group.repo_path,
|
|
1626
|
+
labels_json: group.labels_json,
|
|
1627
|
+
sessions: group.sessionIds.size,
|
|
1628
|
+
requests: group.requests,
|
|
1629
|
+
total_tokens: group.total_tokens,
|
|
1630
|
+
api_equivalent_usd: group.api_equivalent_usd,
|
|
1631
|
+
billable_usd: group.metered_api_usd,
|
|
1632
|
+
metered_api_usd: group.metered_api_usd,
|
|
1633
|
+
subscription_included_usd: group.subscription_included_usd,
|
|
1634
|
+
estimated_usd: group.estimated_usd,
|
|
1635
|
+
unknown_usd: group.unknown_usd,
|
|
1636
|
+
cost_usd: group.api_equivalent_usd,
|
|
1637
|
+
last_active: group.last_active
|
|
1638
|
+
}));
|
|
1639
|
+
const filtered = filter.kind ? result.filter((row) => row.kind === filter.kind) : result;
|
|
1640
|
+
filtered.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
1641
|
+
return filtered;
|
|
1642
|
+
}
|
|
1643
|
+
function queryProjectBreakdownSince(db, since, machine) {
|
|
1644
|
+
return queryProjectBreakdownScoped(db, "timestamp >= ?", "started_at >= ?", [since], [since], machine);
|
|
1645
|
+
}
|
|
1395
1646
|
function queryDailyBreakdown(db, days = 30, machine) {
|
|
1396
1647
|
const machineClause = machine ? " AND machine_id = ?" : "";
|
|
1397
1648
|
const params = machine ? [`-${days}`, machine] : [`-${days}`];
|
|
@@ -1433,9 +1684,9 @@ function deleteProject(db, path) {
|
|
|
1433
1684
|
function upsertBudget(db, budget) {
|
|
1434
1685
|
db.prepare(`
|
|
1435
1686
|
INSERT OR REPLACE INTO budgets
|
|
1436
|
-
(id, project_path, agent, period, limit_usd, alert_at_percent, created_at, updated_at)
|
|
1437
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1438
|
-
`).run(budget.id, budget.project_path ?? null, budget.agent ?? null, budget.period, budget.limit_usd, budget.alert_at_percent, budget.created_at, budget.updated_at);
|
|
1687
|
+
(id, project_path, agent, cost_center_id, period, limit_usd, alert_at_percent, created_at, updated_at)
|
|
1688
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1689
|
+
`).run(budget.id, budget.project_path ?? null, budget.agent ?? null, budget.cost_center_id ?? null, budget.period, budget.limit_usd, budget.alert_at_percent, budget.created_at, budget.updated_at);
|
|
1439
1690
|
}
|
|
1440
1691
|
function listBudgets(db) {
|
|
1441
1692
|
return db.prepare(`SELECT * FROM budgets ORDER BY created_at DESC`).all();
|
|
@@ -1457,6 +1708,10 @@ function getBudgetStatuses(db) {
|
|
|
1457
1708
|
spendQuery += ` AND agent = ?`;
|
|
1458
1709
|
params.push(b.agent);
|
|
1459
1710
|
}
|
|
1711
|
+
if (b.cost_center_id) {
|
|
1712
|
+
spendQuery += ` AND COALESCE(NULLIF(cost_center_id, ''), (SELECT NULLIF(s.cost_center_id, '') FROM sessions s WHERE s.id = requests.session_id)) = ?`;
|
|
1713
|
+
params.push(b.cost_center_id);
|
|
1714
|
+
}
|
|
1460
1715
|
const row = db.prepare(spendQuery).get(...params);
|
|
1461
1716
|
const spend = row.spend;
|
|
1462
1717
|
const percent = b.limit_usd > 0 ? spend / b.limit_usd * 100 : 0;
|
|
@@ -1947,14 +2202,52 @@ var numOr = (v, d = 0) => {
|
|
|
1947
2202
|
const num = Number(v);
|
|
1948
2203
|
return Number.isFinite(num) ? num : d;
|
|
1949
2204
|
}, strOr = (v, d = "") => v == null ? d : String(v);
|
|
1950
|
-
var init_database = () => {
|
|
2205
|
+
var init_database = __esm(() => {
|
|
2206
|
+
init_sqlite_adapter();
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
// src/db/dialect.ts
|
|
2210
|
+
function translateSql(sql, dialect) {
|
|
2211
|
+
if (dialect === "sqlite")
|
|
2212
|
+
return sql;
|
|
2213
|
+
return sqliteToPostgres(sql);
|
|
2214
|
+
}
|
|
2215
|
+
function translateParams(params) {
|
|
2216
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
2217
|
+
return flat.map((p) => p === undefined ? null : p);
|
|
2218
|
+
}
|
|
2219
|
+
function sqliteToPostgres(sql) {
|
|
2220
|
+
let out = sql;
|
|
2221
|
+
let paramIdx = 0;
|
|
2222
|
+
out = out.replace(/\?/g, () => `$${++paramIdx}`);
|
|
2223
|
+
out = out.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
2224
|
+
out = out.replace(/datetime\s*\(\s*'now'\s*,\s*'(-?\d+)\s+(minutes?|hours?|days?|seconds?)'\s*\)/gi, (_match, amount, unit) => {
|
|
2225
|
+
const n = parseInt(amount, 10);
|
|
2226
|
+
const absN = Math.abs(n);
|
|
2227
|
+
const normalizedUnit = unit.toLowerCase().replace(/s$/, "");
|
|
2228
|
+
const plural = absN === 1 ? normalizedUnit : `${normalizedUnit}s`;
|
|
2229
|
+
return n < 0 ? `NOW() - INTERVAL '${absN} ${plural}'` : `NOW() + INTERVAL '${absN} ${plural}'`;
|
|
2230
|
+
});
|
|
2231
|
+
out = out.replace(/lower\s*\(\s*hex\s*\(\s*randomblob\s*\(\s*\d+\s*\)\s*\)\s*\)/gi, "gen_random_uuid()::text");
|
|
2232
|
+
out = out.replace(/GROUP_CONCAT\s*\(/gi, "STRING_AGG(");
|
|
2233
|
+
out = out.replace(/json_extract\s*\(\s*(\w+)\s*,\s*'\$\.(\w+)'\s*\)/gi, (_match, col, key) => `${col}->>'${key}'`);
|
|
2234
|
+
out = out.replace(/\bAUTOINCREMENT\b/gi, "GENERATED ALWAYS AS IDENTITY");
|
|
2235
|
+
out = out.replace(/(?<![I])LIKE/gi, "ILIKE");
|
|
2236
|
+
out = out.replace(/\bIFNULL\s*\(/gi, "COALESCE(");
|
|
2237
|
+
out = out.replace(/INSERT\s+OR\s+REPLACE\s+INTO\s+"?(\w+)"?\s*\(([^)]+)\)\s*VALUES/gi, (_match, table, colList) => `INSERT INTO "${table}" (${colList}) VALUES`);
|
|
2238
|
+
out = out.replace(/INSERT\s+OR\s+REPLACE\s+INTO/gi, "INSERT INTO");
|
|
2239
|
+
if (/INSERT\s+OR\s+IGNORE\s+INTO/i.test(out)) {
|
|
2240
|
+
out = out.replace(/INSERT\s+OR\s+IGNORE\s+INTO/gi, "INSERT INTO");
|
|
2241
|
+
out = out.replace(/;?\s*$/, " ON CONFLICT DO NOTHING");
|
|
2242
|
+
}
|
|
2243
|
+
return out;
|
|
2244
|
+
}
|
|
1951
2245
|
|
|
1952
2246
|
// src/db/sync-pg.ts
|
|
1953
2247
|
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
|
|
1954
2248
|
import { fileURLToPath } from "url";
|
|
1955
|
-
import { dirname, join as
|
|
1956
|
-
import { existsSync as
|
|
1957
|
-
import { translateSql, translateParams } from "@hasna/cloud";
|
|
2249
|
+
import { dirname, join as join10 } from "path";
|
|
2250
|
+
import { existsSync as existsSync11 } from "fs";
|
|
1958
2251
|
function translateSqliteDates(sql) {
|
|
1959
2252
|
let s = sql;
|
|
1960
2253
|
const isoDate = (expr) => `to_char(${expr}, 'YYYY-MM-DD')`;
|
|
@@ -1979,12 +2272,12 @@ function translateSqliteDates(sql) {
|
|
|
1979
2272
|
function resolveWorkerPath() {
|
|
1980
2273
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1981
2274
|
const candidates = [
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2275
|
+
join10(here, "pg-sync-worker.ts"),
|
|
2276
|
+
join10(here, "pg-sync-worker.js"),
|
|
2277
|
+
join10(here, "..", "db", "pg-sync-worker.js")
|
|
1985
2278
|
];
|
|
1986
2279
|
for (const candidate of candidates)
|
|
1987
|
-
if (
|
|
2280
|
+
if (existsSync11(candidate))
|
|
1988
2281
|
return candidate;
|
|
1989
2282
|
return candidates[0];
|
|
1990
2283
|
}
|
|
@@ -2076,10 +2369,67 @@ class SyncPgAdapter {
|
|
|
2076
2369
|
}
|
|
2077
2370
|
var init_sync_pg = () => {};
|
|
2078
2371
|
|
|
2372
|
+
// src/db/pg-migrate.ts
|
|
2373
|
+
var exports_pg_migrate = {};
|
|
2374
|
+
__export(exports_pg_migrate, {
|
|
2375
|
+
resolvePgSsl: () => resolvePgSsl,
|
|
2376
|
+
applyPgMigrations: () => applyPgMigrations
|
|
2377
|
+
});
|
|
2378
|
+
import pg from "pg";
|
|
2379
|
+
function resolvePgSsl(dsn) {
|
|
2380
|
+
return dsn.includes("sslmode=require") || dsn.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
2381
|
+
}
|
|
2382
|
+
async function applyPgMigrations(connectionString, migrations, service = "unknown") {
|
|
2383
|
+
const pool = new pg.Pool({ connectionString, ssl: resolvePgSsl(connectionString) });
|
|
2384
|
+
const result = {
|
|
2385
|
+
service,
|
|
2386
|
+
applied: [],
|
|
2387
|
+
alreadyApplied: [],
|
|
2388
|
+
errors: [],
|
|
2389
|
+
totalMigrations: migrations.length
|
|
2390
|
+
};
|
|
2391
|
+
try {
|
|
2392
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS _pg_migrations (
|
|
2393
|
+
id SERIAL PRIMARY KEY,
|
|
2394
|
+
version INT UNIQUE NOT NULL,
|
|
2395
|
+
applied_at TIMESTAMPTZ DEFAULT NOW()
|
|
2396
|
+
)`);
|
|
2397
|
+
const existing = await pool.query("SELECT version FROM _pg_migrations ORDER BY version");
|
|
2398
|
+
const appliedSet = new Set(existing.rows.map((row) => Number(row["version"])));
|
|
2399
|
+
for (let i = 0;i < migrations.length; i++) {
|
|
2400
|
+
if (appliedSet.has(i)) {
|
|
2401
|
+
result.alreadyApplied.push(i);
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
try {
|
|
2405
|
+
await pool.query(migrations[i]);
|
|
2406
|
+
await pool.query("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", [i]);
|
|
2407
|
+
result.applied.push(i);
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
result.errors.push(`Migration ${i}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
} finally {
|
|
2414
|
+
await pool.end().catch(() => {});
|
|
2415
|
+
}
|
|
2416
|
+
return result;
|
|
2417
|
+
}
|
|
2418
|
+
var init_pg_migrate = () => {};
|
|
2419
|
+
|
|
2079
2420
|
// src/db/pg-migrations.ts
|
|
2080
2421
|
var PG_MIGRATIONS;
|
|
2081
2422
|
var init_pg_migrations = __esm(() => {
|
|
2082
2423
|
PG_MIGRATIONS = [
|
|
2424
|
+
`CREATE TABLE IF NOT EXISTS cost_centers (
|
|
2425
|
+
id TEXT PRIMARY KEY,
|
|
2426
|
+
kind TEXT NOT NULL,
|
|
2427
|
+
name TEXT NOT NULL,
|
|
2428
|
+
repo_path TEXT,
|
|
2429
|
+
labels_json TEXT DEFAULT '{}',
|
|
2430
|
+
created_at TEXT NOT NULL,
|
|
2431
|
+
updated_at TEXT NOT NULL
|
|
2432
|
+
)`,
|
|
2083
2433
|
`CREATE TABLE IF NOT EXISTS requests (
|
|
2084
2434
|
id TEXT PRIMARY KEY,
|
|
2085
2435
|
agent TEXT NOT NULL,
|
|
@@ -2096,6 +2446,7 @@ var init_pg_migrations = __esm(() => {
|
|
|
2096
2446
|
timestamp TEXT NOT NULL,
|
|
2097
2447
|
source_request_id TEXT,
|
|
2098
2448
|
machine_id TEXT DEFAULT '',
|
|
2449
|
+
cost_center_id TEXT,
|
|
2099
2450
|
account_key TEXT DEFAULT '',
|
|
2100
2451
|
account_tool TEXT DEFAULT '',
|
|
2101
2452
|
account_name TEXT DEFAULT '',
|
|
@@ -2113,6 +2464,7 @@ var init_pg_migrations = __esm(() => {
|
|
|
2113
2464
|
total_tokens INTEGER DEFAULT 0,
|
|
2114
2465
|
request_count INTEGER DEFAULT 0,
|
|
2115
2466
|
machine_id TEXT DEFAULT '',
|
|
2467
|
+
cost_center_id TEXT,
|
|
2116
2468
|
account_key TEXT DEFAULT '',
|
|
2117
2469
|
account_tool TEXT DEFAULT '',
|
|
2118
2470
|
account_name TEXT DEFAULT '',
|
|
@@ -2131,6 +2483,7 @@ var init_pg_migrations = __esm(() => {
|
|
|
2131
2483
|
id TEXT PRIMARY KEY,
|
|
2132
2484
|
project_path TEXT,
|
|
2133
2485
|
agent TEXT,
|
|
2486
|
+
cost_center_id TEXT,
|
|
2134
2487
|
period TEXT NOT NULL,
|
|
2135
2488
|
limit_usd REAL NOT NULL,
|
|
2136
2489
|
alert_at_percent INTEGER DEFAULT 80,
|
|
@@ -2160,6 +2513,7 @@ var init_pg_migrations = __esm(() => {
|
|
|
2160
2513
|
`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
|
|
2161
2514
|
`CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
|
|
2162
2515
|
`CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
|
|
2516
|
+
`CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind)`,
|
|
2163
2517
|
`CREATE TABLE IF NOT EXISTS model_pricing (
|
|
2164
2518
|
model TEXT PRIMARY KEY,
|
|
2165
2519
|
input_per_1m REAL NOT NULL DEFAULT 0,
|
|
@@ -2245,6 +2599,7 @@ var init_pg_migrations = __esm(() => {
|
|
|
2245
2599
|
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
|
|
2246
2600
|
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
2247
2601
|
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
2602
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
|
|
2248
2603
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
|
|
2249
2604
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
|
|
2250
2605
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
|
|
@@ -2253,10 +2608,16 @@ var init_pg_migrations = __esm(() => {
|
|
|
2253
2608
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
|
|
2254
2609
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
2255
2610
|
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
2611
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
|
|
2612
|
+
`ALTER TABLE budgets ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
|
|
2613
|
+
`ALTER TABLE cost_centers ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
2256
2614
|
`CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
|
|
2257
2615
|
`CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
|
|
2258
2616
|
`CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
|
|
2259
2617
|
`CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`,
|
|
2618
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id)`,
|
|
2619
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id)`,
|
|
2620
|
+
`CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id)`,
|
|
2260
2621
|
`ALTER TABLE requests ALTER COLUMN input_tokens TYPE BIGINT`,
|
|
2261
2622
|
`ALTER TABLE requests ALTER COLUMN output_tokens TYPE BIGINT`,
|
|
2262
2623
|
`ALTER TABLE requests ALTER COLUMN cache_read_tokens TYPE BIGINT`,
|
|
@@ -2299,7 +2660,7 @@ __export(exports_cloud, {
|
|
|
2299
2660
|
cloudMigrations: () => cloudMigrations,
|
|
2300
2661
|
authClientFromPool: () => authClientFromPool
|
|
2301
2662
|
});
|
|
2302
|
-
import
|
|
2663
|
+
import pg2 from "pg";
|
|
2303
2664
|
function getCloudDatabaseUrl() {
|
|
2304
2665
|
return process.env["HASNA_ECONOMY_DATABASE_URL"]?.trim() || process.env["ECONOMY_DATABASE_URL"]?.trim() || process.env["DATABASE_URL"]?.trim() || undefined;
|
|
2305
2666
|
}
|
|
@@ -2323,8 +2684,7 @@ function openCloudDatabase(dsn = getCloudDatabaseUrl()) {
|
|
|
2323
2684
|
function createCloudPool(dsn = getCloudDatabaseUrl()) {
|
|
2324
2685
|
if (!dsn)
|
|
2325
2686
|
throw new Error("economy cloud mode requires a Postgres DSN (HASNA_ECONOMY_DATABASE_URL)");
|
|
2326
|
-
const
|
|
2327
|
-
const pool = new pg.Pool({ connectionString: dsn, ssl, max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
|
|
2687
|
+
const pool = new pg2.Pool({ connectionString: dsn, ssl: resolvePgSsl(dsn), max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
|
|
2328
2688
|
pool.on("error", (err) => {
|
|
2329
2689
|
console.error(JSON.stringify({ evt: "pg_pool_error", message: err.message }));
|
|
2330
2690
|
});
|
|
@@ -2350,6 +2710,7 @@ function cloudMigrations() {
|
|
|
2350
2710
|
}
|
|
2351
2711
|
var init_cloud = __esm(() => {
|
|
2352
2712
|
init_sync_pg();
|
|
2713
|
+
init_pg_migrate();
|
|
2353
2714
|
init_pg_migrations();
|
|
2354
2715
|
});
|
|
2355
2716
|
|
|
@@ -2628,13 +2989,14 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
|
2628
2989
|
let imported = 0;
|
|
2629
2990
|
let skipped = 0;
|
|
2630
2991
|
for (const project of projects) {
|
|
2631
|
-
|
|
2992
|
+
const path = resolveProjectPath(project);
|
|
2993
|
+
if (!path) {
|
|
2632
2994
|
skipped++;
|
|
2633
2995
|
continue;
|
|
2634
2996
|
}
|
|
2635
2997
|
upsertProject(db, {
|
|
2636
2998
|
id: project.id,
|
|
2637
|
-
path
|
|
2999
|
+
path,
|
|
2638
3000
|
name: project.name,
|
|
2639
3001
|
description: project.description,
|
|
2640
3002
|
tags: project.tags ?? [],
|
|
@@ -2644,20 +3006,21 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
|
2644
3006
|
}
|
|
2645
3007
|
return { imported, skipped };
|
|
2646
3008
|
}
|
|
3009
|
+
var resolveProjectPath = (project) => project.primary_path || project.path || "";
|
|
2647
3010
|
var init_open_projects = __esm(() => {
|
|
2648
3011
|
init_database();
|
|
2649
3012
|
});
|
|
2650
3013
|
|
|
2651
3014
|
// src/lib/config.ts
|
|
2652
|
-
import { existsSync as
|
|
2653
|
-
import { dirname as dirname2, join as
|
|
3015
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
3016
|
+
import { dirname as dirname2, join as join11 } from "path";
|
|
2654
3017
|
function getConfigPath() {
|
|
2655
|
-
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ??
|
|
3018
|
+
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join11(getDataDir(), "config.json");
|
|
2656
3019
|
}
|
|
2657
3020
|
function loadConfig() {
|
|
2658
3021
|
try {
|
|
2659
3022
|
const configPath = getConfigPath();
|
|
2660
|
-
if (
|
|
3023
|
+
if (existsSync12(configPath)) {
|
|
2661
3024
|
const raw = readFileSync10(configPath, "utf-8");
|
|
2662
3025
|
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
2663
3026
|
}
|
|
@@ -2737,6 +3100,8 @@ async function checkAndFireWebhooks(db) {
|
|
|
2737
3100
|
event: "budget_alert",
|
|
2738
3101
|
budget_id: b.id,
|
|
2739
3102
|
project: b.project_path ?? "global",
|
|
3103
|
+
cost_center_id: b.cost_center_id ?? null,
|
|
3104
|
+
scope: b.cost_center_id ?? b.project_path ?? "global",
|
|
2740
3105
|
period: b.period,
|
|
2741
3106
|
spend: b.current_spend_usd,
|
|
2742
3107
|
limit: b.limit_usd,
|
|
@@ -2960,6 +3325,7 @@ function defaultCostBasisForAgent(agent) {
|
|
|
2960
3325
|
}
|
|
2961
3326
|
|
|
2962
3327
|
// src/lib/accounts.ts
|
|
3328
|
+
import { appliedProfileName, resolveStore } from "@hasna/accounts";
|
|
2963
3329
|
var AGENT_ACCOUNT_TOOLS = {
|
|
2964
3330
|
claude: ["claude"],
|
|
2965
3331
|
takumi: ["takumi", "claude"],
|
|
@@ -3024,57 +3390,35 @@ function envOverride(agent, env) {
|
|
|
3024
3390
|
account_source: "override"
|
|
3025
3391
|
};
|
|
3026
3392
|
}
|
|
3027
|
-
function
|
|
3028
|
-
try {
|
|
3029
|
-
return new Set(api.listTools().map((tool) => tool.id));
|
|
3030
|
-
} catch {
|
|
3031
|
-
return new Set;
|
|
3032
|
-
}
|
|
3033
|
-
}
|
|
3034
|
-
function profileForEnvDir(api, tool, env) {
|
|
3393
|
+
async function profileForEnvDir(store, tool, env) {
|
|
3035
3394
|
const configuredDir = env[tool.envVar];
|
|
3036
3395
|
if (!configuredDir)
|
|
3037
3396
|
return null;
|
|
3038
3397
|
const normalized = normalizeDir(configuredDir);
|
|
3039
|
-
|
|
3040
|
-
return api.listProfiles(tool.id).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
|
|
3041
|
-
} catch {
|
|
3042
|
-
return null;
|
|
3043
|
-
}
|
|
3398
|
+
return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
|
|
3044
3399
|
}
|
|
3045
3400
|
async function resolveAccountForAgent(agent, env = process.env) {
|
|
3046
3401
|
const override = envOverride(agent, env);
|
|
3047
3402
|
if (override)
|
|
3048
3403
|
return override;
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
api = await import("@hasna/accounts");
|
|
3052
|
-
} catch {
|
|
3053
|
-
return null;
|
|
3054
|
-
}
|
|
3055
|
-
const toolIds = knownToolIds(api);
|
|
3404
|
+
const store = resolveStore(env);
|
|
3405
|
+
const tools = new Map((await store.listTools()).map((tool) => [tool.id, tool]));
|
|
3056
3406
|
for (const toolId of AGENT_ACCOUNT_TOOLS[agent]) {
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
let tool;
|
|
3060
|
-
try {
|
|
3061
|
-
tool = api.getTool(toolId);
|
|
3062
|
-
} catch {
|
|
3407
|
+
const tool = tools.get(toolId);
|
|
3408
|
+
if (!tool)
|
|
3063
3409
|
continue;
|
|
3064
|
-
|
|
3065
|
-
const envProfile = profileForEnvDir(api, tool, env);
|
|
3410
|
+
const envProfile = await profileForEnvDir(store, tool, env);
|
|
3066
3411
|
if (envProfile)
|
|
3067
3412
|
return fromProfile(envProfile, "env");
|
|
3068
|
-
|
|
3069
|
-
|
|
3413
|
+
const appliedName = appliedProfileName(toolId);
|
|
3414
|
+
if (appliedName) {
|
|
3415
|
+
const applied = await store.findProfile(appliedName, toolId);
|
|
3070
3416
|
if (applied)
|
|
3071
3417
|
return fromProfile(applied, "applied");
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
return fromProfile(current, "current");
|
|
3077
|
-
} catch {}
|
|
3418
|
+
}
|
|
3419
|
+
const current = await store.currentProfile(toolId);
|
|
3420
|
+
if (current)
|
|
3421
|
+
return fromProfile(current, "current");
|
|
3078
3422
|
}
|
|
3079
3423
|
return null;
|
|
3080
3424
|
}
|
|
@@ -4095,32 +4439,219 @@ function statSyncSafe(path) {
|
|
|
4095
4439
|
}
|
|
4096
4440
|
}
|
|
4097
4441
|
|
|
4442
|
+
// src/ingest/loops.ts
|
|
4443
|
+
init_database();
|
|
4444
|
+
init_pricing();
|
|
4445
|
+
import { existsSync as existsSync8 } from "fs";
|
|
4446
|
+
import { homedir as homedir8 } from "os";
|
|
4447
|
+
import { join as join8 } from "path";
|
|
4448
|
+
import { Database as BunDatabase2 } from "bun:sqlite";
|
|
4449
|
+
var DEFAULT_LOOPS_DB_PATH = join8(homedir8(), ".hasna", "loops", "loops.db");
|
|
4450
|
+
var LOOPS_INGEST_VERSION = "goal-runs-orchestration-v1";
|
|
4451
|
+
function loopsDbPath() {
|
|
4452
|
+
return process.env["HASNA_ECONOMY_LOOPS_DB_PATH"] ?? DEFAULT_LOOPS_DB_PATH;
|
|
4453
|
+
}
|
|
4454
|
+
function loopsModel() {
|
|
4455
|
+
return process.env["HASNA_ECONOMY_LOOPS_MODEL"] ?? process.env["ECONOMY_LOOPS_MODEL"] ?? "gpt-5.3-codex";
|
|
4456
|
+
}
|
|
4457
|
+
function openLoopsDb(dbPath, verbose) {
|
|
4458
|
+
let lastError;
|
|
4459
|
+
for (const readonly of [true, false]) {
|
|
4460
|
+
let loopsDb = null;
|
|
4461
|
+
try {
|
|
4462
|
+
loopsDb = readonly ? new BunDatabase2(dbPath, { readonly: true }) : new BunDatabase2(dbPath);
|
|
4463
|
+
loopsDb.prepare("PRAGMA schema_version").get();
|
|
4464
|
+
return loopsDb;
|
|
4465
|
+
} catch (error) {
|
|
4466
|
+
lastError = error;
|
|
4467
|
+
loopsDb?.close();
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
if (verbose) {
|
|
4471
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
4472
|
+
console.log("OpenLoops DB unreadable:", dbPath, message);
|
|
4473
|
+
}
|
|
4474
|
+
return null;
|
|
4475
|
+
}
|
|
4476
|
+
function slug(value) {
|
|
4477
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
4478
|
+
}
|
|
4479
|
+
function parseTarget(raw) {
|
|
4480
|
+
if (!raw)
|
|
4481
|
+
return {};
|
|
4482
|
+
try {
|
|
4483
|
+
const parsed = JSON.parse(raw);
|
|
4484
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
4485
|
+
} catch {
|
|
4486
|
+
return {};
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
function stringValue(value) {
|
|
4490
|
+
return typeof value === "string" ? value.trim() : "";
|
|
4491
|
+
}
|
|
4492
|
+
function accountFields(row) {
|
|
4493
|
+
const target = parseTarget(row.target_json);
|
|
4494
|
+
const tool = stringValue(row.account_tool) || stringValue(target["account_tool"]) || stringValue(target["tool"]) || stringValue(target["agent"]);
|
|
4495
|
+
const profile = stringValue(row.account_profile) || stringValue(target["account_profile"]) || stringValue(target["profile"]) || stringValue(target["account"]);
|
|
4496
|
+
if (!tool || !profile) {
|
|
4497
|
+
return { account_key: "", account_tool: tool, account_name: profile, account_source: "" };
|
|
4498
|
+
}
|
|
4499
|
+
return {
|
|
4500
|
+
account_key: `${tool}:${profile}`,
|
|
4501
|
+
account_tool: tool,
|
|
4502
|
+
account_name: profile,
|
|
4503
|
+
account_source: "loops"
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4506
|
+
function readLoopGoalRuns(loopsDb) {
|
|
4507
|
+
return loopsDb.prepare(`
|
|
4508
|
+
SELECT
|
|
4509
|
+
gr.id as goal_run_id,
|
|
4510
|
+
gr.goal_id as goal_id,
|
|
4511
|
+
COALESCE(gr.loop_id, g.loop_id, lr.loop_id) as loop_id,
|
|
4512
|
+
COALESCE(gr.loop_run_id, g.loop_run_id, lr.id) as loop_run_id,
|
|
4513
|
+
COALESCE(l.name, lr.loop_name, 'unknown-loop') as loop_name,
|
|
4514
|
+
l.labels_json as labels_json,
|
|
4515
|
+
l.target_json as target_json,
|
|
4516
|
+
g.objective as objective,
|
|
4517
|
+
gr.phase as phase,
|
|
4518
|
+
gr.status as status,
|
|
4519
|
+
COALESCE(gr.tokens_used, 0) as tokens_used,
|
|
4520
|
+
gr.created_at as created_at,
|
|
4521
|
+
gr.updated_at as updated_at,
|
|
4522
|
+
lr.started_at as loop_run_started_at,
|
|
4523
|
+
wsr.account_profile as account_profile,
|
|
4524
|
+
wsr.account_tool as account_tool
|
|
4525
|
+
FROM goal_runs gr
|
|
4526
|
+
LEFT JOIN goals g ON g.id = gr.goal_id
|
|
4527
|
+
LEFT JOIN loop_runs lr ON lr.id = COALESCE(gr.loop_run_id, g.loop_run_id)
|
|
4528
|
+
LEFT JOIN loops l ON l.id = COALESCE(gr.loop_id, g.loop_id, lr.loop_id)
|
|
4529
|
+
LEFT JOIN (
|
|
4530
|
+
SELECT goal_run_id, MIN(account_profile) as account_profile, MIN(account_tool) as account_tool
|
|
4531
|
+
FROM workflow_step_runs
|
|
4532
|
+
WHERE goal_run_id IS NOT NULL
|
|
4533
|
+
GROUP BY goal_run_id
|
|
4534
|
+
) wsr ON wsr.goal_run_id = gr.id
|
|
4535
|
+
WHERE COALESCE(gr.tokens_used, 0) > 0
|
|
4536
|
+
ORDER BY gr.created_at ASC, gr.id ASC
|
|
4537
|
+
`).all();
|
|
4538
|
+
}
|
|
4539
|
+
async function ingestLoops(db, verbose = false) {
|
|
4540
|
+
const dbPath = loopsDbPath();
|
|
4541
|
+
if (!existsSync8(dbPath)) {
|
|
4542
|
+
if (verbose)
|
|
4543
|
+
console.log("OpenLoops DB not found:", dbPath);
|
|
4544
|
+
return { sessions: 0, requests: 0 };
|
|
4545
|
+
}
|
|
4546
|
+
const model = loopsModel();
|
|
4547
|
+
const machineId = getMachineId();
|
|
4548
|
+
let loopsDb = null;
|
|
4549
|
+
let requests = 0;
|
|
4550
|
+
const sessions = new Set;
|
|
4551
|
+
try {
|
|
4552
|
+
loopsDb = openLoopsDb(dbPath, verbose);
|
|
4553
|
+
if (!loopsDb)
|
|
4554
|
+
return { sessions: 0, requests: 0 };
|
|
4555
|
+
for (const row of readLoopGoalRuns(loopsDb)) {
|
|
4556
|
+
const loopName = row.loop_name || "unknown-loop";
|
|
4557
|
+
const costCenterId = `loop:${slug(loopName)}`;
|
|
4558
|
+
const sessionId = `loops-${row.loop_run_id ?? row.loop_id ?? row.goal_id}`;
|
|
4559
|
+
const requestId = `loops-${row.goal_run_id}`;
|
|
4560
|
+
const timestamp = row.updated_at ?? row.created_at ?? row.loop_run_started_at ?? new Date().toISOString();
|
|
4561
|
+
const stateValue = `${LOOPS_INGEST_VERSION}:${row.updated_at ?? ""}:${row.tokens_used}:${model}:${costCenterId}`;
|
|
4562
|
+
if (getIngestState(db, "loops", row.goal_run_id) === stateValue)
|
|
4563
|
+
continue;
|
|
4564
|
+
upsertCostCenter(db, {
|
|
4565
|
+
id: costCenterId,
|
|
4566
|
+
kind: "loop",
|
|
4567
|
+
name: loopName,
|
|
4568
|
+
repo_path: null,
|
|
4569
|
+
labels_json: row.labels_json ?? "{}",
|
|
4570
|
+
created_at: row.created_at ?? timestamp
|
|
4571
|
+
});
|
|
4572
|
+
const account = accountFields(row);
|
|
4573
|
+
upsertSession(db, {
|
|
4574
|
+
id: sessionId,
|
|
4575
|
+
agent: "loop",
|
|
4576
|
+
project_path: "",
|
|
4577
|
+
project_name: loopName,
|
|
4578
|
+
started_at: row.loop_run_started_at ?? row.created_at ?? timestamp,
|
|
4579
|
+
ended_at: row.updated_at ?? null,
|
|
4580
|
+
total_cost_usd: 0,
|
|
4581
|
+
total_tokens: 0,
|
|
4582
|
+
request_count: 0,
|
|
4583
|
+
machine_id: machineId,
|
|
4584
|
+
cost_center_id: costCenterId,
|
|
4585
|
+
attribution_tag: "loops-orchestration",
|
|
4586
|
+
...account
|
|
4587
|
+
});
|
|
4588
|
+
const inputTokens = row.tokens_used;
|
|
4589
|
+
const outputTokens = 0;
|
|
4590
|
+
upsertRequest(db, {
|
|
4591
|
+
id: requestId,
|
|
4592
|
+
agent: "loop",
|
|
4593
|
+
session_id: sessionId,
|
|
4594
|
+
model,
|
|
4595
|
+
input_tokens: inputTokens,
|
|
4596
|
+
output_tokens: outputTokens,
|
|
4597
|
+
cache_read_tokens: 0,
|
|
4598
|
+
cache_create_tokens: 0,
|
|
4599
|
+
cost_usd: computeCostFromDb(db, model, inputTokens, outputTokens),
|
|
4600
|
+
cost_basis: "estimated",
|
|
4601
|
+
duration_ms: 0,
|
|
4602
|
+
timestamp,
|
|
4603
|
+
source_request_id: requestId,
|
|
4604
|
+
machine_id: machineId,
|
|
4605
|
+
cost_center_id: costCenterId,
|
|
4606
|
+
attribution_tag: "loops-orchestration",
|
|
4607
|
+
...account
|
|
4608
|
+
});
|
|
4609
|
+
sessions.add(sessionId);
|
|
4610
|
+
requests++;
|
|
4611
|
+
setIngestState(db, "loops", row.goal_run_id, stateValue);
|
|
4612
|
+
if (verbose)
|
|
4613
|
+
console.log(`OpenLoops ${loopName}: ${row.tokens_used} orchestration tokens`);
|
|
4614
|
+
}
|
|
4615
|
+
} catch (error) {
|
|
4616
|
+
if (verbose) {
|
|
4617
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4618
|
+
console.log("OpenLoops ingest skipped:", message);
|
|
4619
|
+
}
|
|
4620
|
+
return { sessions: 0, requests: 0 };
|
|
4621
|
+
} finally {
|
|
4622
|
+
loopsDb?.close();
|
|
4623
|
+
}
|
|
4624
|
+
for (const sessionId of sessions)
|
|
4625
|
+
rollupSession(db, sessionId);
|
|
4626
|
+
return { sessions: sessions.size, requests };
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4098
4629
|
// src/ingest/claude-quota.ts
|
|
4099
4630
|
init_database();
|
|
4100
|
-
import { existsSync as
|
|
4631
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
|
|
4101
4632
|
|
|
4102
4633
|
// src/lib/paths.ts
|
|
4103
|
-
import { homedir as
|
|
4104
|
-
import { join as
|
|
4634
|
+
import { homedir as homedir9 } from "os";
|
|
4635
|
+
import { join as join9 } from "path";
|
|
4105
4636
|
function getHomeDir() {
|
|
4106
|
-
return process.env["USERPROFILE"] ?? process.env["HOME"] ??
|
|
4637
|
+
return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir9();
|
|
4107
4638
|
}
|
|
4108
4639
|
function agentPaths() {
|
|
4109
4640
|
const home = getHomeDir();
|
|
4110
4641
|
return {
|
|
4111
|
-
claudeProjects:
|
|
4112
|
-
claudeCredentials:
|
|
4113
|
-
takumiProjects:
|
|
4114
|
-
codexDir:
|
|
4115
|
-
codexDb:
|
|
4116
|
-
codexAuth:
|
|
4117
|
-
codexConfig:
|
|
4118
|
-
geminiTmp:
|
|
4119
|
-
geminiHistory:
|
|
4120
|
-
opencodeMessages:
|
|
4121
|
-
piSessions:
|
|
4122
|
-
hermesDir:
|
|
4123
|
-
hermesDb:
|
|
4642
|
+
claudeProjects: join9(home, ".claude", "projects"),
|
|
4643
|
+
claudeCredentials: join9(home, ".claude", ".credentials.json"),
|
|
4644
|
+
takumiProjects: join9(home, ".takumi", "projects"),
|
|
4645
|
+
codexDir: join9(home, ".codex"),
|
|
4646
|
+
codexDb: join9(home, ".codex", "state_5.sqlite"),
|
|
4647
|
+
codexAuth: join9(home, ".codex", "auth.json"),
|
|
4648
|
+
codexConfig: join9(home, ".codex", "config.toml"),
|
|
4649
|
+
geminiTmp: join9(home, ".gemini", "tmp"),
|
|
4650
|
+
geminiHistory: join9(home, ".gemini", "history"),
|
|
4651
|
+
opencodeMessages: join9(home, ".local", "share", "opencode", "storage", "message"),
|
|
4652
|
+
piSessions: join9(home, ".pi", "agent", "sessions"),
|
|
4653
|
+
hermesDir: join9(home, ".hermes"),
|
|
4654
|
+
hermesDb: join9(home, ".hermes", "state.db")
|
|
4124
4655
|
};
|
|
4125
4656
|
}
|
|
4126
4657
|
|
|
@@ -4132,7 +4663,7 @@ function readClaudeToken() {
|
|
|
4132
4663
|
const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
|
|
4133
4664
|
if (fromEnv)
|
|
4134
4665
|
return { token: fromEnv };
|
|
4135
|
-
if (!
|
|
4666
|
+
if (!existsSync9(CREDENTIALS_PATH))
|
|
4136
4667
|
return null;
|
|
4137
4668
|
try {
|
|
4138
4669
|
const creds = JSON.parse(readFileSync6(CREDENTIALS_PATH, "utf-8"));
|
|
@@ -4271,14 +4802,14 @@ async function ingestClaudeQuota(db, verbose = false) {
|
|
|
4271
4802
|
|
|
4272
4803
|
// src/ingest/codex-quota.ts
|
|
4273
4804
|
init_database();
|
|
4274
|
-
import { existsSync as
|
|
4805
|
+
import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
|
|
4275
4806
|
var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
|
|
4276
4807
|
function readCodexAuth() {
|
|
4277
4808
|
const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
|
|
4278
4809
|
if (fromEnv)
|
|
4279
4810
|
return { token: fromEnv, authMode: "chatgpt" };
|
|
4280
4811
|
const authPath = agentPaths().codexAuth;
|
|
4281
|
-
if (!
|
|
4812
|
+
if (!existsSync10(authPath))
|
|
4282
4813
|
return null;
|
|
4283
4814
|
try {
|
|
4284
4815
|
const auth = JSON.parse(readFileSync7(authPath, "utf-8"));
|
|
@@ -4411,7 +4942,7 @@ async function ingestCodexQuota(db, verbose = false) {
|
|
|
4411
4942
|
// src/lib/sync-all.ts
|
|
4412
4943
|
init_database();
|
|
4413
4944
|
async function syncAll(db, opts = {}) {
|
|
4414
|
-
const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes);
|
|
4945
|
+
const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes || opts.loops);
|
|
4415
4946
|
const all = !anySpecific;
|
|
4416
4947
|
const result = { deduped: 0 };
|
|
4417
4948
|
if (all || opts.claude) {
|
|
@@ -4434,6 +4965,8 @@ async function syncAll(db, opts = {}) {
|
|
|
4434
4965
|
result.pi = await ingestPi(db, opts.verbose);
|
|
4435
4966
|
if (all || opts.hermes)
|
|
4436
4967
|
result.hermes = await ingestHermes(db, opts.verbose);
|
|
4968
|
+
if (all || opts.loops)
|
|
4969
|
+
result.loops = await ingestLoops(db, opts.verbose);
|
|
4437
4970
|
if (opts.dedupe !== false)
|
|
4438
4971
|
result.deduped = dedupeRequests(db);
|
|
4439
4972
|
return result;
|
|
@@ -5277,7 +5810,7 @@ function isAuthorizedRequest(req, path) {
|
|
|
5277
5810
|
|
|
5278
5811
|
// src/server/serve.ts
|
|
5279
5812
|
import { randomUUID } from "crypto";
|
|
5280
|
-
import { existsSync as
|
|
5813
|
+
import { existsSync as existsSync13 } from "fs";
|
|
5281
5814
|
import { resolve, sep } from "path";
|
|
5282
5815
|
|
|
5283
5816
|
// src/lib/package-metadata.ts
|
|
@@ -6063,6 +6596,15 @@ var openApiSpec = {
|
|
|
6063
6596
|
type: "string"
|
|
6064
6597
|
}
|
|
6065
6598
|
},
|
|
6599
|
+
{
|
|
6600
|
+
name: "since",
|
|
6601
|
+
in: "query",
|
|
6602
|
+
description: "ISO timestamp lower bound; takes precedence over period",
|
|
6603
|
+
schema: {
|
|
6604
|
+
type: "string",
|
|
6605
|
+
format: "date-time"
|
|
6606
|
+
}
|
|
6607
|
+
},
|
|
6066
6608
|
{
|
|
6067
6609
|
name: "machine",
|
|
6068
6610
|
in: "query",
|
|
@@ -6914,7 +7456,7 @@ var CORS = {
|
|
|
6914
7456
|
var APP = "economy";
|
|
6915
7457
|
var ECONOMY_WRITE_SCOPE = `${APP}:write`;
|
|
6916
7458
|
var AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
|
|
6917
|
-
var SYNC_SOURCES = ["all", ...AGENTS];
|
|
7459
|
+
var SYNC_SOURCES = ["all", ...AGENTS, "loops"];
|
|
6918
7460
|
var DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
|
|
6919
7461
|
function json(data, status = 200) {
|
|
6920
7462
|
return new Response(JSON.stringify(data), {
|
|
@@ -6987,13 +7529,13 @@ function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
|
|
|
6987
7529
|
if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
|
|
6988
7530
|
return apiHandler(req);
|
|
6989
7531
|
}
|
|
6990
|
-
if (
|
|
7532
|
+
if (existsSync13(dashboardDir)) {
|
|
6991
7533
|
const filePath = dashboardPath(dashboardDir, url.pathname);
|
|
6992
|
-
if (filePath &&
|
|
7534
|
+
if (filePath && existsSync13(filePath)) {
|
|
6993
7535
|
return new Response(Bun.file(filePath));
|
|
6994
7536
|
}
|
|
6995
7537
|
const indexPath = dashboardPath(dashboardDir, "/");
|
|
6996
|
-
if (indexPath &&
|
|
7538
|
+
if (indexPath && existsSync13(indexPath)) {
|
|
6997
7539
|
return new Response(Bun.file(indexPath));
|
|
6998
7540
|
}
|
|
6999
7541
|
}
|
|
@@ -7155,13 +7697,18 @@ function createHandler(db, options = {}) {
|
|
|
7155
7697
|
if (path === "/api/breakdown" && method === "GET") {
|
|
7156
7698
|
const by = url.searchParams.get("by") ?? "model";
|
|
7157
7699
|
const period = url.searchParams.get("period") ?? "all";
|
|
7700
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
7158
7701
|
const machine = url.searchParams.get("machine") ?? undefined;
|
|
7159
7702
|
if (by === "project")
|
|
7160
|
-
return ok(queryProjectBreakdown(db, period, machine));
|
|
7703
|
+
return ok(since ? queryProjectBreakdownSince(db, since, machine) : queryProjectBreakdown(db, period, machine));
|
|
7161
7704
|
if (by === "agent")
|
|
7162
7705
|
return ok(queryAgentBreakdown(db, period, machine));
|
|
7163
7706
|
if (by === "account")
|
|
7164
7707
|
return ok(queryAccountBreakdown(db, period, machine));
|
|
7708
|
+
if (by === "cost-center")
|
|
7709
|
+
return ok(queryCostCenterBreakdown(db, period, { machine }));
|
|
7710
|
+
if (["loop", "app", "repo", "service", "team"].includes(by))
|
|
7711
|
+
return ok(queryCostCenterBreakdown(db, period, { kind: by, machine }));
|
|
7165
7712
|
return ok(queryModelBreakdown(db));
|
|
7166
7713
|
}
|
|
7167
7714
|
if (path === "/api/budgets" && method === "GET") {
|
|
@@ -7185,6 +7732,7 @@ function createHandler(db, options = {}) {
|
|
|
7185
7732
|
id: randomUUID(),
|
|
7186
7733
|
project_path: body["project_path"] ?? null,
|
|
7187
7734
|
agent,
|
|
7735
|
+
cost_center_id: optionalString(body["cost_center_id"]) ?? null,
|
|
7188
7736
|
period: normalizeBudgetPeriod(body["period"]),
|
|
7189
7737
|
limit_usd: limitUsd,
|
|
7190
7738
|
alert_at_percent: alertAtPercent,
|
|
@@ -7546,12 +8094,12 @@ function parsePort(raw, label) {
|
|
|
7546
8094
|
}
|
|
7547
8095
|
async function runMigrate() {
|
|
7548
8096
|
const { getCloudDatabaseUrl: getCloudDatabaseUrl2, createCloudPool: createCloudPool2, authClientFromPool: authClientFromPool2, cloudMigrations: cloudMigrations2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
|
|
7549
|
-
const { applyPgMigrations } = await
|
|
8097
|
+
const { applyPgMigrations: applyPgMigrations2 } = await Promise.resolve().then(() => (init_pg_migrate(), exports_pg_migrate));
|
|
7550
8098
|
const { ApiKeyStore: ApiKeyStore2 } = await import("@hasna/contracts/auth");
|
|
7551
8099
|
const dsn = getCloudDatabaseUrl2();
|
|
7552
8100
|
if (!dsn)
|
|
7553
8101
|
throw new Error("migrate requires a Postgres DSN: set HASNA_ECONOMY_DATABASE_URL (or ECONOMY_DATABASE_URL / DATABASE_URL)");
|
|
7554
|
-
const result = await
|
|
8102
|
+
const result = await applyPgMigrations2(dsn, cloudMigrations2(), "economy");
|
|
7555
8103
|
const pool = createCloudPool2(dsn);
|
|
7556
8104
|
try {
|
|
7557
8105
|
await new ApiKeyStore2(authClientFromPool2(pool)).ensureSchema();
|