@hasna/economy 0.2.30 → 0.2.31
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 +6 -6
- package/dist/cli/commands/tui.d.ts +1 -1
- package/dist/cli/commands/tui.d.ts.map +1 -1
- package/dist/cli/index.js +850 -187
- package/dist/db/database.d.ts +4 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/storage-adapter.d.ts +34 -0
- package/dist/db/storage-adapter.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1057 -54
- 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/opencode.d.ts +1 -1
- package/dist/ingest/opencode.d.ts.map +1 -1
- package/dist/ingest/otel.d.ts +1 -1
- 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/billing-diff.d.ts +1 -1
- package/dist/lib/billing-diff.d.ts.map +1 -1
- package/dist/lib/cloud-sync.d.ts +9 -2
- package/dist/lib/cloud-sync.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts +3 -2
- package/dist/lib/open-projects.d.ts.map +1 -1
- package/dist/lib/peer-sync.d.ts +1 -1
- package/dist/lib/peer-sync.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/remote-storage.d.ts +15 -0
- package/dist/lib/remote-storage.d.ts.map +1 -0
- 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/storage-sync.d.ts +27 -0
- package/dist/lib/storage-sync.d.ts.map +1 -0
- package/dist/lib/sync-all.d.ts +1 -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 +514 -38
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/otel/index.js +442 -15
- package/dist/server/index.js +510 -51
- package/dist/server/serve.d.ts +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -16,6 +16,60 @@ var __export = (target, all) => {
|
|
|
16
16
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
17
|
var __require = import.meta.require;
|
|
18
18
|
|
|
19
|
+
// src/db/storage-adapter.ts
|
|
20
|
+
import { Database as BunDatabase } from "bun:sqlite";
|
|
21
|
+
|
|
22
|
+
class SqliteAdapter {
|
|
23
|
+
db;
|
|
24
|
+
constructor(path) {
|
|
25
|
+
this.db = new BunDatabase(path, { create: true });
|
|
26
|
+
}
|
|
27
|
+
run(sql, ...params) {
|
|
28
|
+
const result = this.db.prepare(sql).run(...params);
|
|
29
|
+
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
|
|
30
|
+
}
|
|
31
|
+
get(sql, ...params) {
|
|
32
|
+
return this.db.prepare(sql).get(...params);
|
|
33
|
+
}
|
|
34
|
+
all(sql, ...params) {
|
|
35
|
+
return this.db.prepare(sql).all(...params);
|
|
36
|
+
}
|
|
37
|
+
exec(sql) {
|
|
38
|
+
this.db.exec(sql);
|
|
39
|
+
}
|
|
40
|
+
query(sql) {
|
|
41
|
+
return this.db.query(sql);
|
|
42
|
+
}
|
|
43
|
+
prepare(sql) {
|
|
44
|
+
const statement = this.db.prepare(sql);
|
|
45
|
+
return {
|
|
46
|
+
run(...params) {
|
|
47
|
+
const result = statement.run(...params);
|
|
48
|
+
return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
|
|
49
|
+
},
|
|
50
|
+
get(...params) {
|
|
51
|
+
return statement.get(...params);
|
|
52
|
+
},
|
|
53
|
+
all(...params) {
|
|
54
|
+
return statement.all(...params);
|
|
55
|
+
},
|
|
56
|
+
finalize() {
|
|
57
|
+
statement.finalize();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
this.db.close();
|
|
63
|
+
}
|
|
64
|
+
transaction(fn) {
|
|
65
|
+
return this.db.transaction(fn)();
|
|
66
|
+
}
|
|
67
|
+
get raw() {
|
|
68
|
+
return this.db;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
var init_storage_adapter = () => {};
|
|
72
|
+
|
|
19
73
|
// src/lib/pricing.ts
|
|
20
74
|
var exports_pricing = {};
|
|
21
75
|
__export(exports_pricing, {
|
|
@@ -512,7 +566,6 @@ var init_pricing = __esm(() => {
|
|
|
512
566
|
});
|
|
513
567
|
|
|
514
568
|
// src/db/database.ts
|
|
515
|
-
import { SqliteAdapter as Database } from "@hasna/cloud";
|
|
516
569
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
517
570
|
import { hostname } from "os";
|
|
518
571
|
import { homedir } from "os";
|
|
@@ -555,7 +608,7 @@ function openDatabase(dbPath, skipSeed = false) {
|
|
|
555
608
|
if (dir && !existsSync(dir))
|
|
556
609
|
mkdirSync(dir, { recursive: true });
|
|
557
610
|
}
|
|
558
|
-
const db = new
|
|
611
|
+
const db = new SqliteAdapter(path);
|
|
559
612
|
db.exec("PRAGMA journal_mode = WAL");
|
|
560
613
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
561
614
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -1307,13 +1360,17 @@ function queryDailyBreakdown(db, days = 30, machine) {
|
|
|
1307
1360
|
ORDER BY date ASC
|
|
1308
1361
|
`).all(...params);
|
|
1309
1362
|
}
|
|
1310
|
-
function queryHourlyBreakdown(db, machine) {
|
|
1311
|
-
const
|
|
1312
|
-
const params =
|
|
1363
|
+
function queryHourlyBreakdown(db, machine, hours) {
|
|
1364
|
+
const clauses = hours == null ? [`DATE(timestamp) = DATE('now')`] : [`DATETIME(timestamp) >= DATETIME('now', ?)`];
|
|
1365
|
+
const params = hours == null ? [] : [`-${hours} hours`];
|
|
1366
|
+
if (machine) {
|
|
1367
|
+
clauses.push("machine_id = ?");
|
|
1368
|
+
params.push(machine);
|
|
1369
|
+
}
|
|
1313
1370
|
return db.prepare(`
|
|
1314
1371
|
SELECT STRFTIME('%H', timestamp) as hour, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
1315
1372
|
FROM requests
|
|
1316
|
-
WHERE
|
|
1373
|
+
WHERE ${clauses.join(" AND ")}
|
|
1317
1374
|
GROUP BY STRFTIME('%H', timestamp), agent
|
|
1318
1375
|
ORDER BY hour ASC
|
|
1319
1376
|
`).all(...params);
|
|
@@ -1587,7 +1644,198 @@ function dedupeRequests(db) {
|
|
|
1587
1644
|
}
|
|
1588
1645
|
return removed;
|
|
1589
1646
|
}
|
|
1590
|
-
var init_database = () => {
|
|
1647
|
+
var init_database = __esm(() => {
|
|
1648
|
+
init_storage_adapter();
|
|
1649
|
+
init_storage_adapter();
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
// src/db/pg-migrations.ts
|
|
1653
|
+
var exports_pg_migrations = {};
|
|
1654
|
+
__export(exports_pg_migrations, {
|
|
1655
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS
|
|
1656
|
+
});
|
|
1657
|
+
var PG_MIGRATIONS;
|
|
1658
|
+
var init_pg_migrations = __esm(() => {
|
|
1659
|
+
PG_MIGRATIONS = [
|
|
1660
|
+
`CREATE TABLE IF NOT EXISTS requests (
|
|
1661
|
+
id TEXT PRIMARY KEY,
|
|
1662
|
+
agent TEXT NOT NULL,
|
|
1663
|
+
session_id TEXT NOT NULL,
|
|
1664
|
+
model TEXT NOT NULL,
|
|
1665
|
+
input_tokens INTEGER DEFAULT 0,
|
|
1666
|
+
output_tokens INTEGER DEFAULT 0,
|
|
1667
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
1668
|
+
cache_create_tokens INTEGER DEFAULT 0,
|
|
1669
|
+
cache_create_5m_tokens INTEGER DEFAULT 0,
|
|
1670
|
+
cache_create_1h_tokens INTEGER DEFAULT 0,
|
|
1671
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1672
|
+
duration_ms INTEGER DEFAULT 0,
|
|
1673
|
+
timestamp TEXT NOT NULL,
|
|
1674
|
+
source_request_id TEXT,
|
|
1675
|
+
machine_id TEXT DEFAULT '',
|
|
1676
|
+
account_key TEXT DEFAULT '',
|
|
1677
|
+
account_tool TEXT DEFAULT '',
|
|
1678
|
+
account_name TEXT DEFAULT '',
|
|
1679
|
+
account_email TEXT DEFAULT '',
|
|
1680
|
+
account_source TEXT DEFAULT ''
|
|
1681
|
+
)`,
|
|
1682
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
1683
|
+
id TEXT PRIMARY KEY,
|
|
1684
|
+
agent TEXT NOT NULL,
|
|
1685
|
+
project_path TEXT DEFAULT '',
|
|
1686
|
+
project_name TEXT DEFAULT '',
|
|
1687
|
+
started_at TEXT NOT NULL,
|
|
1688
|
+
ended_at TEXT,
|
|
1689
|
+
total_cost_usd REAL DEFAULT 0,
|
|
1690
|
+
total_tokens INTEGER DEFAULT 0,
|
|
1691
|
+
request_count INTEGER DEFAULT 0,
|
|
1692
|
+
machine_id TEXT DEFAULT '',
|
|
1693
|
+
account_key TEXT DEFAULT '',
|
|
1694
|
+
account_tool TEXT DEFAULT '',
|
|
1695
|
+
account_name TEXT DEFAULT '',
|
|
1696
|
+
account_email TEXT DEFAULT '',
|
|
1697
|
+
account_source TEXT DEFAULT ''
|
|
1698
|
+
)`,
|
|
1699
|
+
`CREATE TABLE IF NOT EXISTS projects (
|
|
1700
|
+
id TEXT PRIMARY KEY,
|
|
1701
|
+
path TEXT UNIQUE NOT NULL,
|
|
1702
|
+
name TEXT NOT NULL,
|
|
1703
|
+
description TEXT,
|
|
1704
|
+
tags TEXT DEFAULT '[]',
|
|
1705
|
+
created_at TEXT NOT NULL
|
|
1706
|
+
)`,
|
|
1707
|
+
`CREATE TABLE IF NOT EXISTS budgets (
|
|
1708
|
+
id TEXT PRIMARY KEY,
|
|
1709
|
+
project_path TEXT,
|
|
1710
|
+
agent TEXT,
|
|
1711
|
+
period TEXT NOT NULL,
|
|
1712
|
+
limit_usd REAL NOT NULL,
|
|
1713
|
+
alert_at_percent INTEGER DEFAULT 80,
|
|
1714
|
+
created_at TEXT NOT NULL,
|
|
1715
|
+
updated_at TEXT NOT NULL
|
|
1716
|
+
)`,
|
|
1717
|
+
`CREATE TABLE IF NOT EXISTS goals (
|
|
1718
|
+
id TEXT PRIMARY KEY,
|
|
1719
|
+
period TEXT NOT NULL,
|
|
1720
|
+
project_path TEXT,
|
|
1721
|
+
agent TEXT,
|
|
1722
|
+
limit_usd REAL NOT NULL,
|
|
1723
|
+
created_at TEXT NOT NULL,
|
|
1724
|
+
updated_at TEXT NOT NULL
|
|
1725
|
+
)`,
|
|
1726
|
+
`CREATE TABLE IF NOT EXISTS ingest_state (
|
|
1727
|
+
source TEXT NOT NULL,
|
|
1728
|
+
key TEXT NOT NULL,
|
|
1729
|
+
value TEXT NOT NULL,
|
|
1730
|
+
PRIMARY KEY (source, key)
|
|
1731
|
+
)`,
|
|
1732
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)`,
|
|
1733
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp)`,
|
|
1734
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent)`,
|
|
1735
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id)`,
|
|
1736
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
|
|
1737
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
|
|
1738
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
|
|
1739
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
|
|
1740
|
+
`CREATE TABLE IF NOT EXISTS model_pricing (
|
|
1741
|
+
model TEXT PRIMARY KEY,
|
|
1742
|
+
input_per_1m REAL NOT NULL DEFAULT 0,
|
|
1743
|
+
output_per_1m REAL NOT NULL DEFAULT 0,
|
|
1744
|
+
cache_read_per_1m REAL NOT NULL DEFAULT 0,
|
|
1745
|
+
cache_write_per_1m REAL NOT NULL DEFAULT 0,
|
|
1746
|
+
cache_write_1h_per_1m REAL NOT NULL DEFAULT 0,
|
|
1747
|
+
cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0,
|
|
1748
|
+
updated_at TEXT NOT NULL
|
|
1749
|
+
)`,
|
|
1750
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_5m_tokens INTEGER DEFAULT 0`,
|
|
1751
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_1h_tokens INTEGER DEFAULT 0`,
|
|
1752
|
+
`ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`,
|
|
1753
|
+
`ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`,
|
|
1754
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
1755
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
1756
|
+
message TEXT NOT NULL,
|
|
1757
|
+
email TEXT,
|
|
1758
|
+
category TEXT DEFAULT 'general',
|
|
1759
|
+
version TEXT,
|
|
1760
|
+
machine_id TEXT,
|
|
1761
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1762
|
+
)`,
|
|
1763
|
+
`CREATE TABLE IF NOT EXISTS billing_daily (
|
|
1764
|
+
date TEXT NOT NULL,
|
|
1765
|
+
provider TEXT NOT NULL,
|
|
1766
|
+
description TEXT DEFAULT '',
|
|
1767
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1768
|
+
updated_at TEXT NOT NULL,
|
|
1769
|
+
PRIMARY KEY (date, provider, description)
|
|
1770
|
+
)`,
|
|
1771
|
+
`CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date)`,
|
|
1772
|
+
`CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider)`,
|
|
1773
|
+
`CREATE TABLE IF NOT EXISTS subscriptions (
|
|
1774
|
+
id TEXT PRIMARY KEY,
|
|
1775
|
+
agent TEXT,
|
|
1776
|
+
provider TEXT NOT NULL,
|
|
1777
|
+
plan TEXT NOT NULL,
|
|
1778
|
+
monthly_fee_usd REAL NOT NULL DEFAULT 0,
|
|
1779
|
+
included_usage_usd REAL NOT NULL DEFAULT 0,
|
|
1780
|
+
billing_cycle_start TEXT,
|
|
1781
|
+
reset_policy TEXT DEFAULT 'monthly',
|
|
1782
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
1783
|
+
created_at TEXT NOT NULL,
|
|
1784
|
+
updated_at TEXT NOT NULL
|
|
1785
|
+
)`,
|
|
1786
|
+
`CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
1787
|
+
id TEXT PRIMARY KEY,
|
|
1788
|
+
agent TEXT NOT NULL,
|
|
1789
|
+
date TEXT NOT NULL,
|
|
1790
|
+
metric TEXT NOT NULL,
|
|
1791
|
+
value REAL NOT NULL DEFAULT 0,
|
|
1792
|
+
unit TEXT DEFAULT '',
|
|
1793
|
+
machine_id TEXT DEFAULT '',
|
|
1794
|
+
updated_at TEXT NOT NULL
|
|
1795
|
+
)`,
|
|
1796
|
+
`CREATE TABLE IF NOT EXISTS savings_daily (
|
|
1797
|
+
date TEXT NOT NULL,
|
|
1798
|
+
agent TEXT DEFAULT '',
|
|
1799
|
+
api_equivalent_usd REAL NOT NULL DEFAULT 0,
|
|
1800
|
+
subscription_fee_usd REAL NOT NULL DEFAULT 0,
|
|
1801
|
+
included_consumed_usd REAL NOT NULL DEFAULT 0,
|
|
1802
|
+
on_demand_usd REAL NOT NULL DEFAULT 0,
|
|
1803
|
+
saved_usd REAL NOT NULL DEFAULT 0,
|
|
1804
|
+
updated_at TEXT NOT NULL,
|
|
1805
|
+
PRIMARY KEY (date, agent)
|
|
1806
|
+
)`,
|
|
1807
|
+
`CREATE TABLE IF NOT EXISTS machines (
|
|
1808
|
+
machine_id TEXT PRIMARY KEY,
|
|
1809
|
+
hostname TEXT NOT NULL,
|
|
1810
|
+
last_seen_at TEXT,
|
|
1811
|
+
last_push_at TEXT,
|
|
1812
|
+
last_pull_at TEXT,
|
|
1813
|
+
economy_version TEXT,
|
|
1814
|
+
updated_at TEXT NOT NULL
|
|
1815
|
+
)`,
|
|
1816
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_basis TEXT DEFAULT 'estimated'`,
|
|
1817
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
|
|
1818
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
|
|
1819
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
|
|
1820
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
|
|
1821
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
|
|
1822
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
|
|
1823
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
1824
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
1825
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
|
|
1826
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
|
|
1827
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
|
|
1828
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
|
|
1829
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
|
|
1830
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
|
|
1831
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
1832
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
1833
|
+
`CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
|
|
1834
|
+
`CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
|
|
1835
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
|
|
1836
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`
|
|
1837
|
+
];
|
|
1838
|
+
});
|
|
1591
1839
|
|
|
1592
1840
|
// src/lib/agents.ts
|
|
1593
1841
|
var AGENTS = [
|
|
@@ -1840,22 +2088,26 @@ function clearActiveModel() {
|
|
|
1840
2088
|
// src/lib/open-projects.ts
|
|
1841
2089
|
init_database();
|
|
1842
2090
|
async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
1843
|
-
let
|
|
1844
|
-
if (!
|
|
2091
|
+
let listOpenProjects = listActiveProjects;
|
|
2092
|
+
if (!listOpenProjects) {
|
|
1845
2093
|
const projectsApi = await import("@hasna/projects");
|
|
1846
|
-
|
|
2094
|
+
listOpenProjects = projectsApi.listProjects ?? projectsApi.listWorkspaces;
|
|
1847
2095
|
}
|
|
1848
|
-
|
|
2096
|
+
if (!listOpenProjects) {
|
|
2097
|
+
throw new Error("@hasna/projects does not expose listWorkspaces or listProjects");
|
|
2098
|
+
}
|
|
2099
|
+
const projects = listOpenProjects({ status: "active", limit: 5000 });
|
|
1849
2100
|
let imported = 0;
|
|
1850
2101
|
let skipped = 0;
|
|
1851
2102
|
for (const project of projects) {
|
|
1852
|
-
|
|
2103
|
+
const path = project.path ?? project.primary_path ?? "";
|
|
2104
|
+
if (!path) {
|
|
1853
2105
|
skipped++;
|
|
1854
2106
|
continue;
|
|
1855
2107
|
}
|
|
1856
2108
|
upsertProject(db, {
|
|
1857
2109
|
id: project.id,
|
|
1858
|
-
path
|
|
2110
|
+
path,
|
|
1859
2111
|
name: project.name,
|
|
1860
2112
|
description: project.description,
|
|
1861
2113
|
tags: project.tags ?? [],
|
|
@@ -1867,7 +2119,7 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
|
1867
2119
|
}
|
|
1868
2120
|
// src/lib/peer-sync.ts
|
|
1869
2121
|
init_database();
|
|
1870
|
-
import { Database as
|
|
2122
|
+
import { Database as BunDatabase2 } from "bun:sqlite";
|
|
1871
2123
|
import { existsSync as existsSync3 } from "fs";
|
|
1872
2124
|
|
|
1873
2125
|
// src/lib/package-metadata.ts
|
|
@@ -2112,9 +2364,9 @@ function ensureMachineRegistry(target, machine, now) {
|
|
|
2112
2364
|
}
|
|
2113
2365
|
function openSourceDatabase(path) {
|
|
2114
2366
|
try {
|
|
2115
|
-
return new
|
|
2367
|
+
return new BunDatabase2(path, { readonly: true });
|
|
2116
2368
|
} catch {
|
|
2117
|
-
return new
|
|
2369
|
+
return new BunDatabase2(path);
|
|
2118
2370
|
}
|
|
2119
2371
|
}
|
|
2120
2372
|
function mergePeerDatabase(target, sourcePath, opts = {}) {
|
|
@@ -2158,12 +2410,741 @@ function mergePeerDatabase(target, sourcePath, opts = {}) {
|
|
|
2158
2410
|
tables: tables.filter((t) => t.inserted || t.updated || t.skipped || t.collisions)
|
|
2159
2411
|
};
|
|
2160
2412
|
}
|
|
2413
|
+
// src/lib/cloud-sync.ts
|
|
2414
|
+
init_database();
|
|
2415
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2416
|
+
import { homedir as homedir2, platform } from "os";
|
|
2417
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
2418
|
+
|
|
2419
|
+
// src/lib/remote-storage.ts
|
|
2420
|
+
import pg from "pg";
|
|
2421
|
+
function translatePlaceholders(sql) {
|
|
2422
|
+
let index = 0;
|
|
2423
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
2424
|
+
}
|
|
2425
|
+
function normalizeParams(params) {
|
|
2426
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
2427
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
2428
|
+
}
|
|
2429
|
+
function sslConfigFor(connectionString) {
|
|
2430
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
class PgAdapterAsync {
|
|
2434
|
+
pool;
|
|
2435
|
+
constructor(source) {
|
|
2436
|
+
this.pool = typeof source === "string" ? new pg.Pool({ connectionString: source, ssl: sslConfigFor(source) }) : source;
|
|
2437
|
+
}
|
|
2438
|
+
async run(sql, ...params) {
|
|
2439
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
2440
|
+
return { changes: result.rowCount ?? 0, lastInsertRowid: 0 };
|
|
2441
|
+
}
|
|
2442
|
+
async get(sql, ...params) {
|
|
2443
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
2444
|
+
return result.rows[0] ?? null;
|
|
2445
|
+
}
|
|
2446
|
+
async all(sql, ...params) {
|
|
2447
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
2448
|
+
return result.rows;
|
|
2449
|
+
}
|
|
2450
|
+
async exec(sql) {
|
|
2451
|
+
await this.pool.query(translatePlaceholders(sql));
|
|
2452
|
+
}
|
|
2453
|
+
async close() {
|
|
2454
|
+
await this.pool.end();
|
|
2455
|
+
}
|
|
2456
|
+
async transaction(fn) {
|
|
2457
|
+
const client = await this.pool.connect();
|
|
2458
|
+
try {
|
|
2459
|
+
await client.query("BEGIN");
|
|
2460
|
+
const result = await fn(client);
|
|
2461
|
+
await client.query("COMMIT");
|
|
2462
|
+
return result;
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
await client.query("ROLLBACK");
|
|
2465
|
+
throw error;
|
|
2466
|
+
} finally {
|
|
2467
|
+
client.release();
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
get raw() {
|
|
2471
|
+
return this.pool;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// src/lib/storage-sync.ts
|
|
2476
|
+
async function syncPush(local, remote, options) {
|
|
2477
|
+
const tables = await getTableOrder(remote, options.tables);
|
|
2478
|
+
return syncTransfer(local, remote, { ...options, tables }, "push");
|
|
2479
|
+
}
|
|
2480
|
+
async function syncPull(remote, local, options) {
|
|
2481
|
+
const tables = await getTableOrder(remote, options.tables);
|
|
2482
|
+
return syncTransfer(remote, local, { ...options, tables }, "pull");
|
|
2483
|
+
}
|
|
2484
|
+
function quoteIdent2(identifier) {
|
|
2485
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
2486
|
+
}
|
|
2487
|
+
async function getTableOrder(remote, tables) {
|
|
2488
|
+
if (tables.length <= 1)
|
|
2489
|
+
return tables;
|
|
2490
|
+
try {
|
|
2491
|
+
const rows = await remote.all(`
|
|
2492
|
+
SELECT DISTINCT
|
|
2493
|
+
tc.table_name AS source_table,
|
|
2494
|
+
ccu.table_name AS referenced_table
|
|
2495
|
+
FROM information_schema.table_constraints tc
|
|
2496
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
2497
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
2498
|
+
AND tc.table_schema = ccu.table_schema
|
|
2499
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
2500
|
+
AND tc.table_schema = 'public'
|
|
2501
|
+
`);
|
|
2502
|
+
if (rows.length > 0)
|
|
2503
|
+
return topoSort(tables, rows);
|
|
2504
|
+
} catch {}
|
|
2505
|
+
return tables;
|
|
2506
|
+
}
|
|
2507
|
+
function topoSort(tables, foreignKeys) {
|
|
2508
|
+
const allowed = new Set(tables);
|
|
2509
|
+
const deps = new Map;
|
|
2510
|
+
for (const table of tables)
|
|
2511
|
+
deps.set(table, new Set);
|
|
2512
|
+
for (const fk of foreignKeys) {
|
|
2513
|
+
if (allowed.has(fk.source_table) && allowed.has(fk.referenced_table)) {
|
|
2514
|
+
deps.get(fk.source_table)?.add(fk.referenced_table);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
const sorted = [];
|
|
2518
|
+
const visited = new Set;
|
|
2519
|
+
const visiting = new Set;
|
|
2520
|
+
function visit(table) {
|
|
2521
|
+
if (visited.has(table))
|
|
2522
|
+
return;
|
|
2523
|
+
if (visiting.has(table)) {
|
|
2524
|
+
visited.add(table);
|
|
2525
|
+
sorted.push(table);
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
visiting.add(table);
|
|
2529
|
+
for (const dep of deps.get(table) ?? [])
|
|
2530
|
+
visit(dep);
|
|
2531
|
+
visiting.delete(table);
|
|
2532
|
+
visited.add(table);
|
|
2533
|
+
sorted.push(table);
|
|
2534
|
+
}
|
|
2535
|
+
for (const table of tables)
|
|
2536
|
+
visit(table);
|
|
2537
|
+
return sorted;
|
|
2538
|
+
}
|
|
2539
|
+
async function resolvePrimaryKeys(source, target, table, option) {
|
|
2540
|
+
if (option)
|
|
2541
|
+
return Array.isArray(option) ? option : [option];
|
|
2542
|
+
const sourceKeys = await detectPrimaryKeys(source, table);
|
|
2543
|
+
if (sourceKeys.length > 0)
|
|
2544
|
+
return sourceKeys;
|
|
2545
|
+
return detectPrimaryKeys(target, table);
|
|
2546
|
+
}
|
|
2547
|
+
async function detectPrimaryKeys(adapter, table) {
|
|
2548
|
+
if (isAsyncAdapter(adapter)) {
|
|
2549
|
+
try {
|
|
2550
|
+
const rows = await adapter.all(`
|
|
2551
|
+
SELECT kcu.column_name, kcu.ordinal_position
|
|
2552
|
+
FROM information_schema.table_constraints tc
|
|
2553
|
+
JOIN information_schema.key_column_usage kcu
|
|
2554
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
2555
|
+
AND tc.table_schema = kcu.table_schema
|
|
2556
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
2557
|
+
AND tc.table_schema = 'public'
|
|
2558
|
+
AND tc.table_name = ?
|
|
2559
|
+
ORDER BY kcu.ordinal_position
|
|
2560
|
+
`, table);
|
|
2561
|
+
return rows.map((row) => row.column_name);
|
|
2562
|
+
} catch {
|
|
2563
|
+
return [];
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
try {
|
|
2567
|
+
const rows = adapter.all(`PRAGMA table_info(${quoteIdent2(table)})`);
|
|
2568
|
+
return rows.filter((row) => row.pk > 0).sort((a, b) => a.pk - b.pk).map((row) => row.name);
|
|
2569
|
+
} catch {
|
|
2570
|
+
return [];
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
async function ensureTablesExist(source, target, tables) {
|
|
2574
|
+
if (!isAsyncAdapter(source) || isAsyncAdapter(target))
|
|
2575
|
+
return;
|
|
2576
|
+
for (const table of tables)
|
|
2577
|
+
await ensureTableInSqliteFromPg(target, source, table);
|
|
2578
|
+
}
|
|
2579
|
+
async function ensureTableInSqliteFromPg(target, source, table) {
|
|
2580
|
+
const existing = target.all(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table);
|
|
2581
|
+
if (existing.length > 0)
|
|
2582
|
+
return;
|
|
2583
|
+
const columns = await source.all(`
|
|
2584
|
+
SELECT column_name, data_type, is_nullable
|
|
2585
|
+
FROM information_schema.columns
|
|
2586
|
+
WHERE table_schema = 'public' AND table_name = ?
|
|
2587
|
+
ORDER BY ordinal_position
|
|
2588
|
+
`, table);
|
|
2589
|
+
if (columns.length === 0)
|
|
2590
|
+
return;
|
|
2591
|
+
const primaryKeys = new Set(await detectPrimaryKeys(source, table));
|
|
2592
|
+
const definitions = columns.filter((column) => !["tsvector", "tsquery"].includes(column.data_type.toLowerCase())).map((column) => {
|
|
2593
|
+
const type = pgTypeToSqlite(column.data_type);
|
|
2594
|
+
const notNull = column.is_nullable === "NO" && !primaryKeys.has(column.column_name) ? " NOT NULL" : "";
|
|
2595
|
+
return `${quoteIdent2(column.column_name)} ${type}${notNull}`;
|
|
2596
|
+
});
|
|
2597
|
+
if (primaryKeys.size > 0) {
|
|
2598
|
+
definitions.push(`PRIMARY KEY (${[...primaryKeys].map(quoteIdent2).join(", ")})`);
|
|
2599
|
+
}
|
|
2600
|
+
target.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent2(table)} (${definitions.join(", ")})`);
|
|
2601
|
+
}
|
|
2602
|
+
function pgTypeToSqlite(pgType) {
|
|
2603
|
+
const type = pgType.toLowerCase();
|
|
2604
|
+
if (type.includes("int") || ["bigint", "smallint", "serial", "bigserial"].includes(type))
|
|
2605
|
+
return "INTEGER";
|
|
2606
|
+
if (type.includes("bool"))
|
|
2607
|
+
return "INTEGER";
|
|
2608
|
+
if (type.includes("float") || type.includes("double") || ["real", "numeric", "decimal"].includes(type))
|
|
2609
|
+
return "REAL";
|
|
2610
|
+
if (type === "bytea")
|
|
2611
|
+
return "BLOB";
|
|
2612
|
+
return "TEXT";
|
|
2613
|
+
}
|
|
2614
|
+
async function filterColumnsForTarget(target, table, columns) {
|
|
2615
|
+
if (columns.includes("machine_id") && table !== "machines")
|
|
2616
|
+
await ensureMachineIdColumnInTarget(target, table);
|
|
2617
|
+
try {
|
|
2618
|
+
if (isAsyncAdapter(target)) {
|
|
2619
|
+
const rows2 = await target.all(`
|
|
2620
|
+
SELECT column_name
|
|
2621
|
+
FROM information_schema.columns
|
|
2622
|
+
WHERE table_schema = 'public' AND table_name = ?
|
|
2623
|
+
`, table);
|
|
2624
|
+
if (rows2.length === 0)
|
|
2625
|
+
return columns;
|
|
2626
|
+
const targetColumns2 = new Set(rows2.map((row) => row.column_name));
|
|
2627
|
+
return columns.filter((column) => targetColumns2.has(column));
|
|
2628
|
+
}
|
|
2629
|
+
const rows = target.all(`PRAGMA table_info(${quoteIdent2(table)})`);
|
|
2630
|
+
if (rows.length === 0)
|
|
2631
|
+
return columns;
|
|
2632
|
+
const targetColumns = new Set(rows.map((row) => row.name));
|
|
2633
|
+
return columns.filter((column) => targetColumns.has(column));
|
|
2634
|
+
} catch {
|
|
2635
|
+
return columns;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
async function ensureMachineIdColumnInTarget(target, table) {
|
|
2639
|
+
if (isAsyncAdapter(target)) {
|
|
2640
|
+
const rows2 = await target.all(`
|
|
2641
|
+
SELECT column_name
|
|
2642
|
+
FROM information_schema.columns
|
|
2643
|
+
WHERE table_schema = 'public' AND table_name = ? AND column_name = 'machine_id'
|
|
2644
|
+
`, table);
|
|
2645
|
+
if (rows2.length === 0)
|
|
2646
|
+
await target.exec(`ALTER TABLE ${quoteIdent2(table)} ADD COLUMN machine_id TEXT DEFAULT ''`);
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
const rows = target.all(`PRAGMA table_info(${quoteIdent2(table)})`);
|
|
2650
|
+
if (!rows.some((row) => row.name === "machine_id")) {
|
|
2651
|
+
target.exec(`ALTER TABLE ${quoteIdent2(table)} ADD COLUMN machine_id TEXT DEFAULT ''`);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
async function syncTransfer(source, target, options, _direction) {
|
|
2655
|
+
const { tables, onProgress, batchSize = 100, conflictColumn = "updated_at", primaryKey } = options;
|
|
2656
|
+
const results = [];
|
|
2657
|
+
const sqliteTarget = isAsyncAdapter(target) ? null : target;
|
|
2658
|
+
await ensureTablesExist(source, target, tables);
|
|
2659
|
+
if (sqliteTarget) {
|
|
2660
|
+
try {
|
|
2661
|
+
sqliteTarget.exec("PRAGMA foreign_keys = OFF");
|
|
2662
|
+
} catch {}
|
|
2663
|
+
}
|
|
2664
|
+
try {
|
|
2665
|
+
for (let i = 0;i < tables.length; i++) {
|
|
2666
|
+
const table = tables[i];
|
|
2667
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, rowsSkipped: 0, errors: [] };
|
|
2668
|
+
try {
|
|
2669
|
+
onProgress?.({ table, phase: "reading", rowsRead: 0, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
|
|
2670
|
+
const rows = await readAll(source, `SELECT * FROM ${quoteIdent2(table)}`);
|
|
2671
|
+
result.rowsRead = rows.length;
|
|
2672
|
+
if (rows.length === 0) {
|
|
2673
|
+
onProgress?.({ table, phase: "done", rowsRead: 0, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
|
|
2674
|
+
results.push(result);
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
const sourceColumns = Object.keys(rows[0]);
|
|
2678
|
+
const columns = await filterColumnsForTarget(target, table, sourceColumns);
|
|
2679
|
+
const primaryKeys = await resolvePrimaryKeys(source, target, table, primaryKey);
|
|
2680
|
+
if (primaryKeys.length === 0) {
|
|
2681
|
+
result.errors.push(`Table "${table}" has no primary key; inserted without conflict handling`);
|
|
2682
|
+
for (const batch of batches(rows, batchSize)) {
|
|
2683
|
+
await insertBatch(target, table, columns, batch);
|
|
2684
|
+
result.rowsWritten += batch.length;
|
|
2685
|
+
}
|
|
2686
|
+
results.push(result);
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
const missingKeys = primaryKeys.filter((key) => !columns.includes(key));
|
|
2690
|
+
if (missingKeys.length > 0) {
|
|
2691
|
+
result.errors.push(`Table "${table}" missing primary key column(s): ${missingKeys.join(", ")}`);
|
|
2692
|
+
results.push(result);
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
onProgress?.({ table, phase: "writing", rowsRead: result.rowsRead, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
|
|
2696
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
2697
|
+
const newestWinsColumn = columns.includes(conflictColumn) ? conflictColumn : undefined;
|
|
2698
|
+
for (const batch of batches(rows, batchSize)) {
|
|
2699
|
+
await upsertBatch(target, table, columns, updateColumns, primaryKeys, batch, newestWinsColumn);
|
|
2700
|
+
result.rowsWritten += batch.length;
|
|
2701
|
+
onProgress?.({ table, phase: "writing", rowsRead: result.rowsRead, rowsWritten: result.rowsWritten, totalTables: tables.length, currentTableIndex: i });
|
|
2702
|
+
}
|
|
2703
|
+
onProgress?.({ table, phase: "done", rowsRead: result.rowsRead, rowsWritten: result.rowsWritten, totalTables: tables.length, currentTableIndex: i });
|
|
2704
|
+
} catch (error) {
|
|
2705
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
2706
|
+
}
|
|
2707
|
+
results.push(result);
|
|
2708
|
+
}
|
|
2709
|
+
} finally {
|
|
2710
|
+
if (sqliteTarget) {
|
|
2711
|
+
try {
|
|
2712
|
+
sqliteTarget.exec("PRAGMA foreign_keys = ON");
|
|
2713
|
+
} catch {}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
return results;
|
|
2717
|
+
}
|
|
2718
|
+
function batches(rows, size) {
|
|
2719
|
+
const result = [];
|
|
2720
|
+
for (let offset = 0;offset < rows.length; offset += size)
|
|
2721
|
+
result.push(rows.slice(offset, offset + size));
|
|
2722
|
+
return result;
|
|
2723
|
+
}
|
|
2724
|
+
async function upsertBatch(target, table, columns, updateColumns, primaryKeys, batch, conflictColumn) {
|
|
2725
|
+
if (batch.length === 0 || columns.length === 0)
|
|
2726
|
+
return;
|
|
2727
|
+
const fallbackKey = primaryKeys[0] ?? columns[0] ?? "id";
|
|
2728
|
+
const columnList = columns.map(quoteIdent2).join(", ");
|
|
2729
|
+
const keyList = primaryKeys.map(quoteIdent2).join(", ");
|
|
2730
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent2(column)} = EXCLUDED.${quoteIdent2(column)}`).join(", ") : `${quoteIdent2(fallbackKey)} = EXCLUDED.${quoteIdent2(fallbackKey)}`;
|
|
2731
|
+
const whereClause = conflictColumn && updateColumns.includes(conflictColumn) ? ` WHERE ${quoteIdent2(table)}.${quoteIdent2(conflictColumn)} IS NULL OR EXCLUDED.${quoteIdent2(conflictColumn)} >= ${quoteIdent2(table)}.${quoteIdent2(conflictColumn)}` : "";
|
|
2732
|
+
if (isAsyncAdapter(target)) {
|
|
2733
|
+
const placeholders2 = batch.map((_, rowIndex) => `(${columns.map((__, columnIndex) => `$${rowIndex * columns.length + columnIndex + 1}`).join(", ")})`).join(", ");
|
|
2734
|
+
const params2 = batch.flatMap((row) => columns.map((column) => row[column] ?? null));
|
|
2735
|
+
await target.run(`INSERT INTO ${quoteIdent2(table)} (${columnList}) VALUES ${placeholders2}
|
|
2736
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, ...params2);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
const placeholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
|
|
2740
|
+
const params = batch.flatMap((row) => columns.map((column) => coerceForSqlite(row[column])));
|
|
2741
|
+
target.run(`INSERT INTO ${quoteIdent2(table)} (${columnList}) VALUES ${placeholders}
|
|
2742
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, ...params);
|
|
2743
|
+
}
|
|
2744
|
+
async function insertBatch(target, table, columns, batch) {
|
|
2745
|
+
if (batch.length === 0 || columns.length === 0)
|
|
2746
|
+
return;
|
|
2747
|
+
const columnList = columns.map(quoteIdent2).join(", ");
|
|
2748
|
+
if (isAsyncAdapter(target)) {
|
|
2749
|
+
const placeholders2 = batch.map((_, rowIndex) => `(${columns.map((__, columnIndex) => `$${rowIndex * columns.length + columnIndex + 1}`).join(", ")})`).join(", ");
|
|
2750
|
+
const params2 = batch.flatMap((row) => columns.map((column) => row[column] ?? null));
|
|
2751
|
+
await target.run(`INSERT INTO ${quoteIdent2(table)} (${columnList}) VALUES ${placeholders2}`, ...params2);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const placeholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
|
|
2755
|
+
const params = batch.flatMap((row) => columns.map((column) => coerceForSqlite(row[column])));
|
|
2756
|
+
target.run(`INSERT INTO ${quoteIdent2(table)} (${columnList}) VALUES ${placeholders}`, ...params);
|
|
2757
|
+
}
|
|
2758
|
+
function coerceForSqlite(value) {
|
|
2759
|
+
if (value === null || value === undefined)
|
|
2760
|
+
return null;
|
|
2761
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
2762
|
+
return value;
|
|
2763
|
+
if (value instanceof Date)
|
|
2764
|
+
return value.toISOString();
|
|
2765
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
2766
|
+
return value;
|
|
2767
|
+
if (typeof value === "object")
|
|
2768
|
+
return JSON.stringify(value);
|
|
2769
|
+
return String(value);
|
|
2770
|
+
}
|
|
2771
|
+
function isAsyncAdapter(adapter) {
|
|
2772
|
+
return adapter instanceof PgAdapterAsync;
|
|
2773
|
+
}
|
|
2774
|
+
async function readAll(adapter, sql) {
|
|
2775
|
+
const rows = adapter.all(sql);
|
|
2776
|
+
return rows instanceof Promise ? await rows : rows;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// src/lib/cloud-sync.ts
|
|
2780
|
+
var CLOUD_TABLES = [
|
|
2781
|
+
"requests",
|
|
2782
|
+
"sessions",
|
|
2783
|
+
"projects",
|
|
2784
|
+
"budgets",
|
|
2785
|
+
"goals",
|
|
2786
|
+
"model_pricing",
|
|
2787
|
+
"billing_daily",
|
|
2788
|
+
"subscriptions",
|
|
2789
|
+
"usage_snapshots",
|
|
2790
|
+
"savings_daily",
|
|
2791
|
+
"machines",
|
|
2792
|
+
"ingest_state"
|
|
2793
|
+
];
|
|
2794
|
+
function getCloudDatabaseUrl() {
|
|
2795
|
+
return process.env["ECONOMY_CLOUD_DATABASE_URL"] ?? process.env["HASNA_ECONOMY_CLOUD_DATABASE_URL"] ?? null;
|
|
2796
|
+
}
|
|
2797
|
+
function isCloudAutoEnabled() {
|
|
2798
|
+
return process.env["ECONOMY_CLOUD_AUTO"] === "1" || process.env["ECONOMY_CLOUD_AUTO"] === "true";
|
|
2799
|
+
}
|
|
2800
|
+
function getCloudPullIntervalMinutes() {
|
|
2801
|
+
const raw = process.env["ECONOMY_CLOUD_PULL_INTERVAL"];
|
|
2802
|
+
if (!raw)
|
|
2803
|
+
return 15;
|
|
2804
|
+
const n = Number(raw);
|
|
2805
|
+
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
2806
|
+
}
|
|
2807
|
+
async function getCloudPg() {
|
|
2808
|
+
const url = getCloudDatabaseUrl();
|
|
2809
|
+
if (!url) {
|
|
2810
|
+
throw new Error("Missing ECONOMY_CLOUD_DATABASE_URL (or HASNA_ECONOMY_CLOUD_DATABASE_URL)");
|
|
2811
|
+
}
|
|
2812
|
+
return new PgAdapterAsync(url);
|
|
2813
|
+
}
|
|
2814
|
+
async function runCloudMigrations(cloud) {
|
|
2815
|
+
const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
|
|
2816
|
+
for (const sql of PG_MIGRATIONS2) {
|
|
2817
|
+
await cloud.run(sql);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
function isCloudIncrementalEnabled() {
|
|
2821
|
+
return process.env["ECONOMY_CLOUD_INCREMENTAL"] === "1" || process.env["ECONOMY_CLOUD_INCREMENTAL"] === "true";
|
|
2822
|
+
}
|
|
2823
|
+
async function cloudPush(opts) {
|
|
2824
|
+
const cloud = await getCloudPg();
|
|
2825
|
+
const local = openDatabase(getDbPath(), true);
|
|
2826
|
+
try {
|
|
2827
|
+
await runCloudMigrations(cloud);
|
|
2828
|
+
touchMachineRegistry(local, "push");
|
|
2829
|
+
const tables = resolveCloudTables(opts?.tables);
|
|
2830
|
+
const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
|
|
2831
|
+
const rows = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
2832
|
+
return { rows, machine: getMachineId() };
|
|
2833
|
+
} finally {
|
|
2834
|
+
local.close();
|
|
2835
|
+
await cloud.close();
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
async function cloudPull(opts) {
|
|
2839
|
+
const cloud = await getCloudPg();
|
|
2840
|
+
const local = openDatabase(getDbPath(), true);
|
|
2841
|
+
try {
|
|
2842
|
+
await runCloudMigrations(cloud);
|
|
2843
|
+
const tables = resolveCloudTables(opts?.tables);
|
|
2844
|
+
const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
|
|
2845
|
+
const rows = results.reduce((sum, result) => sum + result.rowsWritten, 0);
|
|
2846
|
+
touchMachineRegistry(local, "pull");
|
|
2847
|
+
setLastCloudPull();
|
|
2848
|
+
return { rows, machine: getMachineId() };
|
|
2849
|
+
} finally {
|
|
2850
|
+
local.close();
|
|
2851
|
+
await cloud.close();
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
async function cloudSyncFull() {
|
|
2855
|
+
const push = await cloudPush();
|
|
2856
|
+
const pull = await cloudPull();
|
|
2857
|
+
return { push: push.rows, pull: pull.rows, machine: getMachineId() };
|
|
2858
|
+
}
|
|
2859
|
+
function setLastCloudPull(at = new Date().toISOString()) {
|
|
2860
|
+
const db = openDatabase(undefined, true);
|
|
2861
|
+
try {
|
|
2862
|
+
db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES ('cloud', 'last_pull_at', ?)`).run(at);
|
|
2863
|
+
} finally {
|
|
2864
|
+
db.close();
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
function getLastCloudPull() {
|
|
2868
|
+
const db = openDatabase(undefined, true);
|
|
2869
|
+
try {
|
|
2870
|
+
const row = db.prepare(`SELECT value FROM ingest_state WHERE source = 'cloud' AND key = 'last_pull_at'`).get();
|
|
2871
|
+
return row?.value ?? null;
|
|
2872
|
+
} finally {
|
|
2873
|
+
db.close();
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
function shouldPullFromCloud() {
|
|
2877
|
+
if (!getCloudDatabaseUrl())
|
|
2878
|
+
return false;
|
|
2879
|
+
const last = getLastCloudPull();
|
|
2880
|
+
if (!last)
|
|
2881
|
+
return true;
|
|
2882
|
+
const ageMs = Date.now() - new Date(last).getTime();
|
|
2883
|
+
return ageMs > getCloudPullIntervalMinutes() * 60000;
|
|
2884
|
+
}
|
|
2885
|
+
async function maybePullFromCloud() {
|
|
2886
|
+
if (!shouldPullFromCloud())
|
|
2887
|
+
return false;
|
|
2888
|
+
try {
|
|
2889
|
+
await cloudPull();
|
|
2890
|
+
return true;
|
|
2891
|
+
} catch {
|
|
2892
|
+
return false;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
async function maybePushAfterIngest() {
|
|
2896
|
+
if (!isCloudAutoEnabled() || !getCloudDatabaseUrl())
|
|
2897
|
+
return false;
|
|
2898
|
+
try {
|
|
2899
|
+
await cloudPush();
|
|
2900
|
+
return true;
|
|
2901
|
+
} catch {
|
|
2902
|
+
return false;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
function touchMachineRegistry(db, direction) {
|
|
2906
|
+
const now = new Date().toISOString();
|
|
2907
|
+
const machine = getMachineId();
|
|
2908
|
+
db.prepare(`
|
|
2909
|
+
INSERT INTO machines (machine_id, hostname, last_seen_at, last_push_at, last_pull_at, economy_version, updated_at)
|
|
2910
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2911
|
+
ON CONFLICT(machine_id) DO UPDATE SET
|
|
2912
|
+
hostname = excluded.hostname,
|
|
2913
|
+
last_seen_at = excluded.last_seen_at,
|
|
2914
|
+
last_push_at = CASE WHEN ? = 'push' THEN excluded.last_push_at ELSE machines.last_push_at END,
|
|
2915
|
+
last_pull_at = CASE WHEN ? = 'pull' THEN excluded.last_pull_at ELSE machines.last_pull_at END,
|
|
2916
|
+
economy_version = excluded.economy_version,
|
|
2917
|
+
updated_at = excluded.updated_at
|
|
2918
|
+
`).run(machine, machine, now, direction === "push" ? now : null, direction === "pull" ? now : null, packageMetadata.version, now, direction, direction);
|
|
2919
|
+
}
|
|
2920
|
+
function resolveCloudTables(tables) {
|
|
2921
|
+
if (!tables || tables.length === 0)
|
|
2922
|
+
return [...CLOUD_TABLES];
|
|
2923
|
+
const allowed = new Set(CLOUD_TABLES);
|
|
2924
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
2925
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
2926
|
+
if (invalid.length > 0) {
|
|
2927
|
+
throw new Error(`Unknown economy sync table(s): ${invalid.join(", ")}`);
|
|
2928
|
+
}
|
|
2929
|
+
return requested;
|
|
2930
|
+
}
|
|
2931
|
+
var SCHEDULE_SERVICE_NAME = "hasna-economy-cloud-sync";
|
|
2932
|
+
var SCHEDULE_CONFIG_DIR = join3(homedir2(), ".hasna", "economy");
|
|
2933
|
+
var SCHEDULE_CONFIG_PATH = join3(SCHEDULE_CONFIG_DIR, "cloud-sync-schedule.json");
|
|
2934
|
+
async function registerCloudSchedule(intervalMinutes) {
|
|
2935
|
+
if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
|
|
2936
|
+
throw new Error("Cloud sync interval must be greater than 0 minutes");
|
|
2937
|
+
}
|
|
2938
|
+
mkdirSync3(SCHEDULE_CONFIG_DIR, { recursive: true });
|
|
2939
|
+
if (platform() === "darwin") {
|
|
2940
|
+
await registerLaunchd(intervalMinutes);
|
|
2941
|
+
} else if (platform() === "linux") {
|
|
2942
|
+
await registerSystemd(intervalMinutes);
|
|
2943
|
+
} else {
|
|
2944
|
+
throw new Error(`Automatic economy cloud sync is not supported on ${platform()}`);
|
|
2945
|
+
}
|
|
2946
|
+
writeFileSync2(SCHEDULE_CONFIG_PATH, JSON.stringify({ intervalMinutes, updatedAt: new Date().toISOString() }, null, 2));
|
|
2947
|
+
}
|
|
2948
|
+
async function removeCloudSchedule() {
|
|
2949
|
+
if (platform() === "darwin")
|
|
2950
|
+
await removeLaunchd();
|
|
2951
|
+
if (platform() === "linux")
|
|
2952
|
+
await removeSystemd();
|
|
2953
|
+
try {
|
|
2954
|
+
unlinkSync(SCHEDULE_CONFIG_PATH);
|
|
2955
|
+
} catch {}
|
|
2956
|
+
}
|
|
2957
|
+
async function getCloudScheduleStatus() {
|
|
2958
|
+
const mechanism = platform() === "darwin" ? "launchd" : platform() === "linux" ? "systemd" : "none";
|
|
2959
|
+
const interval = readScheduleInterval();
|
|
2960
|
+
const registered = mechanism === "launchd" ? existsSync4(getLaunchdPlistPath()) : mechanism === "systemd" ? existsSync4(join3(getSystemdDir(), `${SCHEDULE_SERVICE_NAME}.timer`)) : false;
|
|
2961
|
+
return {
|
|
2962
|
+
registered,
|
|
2963
|
+
schedule_minutes: interval,
|
|
2964
|
+
cron_expression: interval > 0 ? minutesToCron(interval) : null,
|
|
2965
|
+
mechanism
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
function readScheduleInterval() {
|
|
2969
|
+
try {
|
|
2970
|
+
const parsed = JSON.parse(readFileSync3(SCHEDULE_CONFIG_PATH, "utf8"));
|
|
2971
|
+
return typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0 ? parsed.intervalMinutes : 0;
|
|
2972
|
+
} catch {
|
|
2973
|
+
return 0;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
function minutesToCron(minutes) {
|
|
2977
|
+
if (minutes < 60)
|
|
2978
|
+
return `*/${minutes} * * * *`;
|
|
2979
|
+
const hours = Math.floor(minutes / 60);
|
|
2980
|
+
const remainder = minutes % 60;
|
|
2981
|
+
return remainder === 0 && hours <= 24 ? `0 */${hours} * * *` : `*/${minutes} * * * *`;
|
|
2982
|
+
}
|
|
2983
|
+
function getModuleDir() {
|
|
2984
|
+
return typeof import.meta.dir === "string" ? import.meta.dir : dirname2(new URL(import.meta.url).pathname);
|
|
2985
|
+
}
|
|
2986
|
+
function getBunPath() {
|
|
2987
|
+
const candidates = [
|
|
2988
|
+
join3(homedir2(), ".bun", "bin", "bun"),
|
|
2989
|
+
"/opt/homebrew/bin/bun",
|
|
2990
|
+
"/usr/local/bin/bun",
|
|
2991
|
+
"/usr/bin/bun"
|
|
2992
|
+
];
|
|
2993
|
+
return candidates.find((candidate) => existsSync4(candidate)) ?? "bun";
|
|
2994
|
+
}
|
|
2995
|
+
function getEconomySyncCommand() {
|
|
2996
|
+
const dir = getModuleDir();
|
|
2997
|
+
const candidates = [
|
|
2998
|
+
join3(dir, "..", "cli", "index.js"),
|
|
2999
|
+
join3(dir, "..", "cli", "index.ts")
|
|
3000
|
+
];
|
|
3001
|
+
const cliPath = candidates.find((candidate) => existsSync4(candidate));
|
|
3002
|
+
return cliPath ? [getBunPath(), "run", cliPath, "cloud", "sync"] : ["economy", "cloud", "sync"];
|
|
3003
|
+
}
|
|
3004
|
+
function scheduleEnvironment() {
|
|
3005
|
+
const keys = [
|
|
3006
|
+
"ECONOMY_CLOUD_DATABASE_URL",
|
|
3007
|
+
"HASNA_ECONOMY_CLOUD_DATABASE_URL",
|
|
3008
|
+
"HASNA_ECONOMY_DB_PATH",
|
|
3009
|
+
"ECONOMY_DB",
|
|
3010
|
+
"ECONOMY_MACHINE_ID",
|
|
3011
|
+
"ECONOMY_CLOUD_AUTO"
|
|
3012
|
+
];
|
|
3013
|
+
const env = {
|
|
3014
|
+
HOME: homedir2(),
|
|
3015
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin"
|
|
3016
|
+
};
|
|
3017
|
+
for (const key of keys) {
|
|
3018
|
+
const value = process.env[key];
|
|
3019
|
+
if (value)
|
|
3020
|
+
env[key] = value;
|
|
3021
|
+
}
|
|
3022
|
+
return env;
|
|
3023
|
+
}
|
|
3024
|
+
function xmlEscape(value) {
|
|
3025
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3026
|
+
}
|
|
3027
|
+
function getLaunchdPlistPath() {
|
|
3028
|
+
return join3(homedir2(), "Library", "LaunchAgents", "com.hasna.economy-cloud-sync.plist");
|
|
3029
|
+
}
|
|
3030
|
+
function createLaunchdPlist(intervalMinutes) {
|
|
3031
|
+
const args = getEconomySyncCommand();
|
|
3032
|
+
const env = scheduleEnvironment();
|
|
3033
|
+
const stdout = join3(SCHEDULE_CONFIG_DIR, "cloud-sync.log");
|
|
3034
|
+
const stderr = join3(SCHEDULE_CONFIG_DIR, "cloud-sync-error.log");
|
|
3035
|
+
const programArgs = args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join(`
|
|
3036
|
+
`);
|
|
3037
|
+
const environment = Object.entries(env).map(([key, value]) => ` <key>${xmlEscape(key)}</key>
|
|
3038
|
+
<string>${xmlEscape(value)}</string>`).join(`
|
|
3039
|
+
`);
|
|
3040
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3041
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3042
|
+
<plist version="1.0">
|
|
3043
|
+
<dict>
|
|
3044
|
+
<key>Label</key>
|
|
3045
|
+
<string>com.hasna.economy-cloud-sync</string>
|
|
3046
|
+
<key>ProgramArguments</key>
|
|
3047
|
+
<array>
|
|
3048
|
+
${programArgs}
|
|
3049
|
+
</array>
|
|
3050
|
+
<key>StartInterval</key>
|
|
3051
|
+
<integer>${Math.round(intervalMinutes * 60)}</integer>
|
|
3052
|
+
<key>RunAtLoad</key>
|
|
3053
|
+
<true/>
|
|
3054
|
+
<key>StandardOutPath</key>
|
|
3055
|
+
<string>${xmlEscape(stdout)}</string>
|
|
3056
|
+
<key>StandardErrorPath</key>
|
|
3057
|
+
<string>${xmlEscape(stderr)}</string>
|
|
3058
|
+
<key>EnvironmentVariables</key>
|
|
3059
|
+
<dict>
|
|
3060
|
+
${environment}
|
|
3061
|
+
</dict>
|
|
3062
|
+
</dict>
|
|
3063
|
+
</plist>`;
|
|
3064
|
+
}
|
|
3065
|
+
async function registerLaunchd(intervalMinutes) {
|
|
3066
|
+
const plistPath = getLaunchdPlistPath();
|
|
3067
|
+
mkdirSync3(dirname2(plistPath), { recursive: true });
|
|
3068
|
+
try {
|
|
3069
|
+
await Bun.spawn(["launchctl", "unload", plistPath]).exited;
|
|
3070
|
+
} catch {}
|
|
3071
|
+
writeFileSync2(plistPath, createLaunchdPlist(intervalMinutes));
|
|
3072
|
+
await Bun.spawn(["launchctl", "load", plistPath]).exited;
|
|
3073
|
+
}
|
|
3074
|
+
async function removeLaunchd() {
|
|
3075
|
+
const plistPath = getLaunchdPlistPath();
|
|
3076
|
+
try {
|
|
3077
|
+
await Bun.spawn(["launchctl", "unload", plistPath]).exited;
|
|
3078
|
+
} catch {}
|
|
3079
|
+
try {
|
|
3080
|
+
unlinkSync(plistPath);
|
|
3081
|
+
} catch {}
|
|
3082
|
+
}
|
|
3083
|
+
function shellArg(value) {
|
|
3084
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3085
|
+
}
|
|
3086
|
+
function getSystemdDir() {
|
|
3087
|
+
return join3(homedir2(), ".config", "systemd", "user");
|
|
3088
|
+
}
|
|
3089
|
+
function createSystemdService() {
|
|
3090
|
+
const command = getEconomySyncCommand().map(shellArg).join(" ");
|
|
3091
|
+
const environment = Object.entries(scheduleEnvironment()).map(([key, value]) => `Environment=${key}=${shellArg(value)}`).join(`
|
|
3092
|
+
`);
|
|
3093
|
+
return `[Unit]
|
|
3094
|
+
Description=Hasna Economy Cloud Sync
|
|
3095
|
+
After=network.target
|
|
3096
|
+
|
|
3097
|
+
[Service]
|
|
3098
|
+
Type=oneshot
|
|
3099
|
+
ExecStart=${command}
|
|
3100
|
+
${environment}
|
|
3101
|
+
|
|
3102
|
+
[Install]
|
|
3103
|
+
WantedBy=default.target
|
|
3104
|
+
`;
|
|
3105
|
+
}
|
|
3106
|
+
function createSystemdTimer(intervalMinutes) {
|
|
3107
|
+
return `[Unit]
|
|
3108
|
+
Description=Hasna Economy Cloud Sync Timer
|
|
3109
|
+
|
|
3110
|
+
[Timer]
|
|
3111
|
+
OnBootSec=${intervalMinutes}min
|
|
3112
|
+
OnUnitActiveSec=${intervalMinutes}min
|
|
3113
|
+
Persistent=true
|
|
3114
|
+
|
|
3115
|
+
[Install]
|
|
3116
|
+
WantedBy=timers.target
|
|
3117
|
+
`;
|
|
3118
|
+
}
|
|
3119
|
+
async function registerSystemd(intervalMinutes) {
|
|
3120
|
+
const dir = getSystemdDir();
|
|
3121
|
+
mkdirSync3(dir, { recursive: true });
|
|
3122
|
+
writeFileSync2(join3(dir, `${SCHEDULE_SERVICE_NAME}.service`), createSystemdService());
|
|
3123
|
+
writeFileSync2(join3(dir, `${SCHEDULE_SERVICE_NAME}.timer`), createSystemdTimer(intervalMinutes));
|
|
3124
|
+
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
3125
|
+
await Bun.spawn(["systemctl", "--user", "enable", "--now", `${SCHEDULE_SERVICE_NAME}.timer`]).exited;
|
|
3126
|
+
}
|
|
3127
|
+
async function removeSystemd() {
|
|
3128
|
+
try {
|
|
3129
|
+
await Bun.spawn(["systemctl", "--user", "disable", "--now", `${SCHEDULE_SERVICE_NAME}.timer`]).exited;
|
|
3130
|
+
} catch {}
|
|
3131
|
+
const dir = getSystemdDir();
|
|
3132
|
+
try {
|
|
3133
|
+
unlinkSync(join3(dir, `${SCHEDULE_SERVICE_NAME}.service`));
|
|
3134
|
+
} catch {}
|
|
3135
|
+
try {
|
|
3136
|
+
unlinkSync(join3(dir, `${SCHEDULE_SERVICE_NAME}.timer`));
|
|
3137
|
+
} catch {}
|
|
3138
|
+
try {
|
|
3139
|
+
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
3140
|
+
} catch {}
|
|
3141
|
+
}
|
|
2161
3142
|
// src/ingest/claude.ts
|
|
2162
3143
|
init_database();
|
|
2163
3144
|
init_pricing();
|
|
2164
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
2165
|
-
import { homedir as
|
|
2166
|
-
import { join as
|
|
3145
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync2 } from "fs";
|
|
3146
|
+
import { homedir as homedir3 } from "os";
|
|
3147
|
+
import { join as join4, basename } from "path";
|
|
2167
3148
|
|
|
2168
3149
|
// src/lib/savings.ts
|
|
2169
3150
|
function defaultCostBasisForAgent(agent) {
|
|
@@ -2310,8 +3291,8 @@ function withAccount(record, account) {
|
|
|
2310
3291
|
function autoDetectProject(cwd, projects) {
|
|
2311
3292
|
return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
|
|
2312
3293
|
}
|
|
2313
|
-
var CLAUDE_PROJECTS_DIR =
|
|
2314
|
-
var TAKUMI_PROJECTS_DIR =
|
|
3294
|
+
var CLAUDE_PROJECTS_DIR = join4(homedir3(), ".claude", "projects");
|
|
3295
|
+
var TAKUMI_PROJECTS_DIR = join4(homedir3(), ".takumi", "projects");
|
|
2315
3296
|
function dirNameToPath(dirName) {
|
|
2316
3297
|
return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
|
|
2317
3298
|
}
|
|
@@ -2321,9 +3302,9 @@ function collectJsonlFiles(projectDir) {
|
|
|
2321
3302
|
try {
|
|
2322
3303
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
2323
3304
|
if (entry.isDirectory())
|
|
2324
|
-
walk(
|
|
3305
|
+
walk(join4(dir, entry.name));
|
|
2325
3306
|
else if (entry.name.endsWith(".jsonl"))
|
|
2326
|
-
files.push(
|
|
3307
|
+
files.push(join4(dir, entry.name));
|
|
2327
3308
|
}
|
|
2328
3309
|
} catch {}
|
|
2329
3310
|
}
|
|
@@ -2337,7 +3318,7 @@ async function ingestTakumi(db, verbose = false, projectsDir = TAKUMI_PROJECTS_D
|
|
|
2337
3318
|
return ingestJsonlProjects(db, projectsDir, "takumi", verbose);
|
|
2338
3319
|
}
|
|
2339
3320
|
async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
|
|
2340
|
-
if (!
|
|
3321
|
+
if (!existsSync5(projectsDir)) {
|
|
2341
3322
|
if (verbose)
|
|
2342
3323
|
console.log(`${agentName} projects dir not found:`, projectsDir);
|
|
2343
3324
|
return { files: 0, requests: 0, sessions: 0 };
|
|
@@ -2350,7 +3331,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
|
|
|
2350
3331
|
const account = await resolveAccountForAgent(agentName);
|
|
2351
3332
|
const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
2352
3333
|
for (const projectDirEntry of projectDirs) {
|
|
2353
|
-
const projectDirPath =
|
|
3334
|
+
const projectDirPath = join4(projectsDir, projectDirEntry.name);
|
|
2354
3335
|
const projectPath = dirNameToPath(projectDirEntry.name);
|
|
2355
3336
|
const jsonlFiles = collectJsonlFiles(projectDirPath);
|
|
2356
3337
|
for (const filePath of jsonlFiles) {
|
|
@@ -2366,7 +3347,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
|
|
|
2366
3347
|
continue;
|
|
2367
3348
|
let lines;
|
|
2368
3349
|
try {
|
|
2369
|
-
lines =
|
|
3350
|
+
lines = readFileSync4(filePath, "utf-8").split(`
|
|
2370
3351
|
`).filter((l) => l.trim());
|
|
2371
3352
|
} catch {
|
|
2372
3353
|
continue;
|
|
@@ -2485,12 +3466,12 @@ function supportsClaudeDataResidencyPricing(model) {
|
|
|
2485
3466
|
// src/ingest/codex.ts
|
|
2486
3467
|
init_database();
|
|
2487
3468
|
init_pricing();
|
|
2488
|
-
import { existsSync as
|
|
2489
|
-
import { homedir as
|
|
2490
|
-
import { join as
|
|
2491
|
-
import { Database as
|
|
2492
|
-
var DEFAULT_CODEX_DB_PATH =
|
|
2493
|
-
var DEFAULT_CODEX_CONFIG_PATH =
|
|
3469
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
3470
|
+
import { homedir as homedir4 } from "os";
|
|
3471
|
+
import { join as join5, basename as basename2 } from "path";
|
|
3472
|
+
import { Database as BunDatabase3 } from "bun:sqlite";
|
|
3473
|
+
var DEFAULT_CODEX_DB_PATH = join5(homedir4(), ".codex", "state_5.sqlite");
|
|
3474
|
+
var DEFAULT_CODEX_CONFIG_PATH = join5(homedir4(), ".codex", "config.toml");
|
|
2494
3475
|
var CODEX_INGEST_VERSION = "rollout-aggregate-v3";
|
|
2495
3476
|
function codexDbPath() {
|
|
2496
3477
|
return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
|
|
@@ -2500,10 +3481,10 @@ function codexConfigPath() {
|
|
|
2500
3481
|
}
|
|
2501
3482
|
function readCodexModel() {
|
|
2502
3483
|
const configPath = codexConfigPath();
|
|
2503
|
-
if (!
|
|
3484
|
+
if (!existsSync6(configPath))
|
|
2504
3485
|
return "gpt-5-codex";
|
|
2505
3486
|
try {
|
|
2506
|
-
const content =
|
|
3487
|
+
const content = readFileSync5(configPath, "utf-8");
|
|
2507
3488
|
const match = content.match(/^model\s*=\s*"([^"]+)"/m);
|
|
2508
3489
|
return match?.[1] ?? "gpt-5-codex";
|
|
2509
3490
|
} catch {
|
|
@@ -2526,7 +3507,7 @@ function openCodexDb(dbPath, verbose) {
|
|
|
2526
3507
|
for (const readonly of [true, false]) {
|
|
2527
3508
|
let codexDb = null;
|
|
2528
3509
|
try {
|
|
2529
|
-
codexDb = readonly ? new
|
|
3510
|
+
codexDb = readonly ? new BunDatabase3(dbPath, { readonly: true }) : new BunDatabase3(dbPath);
|
|
2530
3511
|
codexDb.prepare("PRAGMA schema_version").get();
|
|
2531
3512
|
return codexDb;
|
|
2532
3513
|
} catch (error) {
|
|
@@ -2541,12 +3522,12 @@ function openCodexDb(dbPath, verbose) {
|
|
|
2541
3522
|
return null;
|
|
2542
3523
|
}
|
|
2543
3524
|
function readTokenEvents(rolloutPath) {
|
|
2544
|
-
if (!rolloutPath || !
|
|
3525
|
+
if (!rolloutPath || !existsSync6(rolloutPath))
|
|
2545
3526
|
return [];
|
|
2546
3527
|
const fallbackUsages = new Map;
|
|
2547
3528
|
let fallbackTimestamp;
|
|
2548
3529
|
let aggregate = null;
|
|
2549
|
-
for (const line of
|
|
3530
|
+
for (const line of readFileSync5(rolloutPath, "utf-8").split(`
|
|
2550
3531
|
`)) {
|
|
2551
3532
|
if (!line.trim())
|
|
2552
3533
|
continue;
|
|
@@ -2618,7 +3599,7 @@ function fallbackEvents(totalTokens) {
|
|
|
2618
3599
|
}
|
|
2619
3600
|
async function ingestCodex(db, verbose = false) {
|
|
2620
3601
|
const dbPath = codexDbPath();
|
|
2621
|
-
if (!
|
|
3602
|
+
if (!existsSync6(dbPath)) {
|
|
2622
3603
|
if (verbose)
|
|
2623
3604
|
console.log("Codex DB not found:", dbPath);
|
|
2624
3605
|
return { sessions: 0, requests: 0 };
|
|
@@ -2701,11 +3682,11 @@ async function ingestCodex(db, verbose = false) {
|
|
|
2701
3682
|
// src/ingest/gemini.ts
|
|
2702
3683
|
init_database();
|
|
2703
3684
|
init_pricing();
|
|
2704
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
2705
|
-
import { homedir as
|
|
2706
|
-
import { join as
|
|
2707
|
-
var DEFAULT_GEMINI_TMP_DIR =
|
|
2708
|
-
var DEFAULT_GEMINI_HISTORY_DIR =
|
|
3685
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync3 } from "fs";
|
|
3686
|
+
import { homedir as homedir5 } from "os";
|
|
3687
|
+
import { join as join6, basename as basename3 } from "path";
|
|
3688
|
+
var DEFAULT_GEMINI_TMP_DIR = join6(homedir5(), ".gemini", "tmp");
|
|
3689
|
+
var DEFAULT_GEMINI_HISTORY_DIR = join6(homedir5(), ".gemini", "history");
|
|
2709
3690
|
function geminiTmpDir() {
|
|
2710
3691
|
return process.env["HASNA_ECONOMY_GEMINI_TMP_DIR"] ?? DEFAULT_GEMINI_TMP_DIR;
|
|
2711
3692
|
}
|
|
@@ -2722,12 +3703,12 @@ function numberField(...values) {
|
|
|
2722
3703
|
function listProjectDirs(...roots) {
|
|
2723
3704
|
const dirs = new Set;
|
|
2724
3705
|
for (const root of roots) {
|
|
2725
|
-
if (!
|
|
3706
|
+
if (!existsSync7(root))
|
|
2726
3707
|
continue;
|
|
2727
3708
|
try {
|
|
2728
3709
|
for (const entry of readdirSync3(root, { withFileTypes: true })) {
|
|
2729
3710
|
if (entry.isDirectory())
|
|
2730
|
-
dirs.add(
|
|
3711
|
+
dirs.add(join6(root, entry.name));
|
|
2731
3712
|
}
|
|
2732
3713
|
} catch {}
|
|
2733
3714
|
}
|
|
@@ -2738,17 +3719,17 @@ function projectRoot(projectDir, chatData) {
|
|
|
2738
3719
|
return chatData.projectPath;
|
|
2739
3720
|
if (chatData.project_path)
|
|
2740
3721
|
return chatData.project_path;
|
|
2741
|
-
const rootFile =
|
|
3722
|
+
const rootFile = join6(projectDir, ".project_root");
|
|
2742
3723
|
try {
|
|
2743
|
-
if (
|
|
2744
|
-
return
|
|
3724
|
+
if (existsSync7(rootFile))
|
|
3725
|
+
return readFileSync6(rootFile, "utf-8").trim();
|
|
2745
3726
|
} catch {}
|
|
2746
3727
|
return "";
|
|
2747
3728
|
}
|
|
2748
3729
|
async function ingestGemini(db, verbose) {
|
|
2749
3730
|
const tmpDir = geminiTmpDir();
|
|
2750
3731
|
const historyDir = geminiHistoryDir();
|
|
2751
|
-
if (!
|
|
3732
|
+
if (!existsSync7(tmpDir) && !existsSync7(historyDir)) {
|
|
2752
3733
|
if (verbose)
|
|
2753
3734
|
console.log("Gemini tmp/history dirs not found:", tmpDir, historyDir);
|
|
2754
3735
|
return { sessions: 0, requests: 0 };
|
|
@@ -2760,17 +3741,17 @@ async function ingestGemini(db, verbose) {
|
|
|
2760
3741
|
const account = await resolveAccountForAgent("gemini");
|
|
2761
3742
|
const projectDirs = listProjectDirs(tmpDir, historyDir);
|
|
2762
3743
|
for (const projectDir of projectDirs) {
|
|
2763
|
-
const chatsDir =
|
|
2764
|
-
if (!
|
|
3744
|
+
const chatsDir = join6(projectDir, "chats");
|
|
3745
|
+
if (!existsSync7(chatsDir))
|
|
2765
3746
|
continue;
|
|
2766
3747
|
let chatFiles = [];
|
|
2767
3748
|
try {
|
|
2768
|
-
chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) =>
|
|
3749
|
+
chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join6(chatsDir, f));
|
|
2769
3750
|
} catch {
|
|
2770
3751
|
continue;
|
|
2771
3752
|
}
|
|
2772
3753
|
for (const filePath of chatFiles) {
|
|
2773
|
-
const stateKey = filePath.replace(
|
|
3754
|
+
const stateKey = filePath.replace(homedir5(), "~");
|
|
2774
3755
|
let fileMtime = "0";
|
|
2775
3756
|
try {
|
|
2776
3757
|
fileMtime = statSync3(filePath).mtimeMs.toString();
|
|
@@ -2782,7 +3763,7 @@ async function ingestGemini(db, verbose) {
|
|
|
2782
3763
|
continue;
|
|
2783
3764
|
let chatData;
|
|
2784
3765
|
try {
|
|
2785
|
-
chatData = JSON.parse(
|
|
3766
|
+
chatData = JSON.parse(readFileSync6(filePath, "utf-8"));
|
|
2786
3767
|
} catch {
|
|
2787
3768
|
continue;
|
|
2788
3769
|
}
|
|
@@ -2866,11 +3847,18 @@ export {
|
|
|
2866
3847
|
upsertGoal,
|
|
2867
3848
|
upsertBudget,
|
|
2868
3849
|
upsertBillingDaily,
|
|
3850
|
+
syncPush,
|
|
3851
|
+
syncPull,
|
|
2869
3852
|
syncOpenProjectsRegistry,
|
|
3853
|
+
shouldPullFromCloud,
|
|
3854
|
+
setLastCloudPull,
|
|
2870
3855
|
setIngestState,
|
|
2871
3856
|
setActiveModel,
|
|
2872
3857
|
seedModelPricing,
|
|
3858
|
+
runCloudMigrations,
|
|
2873
3859
|
rollupSession,
|
|
3860
|
+
removeCloudSchedule,
|
|
3861
|
+
registerCloudSchedule,
|
|
2874
3862
|
readCodexModel,
|
|
2875
3863
|
queryUsageSnapshots,
|
|
2876
3864
|
queryTopSessions,
|
|
@@ -2887,6 +3875,8 @@ export {
|
|
|
2887
3875
|
openDatabase,
|
|
2888
3876
|
normalizeModelName,
|
|
2889
3877
|
mergePeerDatabase,
|
|
3878
|
+
maybePushAfterIngest,
|
|
3879
|
+
maybePullFromCloud,
|
|
2890
3880
|
listSubscriptions,
|
|
2891
3881
|
listProjects,
|
|
2892
3882
|
listModelPricing,
|
|
@@ -2894,6 +3884,8 @@ export {
|
|
|
2894
3884
|
listMachineRegistry,
|
|
2895
3885
|
listGoals,
|
|
2896
3886
|
listBudgets,
|
|
3887
|
+
isCloudIncrementalEnabled,
|
|
3888
|
+
isCloudAutoEnabled,
|
|
2897
3889
|
isAgent,
|
|
2898
3890
|
ingestTakumi,
|
|
2899
3891
|
ingestJsonlProjects,
|
|
@@ -2905,10 +3897,15 @@ export {
|
|
|
2905
3897
|
getPricing,
|
|
2906
3898
|
getModelPricing,
|
|
2907
3899
|
getMachineId,
|
|
3900
|
+
getLastCloudPull,
|
|
2908
3901
|
getIngestState,
|
|
2909
3902
|
getGoalStatuses,
|
|
2910
3903
|
getDbPath,
|
|
2911
3904
|
getDataDir,
|
|
3905
|
+
getCloudScheduleStatus,
|
|
3906
|
+
getCloudPullIntervalMinutes,
|
|
3907
|
+
getCloudPg,
|
|
3908
|
+
getCloudDatabaseUrl,
|
|
2912
3909
|
getBudgetStatuses,
|
|
2913
3910
|
getActiveModel,
|
|
2914
3911
|
gatherTrainingData,
|
|
@@ -2921,10 +3918,16 @@ export {
|
|
|
2921
3918
|
dedupeRequests,
|
|
2922
3919
|
computeCostFromDb,
|
|
2923
3920
|
computeCost,
|
|
3921
|
+
cloudSyncFull,
|
|
3922
|
+
cloudPush,
|
|
3923
|
+
cloudPull,
|
|
2924
3924
|
clearBillingRange,
|
|
2925
3925
|
clearActiveModel,
|
|
3926
|
+
SqliteAdapter,
|
|
3927
|
+
PgAdapterAsync,
|
|
2926
3928
|
DEFAULT_PRICING,
|
|
2927
3929
|
DEFAULT_MODEL,
|
|
2928
3930
|
COST_BASIS,
|
|
3931
|
+
CLOUD_TABLES,
|
|
2929
3932
|
AGENTS
|
|
2930
3933
|
};
|