@hasna/economy 0.3.28 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +119 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +18 -22
- package/SECURITY.md +1 -1
- package/dist/chunks/agent-registry-store-tpnhx3wj.js +39 -0
- package/dist/chunks/billing-bkpxy4kv.js +13 -0
- package/dist/chunks/config-j9mv3ehk.js +15 -0
- package/dist/chunks/index-2hwzmfgv.js +3407 -0
- package/dist/chunks/index-axh32d30.js +1313 -0
- package/dist/chunks/index-emvs5ph2.js +2085 -0
- package/dist/chunks/index-ez9thsr7.js +3440 -0
- package/dist/chunks/index-hesxq90g.js +62 -0
- package/dist/chunks/index-k540hfww.js +258 -0
- package/dist/chunks/index-kgy9b6bf.js +451 -0
- package/dist/chunks/index-kn5572kd.js +124 -0
- package/dist/chunks/index-p35cyhc4.js +4 -0
- package/dist/chunks/index-w3bvv0ns.js +26 -0
- package/dist/chunks/index-zpvea08j.js +3372 -0
- package/dist/chunks/menubar-6xtkt17h.js +296 -0
- package/dist/chunks/open-projects-6z4pq6mx.js +39 -0
- package/dist/chunks/pricing-f80p81gr.js +29 -0
- package/dist/chunks/pricing-vf2pycmh.js +30 -0
- package/dist/chunks/pricing-y21mcjsa.js +30 -0
- package/dist/chunks/serve-0k69e4h9.js +4655 -0
- package/dist/chunks/sqlite-store-501f32ts.js +258 -0
- package/dist/chunks/sqlite-store-fq4natnf.js +256 -0
- package/dist/chunks/sqlite-store-st99garx.js +258 -0
- package/dist/chunks/third-party-sqlite-5e85fxa2.js +11 -0
- package/dist/chunks/tui-7f8f951w.js +22 -0
- package/dist/chunks/watch-mm1j39vy.js +196 -0
- package/dist/chunks/webhooks-k4fh2t9c.js +109 -0
- package/dist/cli/commands/brief.d.ts.map +1 -1
- package/dist/cli/commands/extras.d.ts.map +1 -1
- package/dist/cli/commands/journal.d.ts +3 -0
- package/dist/cli/commands/journal.d.ts.map +1 -0
- package/dist/cli/commands/menubar.d.ts +97 -4
- package/dist/cli/commands/menubar.d.ts.map +1 -1
- package/dist/cli/commands/tui.d.ts +1 -1
- package/dist/cli/commands/tui.d.ts.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/index.js +1524 -9998
- package/dist/db/cloud.d.ts +18 -12
- package/dist/db/cloud.d.ts.map +1 -1
- package/dist/db/database.d.ts +33 -12
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/ingest-concurrency-worker.d.ts +2 -0
- package/dist/db/ingest-concurrency-worker.d.ts.map +1 -0
- package/dist/db/ingest-errors.d.ts +9 -0
- package/dist/db/ingest-errors.d.ts.map +1 -0
- package/dist/db/ingest-schema.d.ts +3 -0
- package/dist/db/ingest-schema.d.ts.map +1 -0
- package/dist/db/ingest-specs.d.ts +8 -0
- package/dist/db/ingest-specs.d.ts.map +1 -0
- package/dist/db/ingest-test-contract.d.ts +3 -0
- package/dist/db/ingest-test-contract.d.ts.map +1 -0
- package/dist/db/ingest-validation.d.ts +12 -0
- package/dist/db/ingest-validation.d.ts.map +1 -0
- package/dist/db/ingest.d.ts +27 -0
- package/dist/db/ingest.d.ts.map +1 -0
- package/dist/db/legacy-schema.d.ts +7 -0
- package/dist/db/legacy-schema.d.ts.map +1 -0
- package/dist/db/observation-contract.d.ts +3 -0
- package/dist/db/observation-contract.d.ts.map +1 -0
- package/dist/db/observation-provenance.d.ts +23 -0
- package/dist/db/observation-provenance.d.ts.map +1 -0
- package/dist/db/observation-schema.d.ts +24 -0
- package/dist/db/observation-schema.d.ts.map +1 -0
- package/dist/db/pg-connection.d.ts +10 -0
- package/dist/db/pg-connection.d.ts.map +1 -0
- package/dist/db/pg-migrate.d.ts +4 -9
- package/dist/db/pg-migrate.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/pg-protocol.d.ts +28 -0
- package/dist/db/pg-protocol.d.ts.map +1 -0
- package/dist/db/sqlite-store.d.ts +4 -0
- package/dist/db/sqlite-store.d.ts.map +1 -0
- package/dist/db/sync-pg.d.ts +17 -21
- package/dist/db/sync-pg.d.ts.map +1 -1
- package/dist/db/third-party-sqlite.d.ts +10 -0
- package/dist/db/third-party-sqlite.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1281 -2405
- package/dist/ingest/claude-quota.d.ts.map +1 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex-quota.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts +25 -0
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/collection-outcome.d.ts +17 -0
- package/dist/ingest/collection-outcome.d.ts.map +1 -0
- package/dist/ingest/cursor.d.ts.map +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 +9 -0
- package/dist/ingest/loops.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/pi.d.ts +1 -1
- package/dist/ingest/pi.d.ts.map +1 -1
- package/dist/ingest/quota-observation.d.ts +16 -0
- package/dist/ingest/quota-observation.d.ts.map +1 -0
- package/dist/ingest/source-time.d.ts +4 -0
- package/dist/ingest/source-time.d.ts.map +1 -0
- package/dist/lib/accounts-store.d.ts +109 -0
- package/dist/lib/accounts-store.d.ts.map +1 -0
- package/dist/lib/accounts.d.ts +2 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/analytics.d.ts.map +1 -1
- package/dist/lib/api-display-url.d.ts +19 -0
- package/dist/lib/api-display-url.d.ts.map +1 -0
- package/dist/lib/autosync-gate.d.ts +22 -0
- package/dist/lib/autosync-gate.d.ts.map +1 -0
- package/dist/lib/billing-diff.d.ts +18 -40
- package/dist/lib/billing-diff.d.ts.map +1 -1
- package/dist/lib/brief.d.ts +2 -0
- package/dist/lib/brief.d.ts.map +1 -1
- package/dist/lib/budget-decimal.d.ts +14 -0
- package/dist/lib/budget-decimal.d.ts.map +1 -0
- package/dist/lib/cloud-ingest.d.ts +30 -1
- package/dist/lib/cloud-ingest.d.ts.map +1 -1
- package/dist/lib/cloud-storage.d.ts +166 -10
- package/dist/lib/cloud-storage.d.ts.map +1 -1
- package/dist/lib/gatherer.d.ts.map +1 -1
- package/dist/lib/ingest-batches.d.ts +21 -0
- package/dist/lib/ingest-batches.d.ts.map +1 -0
- package/dist/lib/ingest-journal-client.d.ts +37 -0
- package/dist/lib/ingest-journal-client.d.ts.map +1 -0
- package/dist/lib/ingest-journal-lock.d.ts +5 -0
- package/dist/lib/ingest-journal-lock.d.ts.map +1 -0
- package/dist/lib/ingest-journal-maintenance.d.ts +92 -0
- package/dist/lib/ingest-journal-maintenance.d.ts.map +1 -0
- package/dist/lib/ingest-journal-operation.d.ts +55 -0
- package/dist/lib/ingest-journal-operation.d.ts.map +1 -0
- package/dist/lib/ingest-journal.d.ts +73 -0
- package/dist/lib/ingest-journal.d.ts.map +1 -0
- package/dist/lib/money-display.d.ts +13 -0
- package/dist/lib/money-display.d.ts.map +1 -0
- package/dist/lib/periods.d.ts +12 -0
- package/dist/lib/periods.d.ts.map +1 -1
- package/dist/lib/pricing.d.ts +14 -0
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/savings.d.ts +64 -7
- package/dist/lib/savings.d.ts.map +1 -1
- package/dist/lib/serve-auth.d.ts.map +1 -1
- package/dist/lib/store/index.d.ts +29 -22
- package/dist/lib/store/index.d.ts.map +1 -1
- package/dist/lib/summary-contract.d.ts +32 -0
- package/dist/lib/summary-contract.d.ts.map +1 -0
- package/dist/lib/summary-test-contract.d.ts +3 -0
- package/dist/lib/summary-test-contract.d.ts.map +1 -0
- package/dist/lib/sync-all.d.ts +4 -0
- package/dist/lib/sync-all.d.ts.map +1 -1
- package/dist/lib/sync-maintenance.d.ts.map +1 -1
- package/dist/lib/sync-report.d.ts +4 -0
- package/dist/lib/sync-report.d.ts.map +1 -0
- package/dist/lib/test-keychain-fixture.d.ts +9 -0
- package/dist/lib/test-keychain-fixture.d.ts.map +1 -0
- package/dist/mcp/agent-registry-store.d.ts +30 -0
- package/dist/mcp/agent-registry-store.d.ts.map +1 -0
- package/dist/mcp/agent-registry.d.ts +78 -0
- package/dist/mcp/agent-registry.d.ts.map +1 -0
- package/dist/mcp/harness.d.ts +26 -0
- package/dist/mcp/harness.d.ts.map +1 -0
- package/dist/mcp/index.js +3219 -3357
- package/dist/mcp/ingest-journal-tool.d.ts +2 -0
- package/dist/mcp/ingest-journal-tool.d.ts.map +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/otel/index.js +2197 -121
- package/dist/server/human-auth.d.ts +20 -0
- package/dist/server/human-auth.d.ts.map +1 -0
- package/dist/server/index.js +10477 -6378
- package/dist/server/legacy-inventory.d.ts +52 -0
- package/dist/server/legacy-inventory.d.ts.map +1 -0
- package/dist/server/legacy-operations.d.ts +64 -0
- package/dist/server/legacy-operations.d.ts.map +1 -0
- package/dist/server/legacy-plan.d.ts +55 -0
- package/dist/server/legacy-plan.d.ts.map +1 -0
- package/dist/server/legacy-preservation.d.ts +58 -0
- package/dist/server/legacy-preservation.d.ts.map +1 -0
- package/dist/server/pg-sync-worker.js +195 -21
- package/dist/server/serve.d.ts +20 -18
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/service-admission.d.ts +10 -0
- package/dist/server/service-admission.d.ts.map +1 -0
- package/dist/server/service-dispatcher.d.ts +45 -0
- package/dist/server/service-dispatcher.d.ts.map +1 -0
- package/dist/server/service-operations.d.ts +5 -0
- package/dist/server/service-operations.d.ts.map +1 -0
- package/dist/server/service-protocol.d.ts +50 -0
- package/dist/server/service-protocol.d.ts.map +1 -0
- package/dist/server/service-worker.d.ts +2 -0
- package/dist/server/service-worker.d.ts.map +1 -0
- package/dist/server/service-worker.js +8029 -0
- package/dist/types/index.d.ts +66 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +2 -3
- package/docs/configuration.md +25 -12
- package/docs/cost-reporting.md +49 -0
- package/docs/historical-data-reconciliation.md +203 -0
- package/docs/ingest-journal.md +108 -0
- package/docs/ingestion.md +61 -5
- package/docs/mcp.md +5 -3
- package/docs/native-budget-save.md +84 -0
- package/docs/native-human-auth.md +102 -0
- package/docs/otel.md +8 -2
- package/docs/rest-api.md +4 -4
- package/package.json +17 -20
- package/postinstall.js +52 -1
- package/dashboard/README.md +0 -26
- package/dist/cli/brains.d.ts +0 -3
- package/dist/cli/brains.d.ts.map +0 -1
- package/dist/lib/test-hermetic-accounts.d.ts +0 -6
- package/dist/lib/test-hermetic-accounts.d.ts.map +0 -1
package/dist/otel/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var __export = (target, all) => {
|
|
|
15
15
|
});
|
|
16
16
|
};
|
|
17
17
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = import.meta.require;
|
|
18
19
|
|
|
19
20
|
// src/db/sqlite-adapter.ts
|
|
20
21
|
import { Database } from "bun:sqlite";
|
|
@@ -72,6 +73,851 @@ class SqliteAdapter {
|
|
|
72
73
|
}
|
|
73
74
|
var init_sqlite_adapter = () => {};
|
|
74
75
|
|
|
76
|
+
// src/db/observation-schema.ts
|
|
77
|
+
var OBSERVATION_COLUMNS, SCAN_RECEIPTS_SQL = `CREATE TABLE IF NOT EXISTS scan_receipts (
|
|
78
|
+
id TEXT PRIMARY KEY, machine_id TEXT NOT NULL, source TEXT NOT NULL,
|
|
79
|
+
attempted_at TEXT NOT NULL, completed_at TEXT NOT NULL, status TEXT NOT NULL,
|
|
80
|
+
latest_event_at TEXT, parser_version TEXT NOT NULL, error_code TEXT,
|
|
81
|
+
updated_at TEXT NOT NULL
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_scan_receipts_machine_source
|
|
84
|
+
ON scan_receipts(machine_id, source, completed_at);`, OBSERVATION_PG_MIGRATIONS;
|
|
85
|
+
var init_observation_schema = __esm(() => {
|
|
86
|
+
OBSERVATION_COLUMNS = {
|
|
87
|
+
usage_snapshots: {
|
|
88
|
+
provider: "TEXT NOT NULL DEFAULT ''",
|
|
89
|
+
account_key: "TEXT NOT NULL DEFAULT ''",
|
|
90
|
+
observed_at: "TEXT",
|
|
91
|
+
source_timestamp: "TEXT",
|
|
92
|
+
window_start: "TEXT",
|
|
93
|
+
window_end: "TEXT",
|
|
94
|
+
reset_at: "TEXT",
|
|
95
|
+
window_seconds: "INTEGER",
|
|
96
|
+
value_kind: "TEXT NOT NULL DEFAULT 'unknown'"
|
|
97
|
+
},
|
|
98
|
+
subscriptions: {
|
|
99
|
+
account_key: "TEXT NOT NULL DEFAULT ''",
|
|
100
|
+
observed_station: "TEXT",
|
|
101
|
+
observed_at: "TEXT",
|
|
102
|
+
fee_basis: "TEXT NOT NULL DEFAULT 'unknown'",
|
|
103
|
+
included_basis: "TEXT NOT NULL DEFAULT 'unknown'"
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
OBSERVATION_PG_MIGRATIONS = [
|
|
107
|
+
...Object.entries(OBSERVATION_COLUMNS).flatMap(([table, columns]) => Object.entries(columns).map(([column, type]) => `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column} ${type}`)),
|
|
108
|
+
SCAN_RECEIPTS_SQL
|
|
109
|
+
];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// src/db/ingest-errors.ts
|
|
113
|
+
var IngestError;
|
|
114
|
+
var init_ingest_errors = __esm(() => {
|
|
115
|
+
IngestError = class IngestError extends Error {
|
|
116
|
+
status;
|
|
117
|
+
code;
|
|
118
|
+
field;
|
|
119
|
+
expected;
|
|
120
|
+
constructor(status, code, field, expected) {
|
|
121
|
+
super(`${field}: ${expected}`);
|
|
122
|
+
this.status = status;
|
|
123
|
+
this.code = code;
|
|
124
|
+
this.field = field;
|
|
125
|
+
this.expected = expected;
|
|
126
|
+
this.name = "IngestError";
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// src/db/observation-provenance.ts
|
|
132
|
+
import { createHash } from "crypto";
|
|
133
|
+
function observationId(input) {
|
|
134
|
+
return digest([input.provider, input.account_key, input.machine_id, input.metric, new Date(input.observed_at).toISOString()]);
|
|
135
|
+
}
|
|
136
|
+
function subscriptionId(input) {
|
|
137
|
+
return digest([input.provider, input.account_key]);
|
|
138
|
+
}
|
|
139
|
+
function scanReceiptId(input) {
|
|
140
|
+
return digest([input.machine_id, input.source, new Date(input.attempted_at).toISOString()]);
|
|
141
|
+
}
|
|
142
|
+
function required(row, keys, path) {
|
|
143
|
+
for (const key of keys)
|
|
144
|
+
if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 256) {
|
|
145
|
+
invalid(`${path}.${key}`, "expected nonempty text of at most 256 characters");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function validateObservationProvenance(table, row, path) {
|
|
149
|
+
if (table === "usage_snapshots") {
|
|
150
|
+
if (!["gauge", "cumulative", "delta", "unknown"].includes(String(row["value_kind"] === undefined ? "unknown" : row["value_kind"])))
|
|
151
|
+
invalid(`${path}.value_kind`, "expected gauge, cumulative, delta or unknown");
|
|
152
|
+
const newProvenance = ["provider", "account_key", "observed_at", "source_timestamp", "window_start", "window_end", "reset_at", "window_seconds"].some((k) => row[k] != null && row[k] !== "") || row["value_kind"] != null && row["value_kind"] !== "unknown";
|
|
153
|
+
if (newProvenance) {
|
|
154
|
+
required(row, ["provider", "account_key", "machine_id", "observed_at", "metric"], path);
|
|
155
|
+
if (row["id"] !== observationId(row))
|
|
156
|
+
invalid(`${path}.id`, "expected SHA-256 identity of provider, account_key, machine_id, metric and observed_at");
|
|
157
|
+
if (row["date"] !== String(row["observed_at"]).slice(0, 10))
|
|
158
|
+
invalid(`${path}.date`, "expected the UTC observation date");
|
|
159
|
+
if (row["window_seconds"] != null && (!Number.isSafeInteger(row["window_seconds"]) || Number(row["window_seconds"]) <= 0))
|
|
160
|
+
invalid(`${path}.window_seconds`, "expected positive integer seconds or null");
|
|
161
|
+
if (row["window_start"] && row["window_end"] && Date.parse(String(row["window_start"])) >= Date.parse(String(row["window_end"])))
|
|
162
|
+
invalid(`${path}.window_end`, "expected a time after window_start");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (table === "subscriptions") {
|
|
166
|
+
for (const key of ["fee_basis", "included_basis"])
|
|
167
|
+
if (!["unknown", "observed", "configured"].includes(String(row[key] === undefined ? "unknown" : row[key])))
|
|
168
|
+
invalid(`${path}.${key}`, "expected unknown, observed or configured");
|
|
169
|
+
const identified = row["account_key"] != null && row["account_key"] !== "" || row["observed_at"] != null;
|
|
170
|
+
if (identified || ["fee_basis", "included_basis"].some((k) => row[k] != null && row[k] !== "unknown")) {
|
|
171
|
+
required(row, ["provider", "account_key", "observed_at"], path);
|
|
172
|
+
if (row["id"] !== subscriptionId(row))
|
|
173
|
+
invalid(`${path}.id`, "expected SHA-256 identity of provider and account_key");
|
|
174
|
+
if (row["updated_at"] !== row["observed_at"])
|
|
175
|
+
invalid(`${path}.updated_at`, "expected the observation timestamp");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (table === "scan_receipts") {
|
|
179
|
+
required(row, ["machine_id", "source", "attempted_at", "completed_at", "parser_version"], path);
|
|
180
|
+
if (!["success", "unchanged", "missing", "unsupported", "failed"].includes(String(row["status"])))
|
|
181
|
+
invalid(`${path}.status`, "expected success, unchanged, missing, unsupported or failed");
|
|
182
|
+
if (row["id"] !== scanReceiptId(row))
|
|
183
|
+
invalid(`${path}.id`, "expected SHA-256 identity of machine_id, source and attempted_at");
|
|
184
|
+
if (Date.parse(String(row["completed_at"])) < Date.parse(String(row["attempted_at"])))
|
|
185
|
+
invalid(`${path}.completed_at`, "expected a time at or after attempted_at");
|
|
186
|
+
if (row["updated_at"] !== row["completed_at"])
|
|
187
|
+
invalid(`${path}.updated_at`, "expected completed_at");
|
|
188
|
+
if (row["error_code"] != null && (typeof row["error_code"] !== "string" || !SCAN_ERROR_CODES.includes(row["error_code"])))
|
|
189
|
+
invalid(`${path}.error_code`, "expected a supported authored scan error code or null");
|
|
190
|
+
if (["success", "unchanged"].includes(String(row["status"])) && row["error_code"] != null)
|
|
191
|
+
invalid(`${path}.error_code`, "expected null for a successful scan");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
var SCAN_ERROR_CODES, digest = (parts) => createHash("sha256").update(JSON.stringify(parts)).digest("hex"), invalid = (path, expected) => {
|
|
195
|
+
throw new IngestError(400, "INVALID_INGEST", path, expected);
|
|
196
|
+
};
|
|
197
|
+
var init_observation_provenance = __esm(() => {
|
|
198
|
+
init_ingest_errors();
|
|
199
|
+
SCAN_ERROR_CODES = [
|
|
200
|
+
"SOURCE_MISSING",
|
|
201
|
+
"SOURCE_UNREADABLE",
|
|
202
|
+
"SOURCE_INVALID",
|
|
203
|
+
"LEGACY_AGGREGATE_REQUIRES_MIGRATION",
|
|
204
|
+
"USAGE_EVENTS_UNAVAILABLE",
|
|
205
|
+
"USAGE_EVENTS_INCOMPLETE",
|
|
206
|
+
"PROVIDER_CREDENTIAL_UNAVAILABLE",
|
|
207
|
+
"PROVIDER_CREDENTIAL_UNREADABLE",
|
|
208
|
+
"PROVIDER_AUTH_REFUSED",
|
|
209
|
+
"PROVIDER_REFUSED",
|
|
210
|
+
"PROVIDER_UNAVAILABLE",
|
|
211
|
+
"ACCOUNT_IDENTITY_UNAVAILABLE",
|
|
212
|
+
"SUBSCRIPTION_QUOTA_NOT_APPLICABLE",
|
|
213
|
+
"TEAM_USAGE_ATTRIBUTION_UNAVAILABLE",
|
|
214
|
+
"CROSS_APP_LOCAL_READ_REFUSED",
|
|
215
|
+
"COLLECTION_FAILED"
|
|
216
|
+
];
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// src/db/ingest-specs.ts
|
|
220
|
+
function ingestSpecs() {
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
table: "requests",
|
|
224
|
+
pk: ["id"],
|
|
225
|
+
columns: [
|
|
226
|
+
"id",
|
|
227
|
+
"agent",
|
|
228
|
+
"session_id",
|
|
229
|
+
"model",
|
|
230
|
+
"input_tokens",
|
|
231
|
+
"output_tokens",
|
|
232
|
+
"cache_read_tokens",
|
|
233
|
+
"cache_create_tokens",
|
|
234
|
+
"cache_create_5m_tokens",
|
|
235
|
+
"cache_create_1h_tokens",
|
|
236
|
+
"cost_usd",
|
|
237
|
+
"cost_basis",
|
|
238
|
+
"duration_ms",
|
|
239
|
+
"timestamp",
|
|
240
|
+
"source_request_id",
|
|
241
|
+
"machine_id",
|
|
242
|
+
"cost_center_id",
|
|
243
|
+
"attribution_tag",
|
|
244
|
+
"account_key",
|
|
245
|
+
"account_tool",
|
|
246
|
+
"account_name",
|
|
247
|
+
"account_email",
|
|
248
|
+
"account_source",
|
|
249
|
+
"updated_at",
|
|
250
|
+
"synced_at"
|
|
251
|
+
],
|
|
252
|
+
map: (r, now) => [
|
|
253
|
+
strOr(r["id"]),
|
|
254
|
+
strOr(r["agent"]),
|
|
255
|
+
strOr(r["session_id"]),
|
|
256
|
+
strOr(r["model"]),
|
|
257
|
+
numOr(r["input_tokens"]),
|
|
258
|
+
numOr(r["output_tokens"]),
|
|
259
|
+
numOr(r["cache_read_tokens"]),
|
|
260
|
+
numOr(r["cache_create_tokens"]),
|
|
261
|
+
numOr(r["cache_create_5m_tokens"] ?? r["cache_create_tokens"]),
|
|
262
|
+
numOr(r["cache_create_1h_tokens"]),
|
|
263
|
+
numOr(r["cost_usd"]),
|
|
264
|
+
strOr(r["cost_basis"], "estimated"),
|
|
265
|
+
numOr(r["duration_ms"]),
|
|
266
|
+
strOr(r["timestamp"]),
|
|
267
|
+
r["source_request_id"] == null ? null : strOr(r["source_request_id"]),
|
|
268
|
+
strOr(r["machine_id"]),
|
|
269
|
+
r["cost_center_id"] == null ? null : strOr(r["cost_center_id"]),
|
|
270
|
+
strOr(r["attribution_tag"]),
|
|
271
|
+
strOr(r["account_key"]),
|
|
272
|
+
strOr(r["account_tool"]),
|
|
273
|
+
strOr(r["account_name"]),
|
|
274
|
+
strOr(r["account_email"]),
|
|
275
|
+
strOr(r["account_source"]),
|
|
276
|
+
strOr(r["updated_at"], now),
|
|
277
|
+
strOr(r["synced_at"])
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
table: "sessions",
|
|
282
|
+
pk: ["id"],
|
|
283
|
+
columns: [
|
|
284
|
+
"id",
|
|
285
|
+
"agent",
|
|
286
|
+
"project_path",
|
|
287
|
+
"project_name",
|
|
288
|
+
"started_at",
|
|
289
|
+
"ended_at",
|
|
290
|
+
"total_cost_usd",
|
|
291
|
+
"total_tokens",
|
|
292
|
+
"request_count",
|
|
293
|
+
"machine_id",
|
|
294
|
+
"cost_center_id",
|
|
295
|
+
"attribution_tag",
|
|
296
|
+
"account_key",
|
|
297
|
+
"account_tool",
|
|
298
|
+
"account_name",
|
|
299
|
+
"account_email",
|
|
300
|
+
"account_source",
|
|
301
|
+
"updated_at",
|
|
302
|
+
"synced_at"
|
|
303
|
+
],
|
|
304
|
+
map: (r, now) => [
|
|
305
|
+
strOr(r["id"]),
|
|
306
|
+
strOr(r["agent"]),
|
|
307
|
+
strOr(r["project_path"]),
|
|
308
|
+
strOr(r["project_name"]),
|
|
309
|
+
strOr(r["started_at"]),
|
|
310
|
+
r["ended_at"] == null ? null : strOr(r["ended_at"]),
|
|
311
|
+
numOr(r["total_cost_usd"]),
|
|
312
|
+
numOr(r["total_tokens"]),
|
|
313
|
+
numOr(r["request_count"]),
|
|
314
|
+
strOr(r["machine_id"]),
|
|
315
|
+
r["cost_center_id"] == null ? null : strOr(r["cost_center_id"]),
|
|
316
|
+
strOr(r["attribution_tag"]),
|
|
317
|
+
strOr(r["account_key"]),
|
|
318
|
+
strOr(r["account_tool"]),
|
|
319
|
+
strOr(r["account_name"]),
|
|
320
|
+
strOr(r["account_email"]),
|
|
321
|
+
strOr(r["account_source"]),
|
|
322
|
+
strOr(r["updated_at"], now),
|
|
323
|
+
strOr(r["synced_at"])
|
|
324
|
+
]
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
table: "projects",
|
|
328
|
+
pk: ["id"],
|
|
329
|
+
columns: ["id", "path", "name", "description", "tags", "created_at"],
|
|
330
|
+
map: (r) => [
|
|
331
|
+
strOr(r["id"]),
|
|
332
|
+
strOr(r["path"]),
|
|
333
|
+
strOr(r["name"]),
|
|
334
|
+
r["description"] == null ? null : strOr(r["description"]),
|
|
335
|
+
typeof r["tags"] === "string" ? r["tags"] : JSON.stringify(r["tags"] ?? []),
|
|
336
|
+
strOr(r["created_at"])
|
|
337
|
+
]
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
table: "budgets",
|
|
341
|
+
pk: ["id"],
|
|
342
|
+
columns: ["id", "project_path", "agent", "period", "limit_usd", "alert_at_percent", "cost_center_id", "created_at", "updated_at", "metric"],
|
|
343
|
+
map: (r) => [
|
|
344
|
+
strOr(r["id"]),
|
|
345
|
+
r["project_path"] == null ? null : strOr(r["project_path"]),
|
|
346
|
+
r["agent"] == null ? null : strOr(r["agent"]),
|
|
347
|
+
strOr(r["period"]),
|
|
348
|
+
numOr(r["limit_usd"]),
|
|
349
|
+
numOr(r["alert_at_percent"], 80),
|
|
350
|
+
r["cost_center_id"] == null ? null : strOr(r["cost_center_id"]),
|
|
351
|
+
strOr(r["created_at"]),
|
|
352
|
+
strOr(r["updated_at"]),
|
|
353
|
+
strOr(r["metric"], "legacy_request_cost_usd")
|
|
354
|
+
]
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
table: "goals",
|
|
358
|
+
pk: ["id"],
|
|
359
|
+
columns: ["id", "period", "project_path", "agent", "limit_usd", "created_at", "updated_at"],
|
|
360
|
+
map: (r) => [
|
|
361
|
+
strOr(r["id"]),
|
|
362
|
+
strOr(r["period"]),
|
|
363
|
+
r["project_path"] == null ? null : strOr(r["project_path"]),
|
|
364
|
+
r["agent"] == null ? null : strOr(r["agent"]),
|
|
365
|
+
numOr(r["limit_usd"]),
|
|
366
|
+
strOr(r["created_at"]),
|
|
367
|
+
strOr(r["updated_at"])
|
|
368
|
+
]
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
table: "billing_daily",
|
|
372
|
+
pk: ["date", "provider", "description"],
|
|
373
|
+
columns: ["date", "provider", "description", "cost_usd", "updated_at"],
|
|
374
|
+
map: (r) => [strOr(r["date"]), strOr(r["provider"]), strOr(r["description"]), numOr(r["cost_usd"]), strOr(r["updated_at"])]
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
table: "model_pricing",
|
|
378
|
+
pk: ["model"],
|
|
379
|
+
columns: [
|
|
380
|
+
"model",
|
|
381
|
+
"input_per_1m",
|
|
382
|
+
"output_per_1m",
|
|
383
|
+
"cache_read_per_1m",
|
|
384
|
+
"cache_write_per_1m",
|
|
385
|
+
"cache_write_1h_per_1m",
|
|
386
|
+
"cache_storage_per_1m_hour",
|
|
387
|
+
"updated_at"
|
|
388
|
+
],
|
|
389
|
+
map: (r) => [
|
|
390
|
+
strOr(r["model"]),
|
|
391
|
+
numOr(r["input_per_1m"]),
|
|
392
|
+
numOr(r["output_per_1m"]),
|
|
393
|
+
numOr(r["cache_read_per_1m"]),
|
|
394
|
+
numOr(r["cache_write_per_1m"]),
|
|
395
|
+
numOr(r["cache_write_1h_per_1m"]),
|
|
396
|
+
numOr(r["cache_storage_per_1m_hour"]),
|
|
397
|
+
strOr(r["updated_at"])
|
|
398
|
+
]
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
table: "subscriptions",
|
|
402
|
+
pk: ["id"],
|
|
403
|
+
columns: [
|
|
404
|
+
"id",
|
|
405
|
+
"agent",
|
|
406
|
+
"provider",
|
|
407
|
+
"plan",
|
|
408
|
+
"monthly_fee_usd",
|
|
409
|
+
"included_usage_usd",
|
|
410
|
+
"billing_cycle_start",
|
|
411
|
+
"reset_policy",
|
|
412
|
+
"active",
|
|
413
|
+
"created_at",
|
|
414
|
+
"updated_at",
|
|
415
|
+
"account_key",
|
|
416
|
+
"observed_station",
|
|
417
|
+
"observed_at",
|
|
418
|
+
"fee_basis",
|
|
419
|
+
"included_basis"
|
|
420
|
+
],
|
|
421
|
+
map: (r) => [
|
|
422
|
+
strOr(r["id"]),
|
|
423
|
+
r["agent"] == null ? null : strOr(r["agent"]),
|
|
424
|
+
strOr(r["provider"]),
|
|
425
|
+
strOr(r["plan"]),
|
|
426
|
+
numOr(r["monthly_fee_usd"]),
|
|
427
|
+
numOr(r["included_usage_usd"]),
|
|
428
|
+
r["billing_cycle_start"] == null ? null : strOr(r["billing_cycle_start"]),
|
|
429
|
+
strOr(r["reset_policy"], "unknown"),
|
|
430
|
+
numOr(r["active"], 1),
|
|
431
|
+
strOr(r["created_at"]),
|
|
432
|
+
strOr(r["updated_at"]),
|
|
433
|
+
strOr(r["account_key"]),
|
|
434
|
+
r["observed_station"] ?? null,
|
|
435
|
+
r["observed_at"] ?? null,
|
|
436
|
+
strOr(r["fee_basis"], "unknown"),
|
|
437
|
+
strOr(r["included_basis"], "unknown")
|
|
438
|
+
]
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
table: "usage_snapshots",
|
|
442
|
+
pk: ["id"],
|
|
443
|
+
columns: [
|
|
444
|
+
"id",
|
|
445
|
+
"agent",
|
|
446
|
+
"date",
|
|
447
|
+
"metric",
|
|
448
|
+
"value",
|
|
449
|
+
"unit",
|
|
450
|
+
"machine_id",
|
|
451
|
+
"updated_at",
|
|
452
|
+
"provider",
|
|
453
|
+
"account_key",
|
|
454
|
+
"observed_at",
|
|
455
|
+
"source_timestamp",
|
|
456
|
+
"window_start",
|
|
457
|
+
"window_end",
|
|
458
|
+
"reset_at",
|
|
459
|
+
"window_seconds",
|
|
460
|
+
"value_kind"
|
|
461
|
+
],
|
|
462
|
+
map: (r, now) => [
|
|
463
|
+
strOr(r["id"], `${strOr(r["agent"])}-${strOr(r["date"])}-${strOr(r["metric"])}-${strOr(r["machine_id"])}`),
|
|
464
|
+
strOr(r["agent"]),
|
|
465
|
+
strOr(r["date"]),
|
|
466
|
+
strOr(r["metric"]),
|
|
467
|
+
numOr(r["value"]),
|
|
468
|
+
strOr(r["unit"]),
|
|
469
|
+
strOr(r["machine_id"]),
|
|
470
|
+
strOr(r["updated_at"], now),
|
|
471
|
+
strOr(r["provider"]),
|
|
472
|
+
strOr(r["account_key"]),
|
|
473
|
+
r["observed_at"] ?? null,
|
|
474
|
+
r["source_timestamp"] ?? null,
|
|
475
|
+
r["window_start"] ?? null,
|
|
476
|
+
r["window_end"] ?? null,
|
|
477
|
+
r["reset_at"] ?? null,
|
|
478
|
+
r["window_seconds"] ?? null,
|
|
479
|
+
strOr(r["value_kind"], "unknown")
|
|
480
|
+
]
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
table: "scan_receipts",
|
|
484
|
+
pk: ["id"],
|
|
485
|
+
columns: ["id", "machine_id", "source", "attempted_at", "completed_at", "status", "latest_event_at", "parser_version", "error_code", "updated_at"],
|
|
486
|
+
map: (r) => [strOr(r["id"]), strOr(r["machine_id"]), strOr(r["source"]), strOr(r["attempted_at"]), strOr(r["completed_at"]), strOr(r["status"]), r["latest_event_at"] ?? null, strOr(r["parser_version"]), r["error_code"] ?? null, strOr(r["updated_at"], strOr(r["completed_at"]))]
|
|
487
|
+
}
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
var numOr = (v, d = 0) => {
|
|
491
|
+
if (v == null)
|
|
492
|
+
return d;
|
|
493
|
+
const num = Number(v);
|
|
494
|
+
return Number.isFinite(num) ? num : d;
|
|
495
|
+
}, strOr = (v, d = "") => v == null ? d : String(v);
|
|
496
|
+
|
|
497
|
+
// src/lib/agents.ts
|
|
498
|
+
function isAgent(value) {
|
|
499
|
+
return AGENTS.includes(value);
|
|
500
|
+
}
|
|
501
|
+
var AGENTS;
|
|
502
|
+
var init_agents = __esm(() => {
|
|
503
|
+
AGENTS = [
|
|
504
|
+
"claude",
|
|
505
|
+
"takumi",
|
|
506
|
+
"codex",
|
|
507
|
+
"gemini",
|
|
508
|
+
"opencode",
|
|
509
|
+
"cursor",
|
|
510
|
+
"pi",
|
|
511
|
+
"hermes"
|
|
512
|
+
];
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// src/db/ingest-validation.ts
|
|
516
|
+
import { createHash as createHash2 } from "crypto";
|
|
517
|
+
function stableJson(value) {
|
|
518
|
+
if (Array.isArray(value))
|
|
519
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
520
|
+
if (value !== null && typeof value === "object") {
|
|
521
|
+
const record = value;
|
|
522
|
+
return `{${Object.keys(record).sort().map((k) => `${JSON.stringify(k)}:${stableJson(record[k])}`).join(",")}}`;
|
|
523
|
+
}
|
|
524
|
+
return JSON.stringify(value);
|
|
525
|
+
}
|
|
526
|
+
function canonicalIngestBodyHash(body) {
|
|
527
|
+
return createHash2("sha256").update(stableJson(body)).digest("hex");
|
|
528
|
+
}
|
|
529
|
+
function invalid2(field, expected) {
|
|
530
|
+
throw new IngestError(400, "INVALID_INGEST", field, expected);
|
|
531
|
+
}
|
|
532
|
+
function plain(value) {
|
|
533
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
534
|
+
}
|
|
535
|
+
function date(value) {
|
|
536
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
537
|
+
}
|
|
538
|
+
function instant(value) {
|
|
539
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) && date(value.slice(0, 10)) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 19) === value.slice(0, 19);
|
|
540
|
+
}
|
|
541
|
+
function validateIngest(body) {
|
|
542
|
+
if (!plain(body))
|
|
543
|
+
invalid2("body", "expected an object of supported table arrays");
|
|
544
|
+
const specs = ingestSpecs();
|
|
545
|
+
const allowed = new Set(["schema_version", "idempotency_key", "subscription_mode", "budget_mode", ...specs.map((s) => s.table)]);
|
|
546
|
+
for (const key of Object.keys(body))
|
|
547
|
+
if (!allowed.has(key))
|
|
548
|
+
invalid2("body", "unknown field; expected a supported table, schema_version, subscription_mode, budget_mode or idempotency_key");
|
|
549
|
+
if (body["subscription_mode"] !== undefined && (typeof body["subscription_mode"] !== "string" || !["quota_observation", "configuration"].includes(body["subscription_mode"]) || !Array.isArray(body["subscriptions"]))) {
|
|
550
|
+
invalid2("subscription_mode", "expected quota_observation or configuration with a subscriptions array");
|
|
551
|
+
}
|
|
552
|
+
if (body["schema_version"] !== undefined && body["schema_version"] !== 1)
|
|
553
|
+
invalid2("schema_version", "expected 1");
|
|
554
|
+
if (body["idempotency_key"] !== undefined && (typeof body["idempotency_key"] !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(body["idempotency_key"])))
|
|
555
|
+
invalid2("idempotency_key", "expected 1\u2013200 letters, digits, dot, underscore, colon or hyphen");
|
|
556
|
+
const createBudget = body["budget_mode"] !== undefined;
|
|
557
|
+
if (createBudget) {
|
|
558
|
+
if (body["budget_mode"] !== "create")
|
|
559
|
+
invalid2("budget_mode", "expected create");
|
|
560
|
+
if (body["schema_version"] !== 1)
|
|
561
|
+
invalid2("schema_version", "budget creation requires 1");
|
|
562
|
+
if (typeof body["idempotency_key"] !== "string")
|
|
563
|
+
invalid2("idempotency_key", "budget creation requires a stable caller-owned key");
|
|
564
|
+
if (Object.keys(body).some((k) => !["schema_version", "idempotency_key", "budget_mode", "budgets"].includes(k)))
|
|
565
|
+
invalid2("body", "budget creation accepts only schema_version, idempotency_key, budget_mode and budgets");
|
|
566
|
+
if (!Array.isArray(body["budgets"]) || body["budgets"].length !== 1)
|
|
567
|
+
invalid2("budgets", "budget creation requires exactly one rule");
|
|
568
|
+
}
|
|
569
|
+
let count = 0;
|
|
570
|
+
let bytes = 0;
|
|
571
|
+
const groups = [];
|
|
572
|
+
for (const spec of specs) {
|
|
573
|
+
const input = body[spec.table];
|
|
574
|
+
if (input === undefined)
|
|
575
|
+
continue;
|
|
576
|
+
if (!Array.isArray(input))
|
|
577
|
+
invalid2(spec.table, "expected an array");
|
|
578
|
+
count += input.length;
|
|
579
|
+
if (count > MAX_INGEST_ROWS)
|
|
580
|
+
throw new IngestError(413, "INGEST_TOO_LARGE", "body", `maximum ${MAX_INGEST_ROWS} rows; split into bounded commands`);
|
|
581
|
+
const rows = [];
|
|
582
|
+
for (let i = 0;i < input.length; i++) {
|
|
583
|
+
const raw = input[i];
|
|
584
|
+
const path = `${spec.table}[${i}]`;
|
|
585
|
+
if (!plain(raw))
|
|
586
|
+
invalid2(path, "expected a row object");
|
|
587
|
+
const row = {};
|
|
588
|
+
for (const key of Object.keys(raw)) {
|
|
589
|
+
if (!spec.columns.includes(key))
|
|
590
|
+
invalid2(/^[a-z_]{1,64}$/.test(key) ? `${path}.${key}` : path, "unknown column for this table");
|
|
591
|
+
const value = raw[key];
|
|
592
|
+
if (["observed_at", "source_timestamp", "window_start", "window_end", "reset_at", "attempted_at", "completed_at", "latest_event_at"].includes(key) && value === "")
|
|
593
|
+
invalid2(`${path}.${key}`, "expected an ISO UTC timestamp or null");
|
|
594
|
+
if (numeric.has(key) && !(key === "window_seconds" && value === null)) {
|
|
595
|
+
if (typeof value !== "number" || !Number.isFinite(value) || integers.has(key) && !Number.isSafeInteger(value) || value < 0 && !(spec.table === "billing_daily" && key === "cost_usd"))
|
|
596
|
+
invalid2(`${path}.${key}`, "expected a finite numeric value in the supported range");
|
|
597
|
+
if (key === "active" && value !== 0 && value !== 1)
|
|
598
|
+
invalid2(`${path}.active`, "expected 0 or 1");
|
|
599
|
+
} else if (key === "tags" && Array.isArray(value)) {
|
|
600
|
+
if (value.length > 100 || value.some((t) => typeof t !== "string" || t.length > 256))
|
|
601
|
+
invalid2(`${path}.tags`, "expected at most 100 string tags of at most 256 characters");
|
|
602
|
+
} else if (value !== null && (typeof value !== "string" || value.length > 16384 || value.includes("\x00"))) {
|
|
603
|
+
invalid2(`${path}.${key}`, "expected text of at most 16384 characters without NUL, or null");
|
|
604
|
+
}
|
|
605
|
+
if (typeof value === "string" && (key === "date" || key === "billing_cycle_start") && value !== "" && !date(value))
|
|
606
|
+
invalid2(`${path}.${key}`, "expected a valid YYYY-MM-DD date");
|
|
607
|
+
if (typeof value === "string" && (key.endsWith("_at") || ["timestamp", "source_timestamp", "window_start", "window_end"].includes(key)) && value !== "") {
|
|
608
|
+
if (!instant(value))
|
|
609
|
+
invalid2(`${path}.${key}`, "expected an ISO UTC timestamp ending in Z");
|
|
610
|
+
row[key] = new Date(value).toISOString();
|
|
611
|
+
} else
|
|
612
|
+
row[key] = value;
|
|
613
|
+
}
|
|
614
|
+
for (const key of required2[spec.table] ?? spec.pk) {
|
|
615
|
+
if (typeof row[key] !== "string" || row[key] === "")
|
|
616
|
+
invalid2(`${path}.${key}`, "expected nonempty text");
|
|
617
|
+
}
|
|
618
|
+
for (const key of spec.pk)
|
|
619
|
+
if (typeof (row[key] ?? "") !== "string" || String(row[key] ?? "").length > 1024)
|
|
620
|
+
invalid2(`${path}.${key}`, "expected a primary key of at most 1024 characters");
|
|
621
|
+
if (spec.table === "budgets" && !["daily", "weekly", "monthly"].includes(String(row["period"])))
|
|
622
|
+
invalid2(`${path}.period`, "expected daily, weekly or monthly");
|
|
623
|
+
if (spec.table === "budgets") {
|
|
624
|
+
if (row["metric"] !== undefined && !["legacy_request_cost_usd", "metered_api_usd"].includes(String(row["metric"])))
|
|
625
|
+
invalid2(`${path}.metric`, "expected legacy_request_cost_usd or metered_api_usd");
|
|
626
|
+
if (createBudget) {
|
|
627
|
+
if (String(row["id"]).trim() === "")
|
|
628
|
+
invalid2(`${path}.id`, "expected a nonempty caller-owned rule identity");
|
|
629
|
+
if (row["metric"] !== "metered_api_usd")
|
|
630
|
+
invalid2(`${path}.metric`, "budget creation requires metered_api_usd");
|
|
631
|
+
if (typeof row["limit_usd"] !== "number" || row["limit_usd"] <= 0)
|
|
632
|
+
invalid2(`${path}.limit_usd`, "expected a positive finite USD amount");
|
|
633
|
+
if (!Number.isInteger(row["alert_at_percent"]) || Number(row["alert_at_percent"]) < 1 || Number(row["alert_at_percent"]) > 100)
|
|
634
|
+
invalid2(`${path}.alert_at_percent`, "expected an integer from 1 to 100");
|
|
635
|
+
for (const field of ["created_at", "updated_at"])
|
|
636
|
+
if (typeof row[field] !== "string" || !instant(row[field]))
|
|
637
|
+
invalid2(`${path}.${field}`, "expected an explicit ISO UTC timestamp ending in Z; preserve it on retry");
|
|
638
|
+
for (const field of ["project_path", "cost_center_id"])
|
|
639
|
+
if (row[field] !== undefined && row[field] !== null && (typeof row[field] !== "string" || row[field].trim() === ""))
|
|
640
|
+
invalid2(`${path}.${field}`, "expected nonempty text or null for all scopes");
|
|
641
|
+
if (row["agent"] !== undefined && row["agent"] !== null && (typeof row["agent"] !== "string" || !isAgent(row["agent"])))
|
|
642
|
+
invalid2(`${path}.agent`, "expected a supported Economy agent or null for all agents");
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (spec.table === "goals" && !["day", "week", "month", "year"].includes(String(row["period"])))
|
|
646
|
+
invalid2(`${path}.period`, "expected day, week, month or year");
|
|
647
|
+
if (spec.table === "scan_receipts" && row["updated_at"] === undefined)
|
|
648
|
+
row["updated_at"] = row["completed_at"];
|
|
649
|
+
validateObservationProvenance(spec.table, row, path);
|
|
650
|
+
bytes += Buffer.byteLength(stableJson(row));
|
|
651
|
+
if (bytes > MAX_INGEST_BYTES)
|
|
652
|
+
throw new IngestError(413, "INGEST_TOO_LARGE", "body", `maximum ${MAX_INGEST_BYTES} JSON bytes; split into bounded commands`);
|
|
653
|
+
rows.push(row);
|
|
654
|
+
}
|
|
655
|
+
groups.push({ spec, rows });
|
|
656
|
+
}
|
|
657
|
+
if (Buffer.byteLength(stableJson(body)) > MAX_INGEST_BYTES)
|
|
658
|
+
throw new IngestError(413, "INGEST_TOO_LARGE", "body", `maximum ${MAX_INGEST_BYTES} JSON bytes; split into bounded commands`);
|
|
659
|
+
return groups;
|
|
660
|
+
}
|
|
661
|
+
var MAX_INGEST_ROWS = 1e4, MAX_INGEST_BYTES, integers, numeric, required2;
|
|
662
|
+
var init_ingest_validation = __esm(() => {
|
|
663
|
+
init_observation_provenance();
|
|
664
|
+
init_ingest_errors();
|
|
665
|
+
init_agents();
|
|
666
|
+
MAX_INGEST_BYTES = 4 * 1024 * 1024;
|
|
667
|
+
integers = new Set([
|
|
668
|
+
"input_tokens",
|
|
669
|
+
"output_tokens",
|
|
670
|
+
"cache_read_tokens",
|
|
671
|
+
"cache_create_tokens",
|
|
672
|
+
"cache_create_5m_tokens",
|
|
673
|
+
"cache_create_1h_tokens",
|
|
674
|
+
"duration_ms",
|
|
675
|
+
"total_tokens",
|
|
676
|
+
"request_count",
|
|
677
|
+
"active",
|
|
678
|
+
"window_seconds"
|
|
679
|
+
]);
|
|
680
|
+
numeric = new Set([
|
|
681
|
+
...integers,
|
|
682
|
+
"cost_usd",
|
|
683
|
+
"total_cost_usd",
|
|
684
|
+
"limit_usd",
|
|
685
|
+
"alert_at_percent",
|
|
686
|
+
"input_per_1m",
|
|
687
|
+
"output_per_1m",
|
|
688
|
+
"cache_read_per_1m",
|
|
689
|
+
"cache_write_per_1m",
|
|
690
|
+
"cache_write_1h_per_1m",
|
|
691
|
+
"cache_storage_per_1m_hour",
|
|
692
|
+
"monthly_fee_usd",
|
|
693
|
+
"included_usage_usd",
|
|
694
|
+
"value"
|
|
695
|
+
]);
|
|
696
|
+
required2 = {
|
|
697
|
+
requests: ["id", "agent", "session_id", "model", "timestamp"],
|
|
698
|
+
sessions: ["id", "agent", "started_at"],
|
|
699
|
+
projects: ["id", "path", "name", "created_at"],
|
|
700
|
+
budgets: ["id", "period"],
|
|
701
|
+
goals: ["id", "period"],
|
|
702
|
+
billing_daily: ["date", "provider"],
|
|
703
|
+
model_pricing: ["model"],
|
|
704
|
+
subscriptions: ["id", "provider", "plan"],
|
|
705
|
+
usage_snapshots: ["id", "agent", "date", "metric", "unit"],
|
|
706
|
+
scan_receipts: ["id", "machine_id", "source", "attempted_at", "completed_at", "status", "parser_version"]
|
|
707
|
+
};
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// src/db/ingest-schema.ts
|
|
711
|
+
var INGEST_SCHEMA_SQL = `
|
|
712
|
+
CREATE TABLE IF NOT EXISTS ingest_session_locks (
|
|
713
|
+
session_id TEXT PRIMARY KEY
|
|
714
|
+
);
|
|
715
|
+
CREATE TABLE IF NOT EXISTS ingest_batches (
|
|
716
|
+
operation_key TEXT PRIMARY KEY,
|
|
717
|
+
actor_id TEXT NOT NULL,
|
|
718
|
+
payload_hash TEXT NOT NULL,
|
|
719
|
+
response_json TEXT NOT NULL,
|
|
720
|
+
created_at TEXT NOT NULL
|
|
721
|
+
);
|
|
722
|
+
CREATE TABLE IF NOT EXISTS ingest_preimages (
|
|
723
|
+
id TEXT PRIMARY KEY,
|
|
724
|
+
operation_key TEXT NOT NULL REFERENCES ingest_batches(operation_key),
|
|
725
|
+
table_name TEXT NOT NULL,
|
|
726
|
+
entity_key TEXT NOT NULL,
|
|
727
|
+
record_json TEXT NOT NULL,
|
|
728
|
+
record_sha256 TEXT NOT NULL,
|
|
729
|
+
recorded_at TEXT NOT NULL,
|
|
730
|
+
UNIQUE (operation_key, table_name, entity_key)
|
|
731
|
+
);
|
|
732
|
+
CREATE INDEX IF NOT EXISTS idx_ingest_preimages_entity
|
|
733
|
+
ON ingest_preimages(table_name, entity_key);
|
|
734
|
+
`;
|
|
735
|
+
|
|
736
|
+
// src/db/legacy-schema.ts
|
|
737
|
+
var LEGACY_LEDGER_SQL = `
|
|
738
|
+
CREATE TABLE IF NOT EXISTS legacy_reconciliations (
|
|
739
|
+
operation_key TEXT PRIMARY KEY NOT NULL,
|
|
740
|
+
payload_sha256 TEXT NOT NULL CHECK(length(payload_sha256)=64),
|
|
741
|
+
plan_sha256 TEXT NOT NULL CHECK(length(plan_sha256)=64),
|
|
742
|
+
capsule_sha256 TEXT NOT NULL CHECK(length(capsule_sha256)=64),
|
|
743
|
+
capsule_json TEXT NOT NULL,
|
|
744
|
+
actor_ref TEXT NOT NULL,
|
|
745
|
+
authority_ref TEXT NOT NULL,
|
|
746
|
+
created_at TEXT NOT NULL,
|
|
747
|
+
response_json TEXT NOT NULL
|
|
748
|
+
);
|
|
749
|
+
CREATE TABLE IF NOT EXISTS legacy_reconciliation_targets (
|
|
750
|
+
operation_key TEXT NOT NULL REFERENCES legacy_reconciliations(operation_key),
|
|
751
|
+
request_id TEXT NOT NULL,
|
|
752
|
+
session_id TEXT NOT NULL,
|
|
753
|
+
disposition TEXT NOT NULL CHECK(disposition IN ('quarantine_aggregate','mark_billing_unknown')),
|
|
754
|
+
original_sha256 TEXT NOT NULL CHECK(length(original_sha256)=64),
|
|
755
|
+
PRIMARY KEY(operation_key,request_id)
|
|
756
|
+
);
|
|
757
|
+
CREATE INDEX IF NOT EXISTS idx_legacy_targets_request ON legacy_reconciliation_targets(request_id);
|
|
758
|
+
CREATE INDEX IF NOT EXISTS idx_legacy_targets_session ON legacy_reconciliation_targets(session_id);
|
|
759
|
+
CREATE TABLE IF NOT EXISTS legacy_rollbacks (
|
|
760
|
+
operation_key TEXT PRIMARY KEY NOT NULL,
|
|
761
|
+
apply_key TEXT NOT NULL UNIQUE REFERENCES legacy_reconciliations(operation_key),
|
|
762
|
+
payload_sha256 TEXT NOT NULL CHECK(length(payload_sha256)=64),
|
|
763
|
+
actor_ref TEXT NOT NULL,
|
|
764
|
+
authority_ref TEXT NOT NULL,
|
|
765
|
+
created_at TEXT NOT NULL,
|
|
766
|
+
response_json TEXT NOT NULL
|
|
767
|
+
);
|
|
768
|
+
`, requestColumns, sessionColumns, q = (s) => '"' + s + '"', requestProjection, totals, sessionProjection, LEGACY_VIEWS_SQL, immutableTables, collision = (table) => table === "legacy_reconciliation_targets" ? "operation_key=NEW.operation_key AND request_id=NEW.request_id" : table === "legacy_rollbacks" ? "operation_key=NEW.operation_key OR apply_key=NEW.apply_key" : "operation_key=NEW.operation_key", LEGACY_SQLITE_SQL, pgViews, LEGACY_PG_SQL;
|
|
769
|
+
var init_legacy_schema = __esm(() => {
|
|
770
|
+
requestColumns = [
|
|
771
|
+
"id",
|
|
772
|
+
"agent",
|
|
773
|
+
"session_id",
|
|
774
|
+
"model",
|
|
775
|
+
"input_tokens",
|
|
776
|
+
"output_tokens",
|
|
777
|
+
"cache_read_tokens",
|
|
778
|
+
"cache_create_tokens",
|
|
779
|
+
"cache_create_5m_tokens",
|
|
780
|
+
"cache_create_1h_tokens",
|
|
781
|
+
"cost_usd",
|
|
782
|
+
"cost_basis",
|
|
783
|
+
"duration_ms",
|
|
784
|
+
"timestamp",
|
|
785
|
+
"source_request_id",
|
|
786
|
+
"machine_id",
|
|
787
|
+
"cost_center_id",
|
|
788
|
+
"attribution_tag",
|
|
789
|
+
"account_key",
|
|
790
|
+
"account_tool",
|
|
791
|
+
"account_name",
|
|
792
|
+
"account_email",
|
|
793
|
+
"account_source",
|
|
794
|
+
"updated_at",
|
|
795
|
+
"synced_at"
|
|
796
|
+
];
|
|
797
|
+
sessionColumns = [
|
|
798
|
+
"id",
|
|
799
|
+
"agent",
|
|
800
|
+
"project_path",
|
|
801
|
+
"project_name",
|
|
802
|
+
"started_at",
|
|
803
|
+
"ended_at",
|
|
804
|
+
"total_cost_usd",
|
|
805
|
+
"total_tokens",
|
|
806
|
+
"request_count",
|
|
807
|
+
"machine_id",
|
|
808
|
+
"cost_center_id",
|
|
809
|
+
"attribution_tag",
|
|
810
|
+
"account_key",
|
|
811
|
+
"account_tool",
|
|
812
|
+
"account_name",
|
|
813
|
+
"account_email",
|
|
814
|
+
"account_source",
|
|
815
|
+
"updated_at",
|
|
816
|
+
"synced_at"
|
|
817
|
+
];
|
|
818
|
+
requestProjection = requestColumns.map((column) => column === "cost_basis" ? "CASE WHEN EXISTS (SELECT 1 FROM economy_legacy_active a WHERE a.request_id=r.id AND a.disposition='mark_billing_unknown') THEN 'unknown' ELSE r.cost_basis END AS cost_basis" : "r." + q(column)).join(`,
|
|
819
|
+
`);
|
|
820
|
+
totals = {
|
|
821
|
+
total_cost_usd: "COALESCE(SUM(cost_usd),0)",
|
|
822
|
+
total_tokens: "COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0)",
|
|
823
|
+
request_count: "COUNT(*)"
|
|
824
|
+
};
|
|
825
|
+
sessionProjection = sessionColumns.map((column) => totals[column] ? `CASE WHEN EXISTS (SELECT 1 FROM economy_legacy_active a WHERE a.session_id=s.id)
|
|
826
|
+
THEN (SELECT ${totals[column]} FROM economy_effective_requests r WHERE r.session_id=s.id)
|
|
827
|
+
ELSE s.${q(column)} END AS ${q(column)}` : "s." + q(column)).join(`,
|
|
828
|
+
`);
|
|
829
|
+
LEGACY_VIEWS_SQL = `
|
|
830
|
+
CREATE VIEW IF NOT EXISTS economy_legacy_active AS
|
|
831
|
+
SELECT t.* FROM legacy_reconciliation_targets t
|
|
832
|
+
WHERE NOT EXISTS (SELECT 1 FROM legacy_rollbacks b WHERE b.apply_key=t.operation_key);
|
|
833
|
+
CREATE VIEW IF NOT EXISTS economy_effective_requests AS
|
|
834
|
+
SELECT ${requestProjection} FROM requests r
|
|
835
|
+
WHERE NOT EXISTS (SELECT 1 FROM economy_legacy_active a WHERE a.request_id=r.id AND a.disposition='quarantine_aggregate');
|
|
836
|
+
CREATE VIEW IF NOT EXISTS economy_effective_sessions AS
|
|
837
|
+
SELECT ${sessionProjection} FROM sessions s
|
|
838
|
+
WHERE NOT EXISTS (SELECT 1 FROM economy_legacy_active a WHERE a.session_id=s.id)
|
|
839
|
+
OR EXISTS (SELECT 1 FROM economy_effective_requests r WHERE r.session_id=s.id);
|
|
840
|
+
`;
|
|
841
|
+
immutableTables = ["legacy_reconciliations", "legacy_reconciliation_targets", "legacy_rollbacks"];
|
|
842
|
+
LEGACY_SQLITE_SQL = LEGACY_LEDGER_SQL + LEGACY_VIEWS_SQL + immutableTables.map((table) => `
|
|
843
|
+
CREATE TRIGGER IF NOT EXISTS ${table}_no_update BEFORE UPDATE ON ${table}
|
|
844
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation history is immutable'); END;
|
|
845
|
+
CREATE TRIGGER IF NOT EXISTS ${table}_no_delete BEFORE DELETE ON ${table}
|
|
846
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation history is immutable'); END;
|
|
847
|
+
CREATE TRIGGER IF NOT EXISTS ${table}_no_replace BEFORE INSERT ON ${table}
|
|
848
|
+
WHEN EXISTS(SELECT 1 FROM ${table} WHERE ${collision(table)})
|
|
849
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation history is immutable'); END;
|
|
850
|
+
`).join("") + `
|
|
851
|
+
CREATE TRIGGER IF NOT EXISTS legacy_target_no_overlap BEFORE INSERT ON legacy_reconciliation_targets
|
|
852
|
+
WHEN EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.session_id=NEW.session_id AND a.operation_key<>NEW.operation_key)
|
|
853
|
+
OR EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.request_id=NEW.request_id)
|
|
854
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation is already active for this session'); END;
|
|
855
|
+
CREATE TRIGGER IF NOT EXISTS legacy_request_no_insert BEFORE INSERT ON requests
|
|
856
|
+
WHEN EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.request_id=NEW.id)
|
|
857
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation protects this request'); END;
|
|
858
|
+
CREATE TRIGGER IF NOT EXISTS legacy_request_no_update BEFORE UPDATE ON requests
|
|
859
|
+
WHEN EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.request_id=NEW.id OR a.request_id=OLD.id)
|
|
860
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation protects this request'); END;
|
|
861
|
+
CREATE TRIGGER IF NOT EXISTS legacy_request_no_delete BEFORE DELETE ON requests
|
|
862
|
+
WHEN EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.request_id=OLD.id)
|
|
863
|
+
BEGIN SELECT RAISE(ABORT,'legacy reconciliation protects this request'); END;
|
|
864
|
+
`;
|
|
865
|
+
pgViews = LEGACY_VIEWS_SQL.replace(/CREATE VIEW IF NOT EXISTS (\w+) AS/g, "CREATE VIEW $1 WITH (security_invoker=true) AS");
|
|
866
|
+
LEGACY_PG_SQL = LEGACY_LEDGER_SQL + pgViews + `
|
|
867
|
+
CREATE FUNCTION economy_legacy_history_immutable() RETURNS trigger LANGUAGE plpgsql AS $body$
|
|
868
|
+
BEGIN
|
|
869
|
+
RAISE EXCEPTION 'legacy reconciliation history is immutable' USING ERRCODE='23000';
|
|
870
|
+
END
|
|
871
|
+
$body$;
|
|
872
|
+
` + immutableTables.map((table) => `
|
|
873
|
+
CREATE TRIGGER ${table}_immutable BEFORE UPDATE OR DELETE OR TRUNCATE ON ${table}
|
|
874
|
+
FOR EACH STATEMENT EXECUTE FUNCTION economy_legacy_history_immutable();
|
|
875
|
+
`).join("") + `
|
|
876
|
+
CREATE FUNCTION economy_legacy_request_guard() RETURNS trigger LANGUAGE plpgsql AS $body$
|
|
877
|
+
BEGIN
|
|
878
|
+
IF TG_OP='TRUNCATE' THEN
|
|
879
|
+
IF EXISTS(SELECT 1 FROM economy_legacy_active) THEN
|
|
880
|
+
RAISE EXCEPTION 'legacy reconciliation protects requests' USING ERRCODE='23000';
|
|
881
|
+
END IF;
|
|
882
|
+
RETURN NULL;
|
|
883
|
+
END IF;
|
|
884
|
+
IF TG_OP IN ('INSERT','UPDATE') THEN
|
|
885
|
+
IF EXISTS(SELECT 1 FROM economy_legacy_active WHERE request_id=NEW.id) THEN
|
|
886
|
+
RAISE EXCEPTION 'legacy reconciliation protects this request' USING ERRCODE='23000';
|
|
887
|
+
END IF;
|
|
888
|
+
END IF;
|
|
889
|
+
IF TG_OP IN ('UPDATE','DELETE') THEN
|
|
890
|
+
IF EXISTS(SELECT 1 FROM economy_legacy_active WHERE request_id=OLD.id) THEN
|
|
891
|
+
RAISE EXCEPTION 'legacy reconciliation protects this request' USING ERRCODE='23000';
|
|
892
|
+
END IF;
|
|
893
|
+
END IF;
|
|
894
|
+
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
|
895
|
+
RETURN NEW;
|
|
896
|
+
END
|
|
897
|
+
$body$;
|
|
898
|
+
CREATE TRIGGER legacy_request_guard BEFORE INSERT OR UPDATE OR DELETE ON requests
|
|
899
|
+
FOR EACH ROW EXECUTE FUNCTION economy_legacy_request_guard();
|
|
900
|
+
CREATE TRIGGER legacy_request_no_truncate BEFORE TRUNCATE ON requests
|
|
901
|
+
FOR EACH STATEMENT EXECUTE FUNCTION economy_legacy_request_guard();
|
|
902
|
+
CREATE FUNCTION economy_legacy_target_guard() RETURNS trigger LANGUAGE plpgsql AS $body$
|
|
903
|
+
BEGIN
|
|
904
|
+
IF EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.session_id=NEW.session_id AND a.operation_key<>NEW.operation_key)
|
|
905
|
+
OR EXISTS(SELECT 1 FROM economy_legacy_active a WHERE a.request_id=NEW.request_id) THEN
|
|
906
|
+
RAISE EXCEPTION 'legacy reconciliation is already active for this session' USING ERRCODE='23000';
|
|
907
|
+
END IF;
|
|
908
|
+
RETURN NEW;
|
|
909
|
+
END
|
|
910
|
+
$body$;
|
|
911
|
+
CREATE TRIGGER legacy_target_no_overlap BEFORE INSERT ON legacy_reconciliation_targets
|
|
912
|
+
FOR EACH ROW EXECUTE FUNCTION economy_legacy_target_guard();
|
|
913
|
+
`;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// src/db/ingest.ts
|
|
917
|
+
var init_ingest = __esm(() => {
|
|
918
|
+
init_ingest_errors();
|
|
919
|
+
init_ingest_validation();
|
|
920
|
+
});
|
|
75
921
|
// src/lib/pricing.ts
|
|
76
922
|
var exports_pricing = {};
|
|
77
923
|
__export(exports_pricing, {
|
|
@@ -83,6 +929,9 @@ __export(exports_pricing, {
|
|
|
83
929
|
ensurePricingSeeded: () => ensurePricingSeeded,
|
|
84
930
|
computeCostFromDb: () => computeCostFromDb,
|
|
85
931
|
computeCost: () => computeCost,
|
|
932
|
+
assessCostFromRows: () => assessCostFromRows,
|
|
933
|
+
assessCostFromDb: () => assessCostFromDb,
|
|
934
|
+
UnknownModelPricingError: () => UnknownModelPricingError,
|
|
86
935
|
DEFAULT_PRICING: () => DEFAULT_PRICING
|
|
87
936
|
});
|
|
88
937
|
function normalizeModelName(raw) {
|
|
@@ -259,16 +1108,18 @@ function isFreeModel(model) {
|
|
|
259
1108
|
return model.trim().toLowerCase().endsWith(":free");
|
|
260
1109
|
}
|
|
261
1110
|
function computeCost(model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
262
|
-
|
|
263
|
-
if (!pricing)
|
|
264
|
-
return 0;
|
|
265
|
-
return computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
1111
|
+
return requirePricedCost(assessResolvedCost(model, null, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours));
|
|
266
1112
|
}
|
|
267
1113
|
function computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1114
|
+
return requirePricedCost(assessCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours));
|
|
1115
|
+
}
|
|
1116
|
+
function requirePricedCost(assessment) {
|
|
1117
|
+
if (assessment.usd === null)
|
|
1118
|
+
throw new UnknownModelPricingError;
|
|
1119
|
+
return assessment.usd;
|
|
1120
|
+
}
|
|
1121
|
+
function assessCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
1122
|
+
return assessResolvedCost(model, getPricingFromDb(db, model), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
272
1123
|
}
|
|
273
1124
|
function getPricingFromRows(rows, model) {
|
|
274
1125
|
if (isFreeModel(model))
|
|
@@ -284,10 +1135,32 @@ function getPricingFromRows(rows, model) {
|
|
|
284
1135
|
return modelPricingFromDbRow(match);
|
|
285
1136
|
}
|
|
286
1137
|
function estimateCostFromRows(rows, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
287
|
-
|
|
1138
|
+
return requirePricedCost(assessCostFromRows(rows, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours));
|
|
1139
|
+
}
|
|
1140
|
+
function assessCostFromRows(rows, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
1141
|
+
return assessResolvedCost(model, getPricingFromRows(rows, model), inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
1142
|
+
}
|
|
1143
|
+
function assessResolvedCost(model, configured, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours) {
|
|
1144
|
+
const counts = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens };
|
|
1145
|
+
for (const [field, value] of Object.entries(counts)) {
|
|
1146
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1147
|
+
throw new RangeError(`${field} must be a nonnegative safe integer`);
|
|
1148
|
+
}
|
|
1149
|
+
if (!Number.isFinite(cacheStorageTokenHours) || cacheStorageTokenHours < 0) {
|
|
1150
|
+
throw new RangeError("cacheStorageTokenHours must be finite and nonnegative");
|
|
1151
|
+
}
|
|
1152
|
+
if (isFreeModel(model))
|
|
1153
|
+
return { usd: 0, pricing: "explicit_free" };
|
|
1154
|
+
const pricing = configured ?? getPricing(model);
|
|
288
1155
|
if (!pricing)
|
|
289
|
-
return
|
|
290
|
-
|
|
1156
|
+
return { usd: null, pricing: "missing" };
|
|
1157
|
+
if (Object.values(pricing).some((value) => value !== undefined && (!Number.isFinite(value) || value < 0))) {
|
|
1158
|
+
throw new RangeError("Model pricing rates must be finite and nonnegative");
|
|
1159
|
+
}
|
|
1160
|
+
const usd = computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
1161
|
+
if (!Number.isFinite(usd))
|
|
1162
|
+
throw new RangeError("Cost estimate exceeds the supported numeric range");
|
|
1163
|
+
return { usd, pricing: configured ? "configured" : "catalog" };
|
|
291
1164
|
}
|
|
292
1165
|
function computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours) {
|
|
293
1166
|
if (isFreeModel(model))
|
|
@@ -308,7 +1181,7 @@ function computeCostWithPricing(model, pricing, inputTokens, outputTokens, cache
|
|
|
308
1181
|
}
|
|
309
1182
|
return (inputTokens * effective.inputPer1M + outputTokens * effective.outputPer1M + cacheReadTokens * effective.cacheReadPer1M + cacheWriteTokens * effective.cacheWritePer1M + cacheWrite1hTokens * (effective.cacheWrite1hPer1M ?? effective.cacheWritePer1M) + cacheStorageTokenHours * (effective.cacheStoragePer1MHour ?? 0)) / 1e6;
|
|
310
1183
|
}
|
|
311
|
-
var DEFAULT_PRICING, LEGACY_DEFAULT_PRICING, ADDITIONAL_LEGACY_DEFAULT_PRICING, REMOVED_DEFAULT_PRICING, FREE_PRICING, GEMINI_PROMPT_TIERS, OPENAI_PROMPT_TIERS, QWEN_PROMPT_TIERS, MINIMAX_PROMPT_TIERS, XAI_PROMPT_TIERS;
|
|
1184
|
+
var DEFAULT_PRICING, LEGACY_DEFAULT_PRICING, ADDITIONAL_LEGACY_DEFAULT_PRICING, REMOVED_DEFAULT_PRICING, FREE_PRICING, GEMINI_PROMPT_TIERS, OPENAI_PROMPT_TIERS, QWEN_PROMPT_TIERS, MINIMAX_PROMPT_TIERS, XAI_PROMPT_TIERS, UnknownModelPricingError;
|
|
312
1185
|
var init_pricing = __esm(() => {
|
|
313
1186
|
init_database();
|
|
314
1187
|
DEFAULT_PRICING = {
|
|
@@ -587,6 +1460,17 @@ var init_pricing = __esm(() => {
|
|
|
587
1460
|
cacheReadPer1M: 0
|
|
588
1461
|
}
|
|
589
1462
|
};
|
|
1463
|
+
UnknownModelPricingError = class UnknownModelPricingError extends Error {
|
|
1464
|
+
code = "MODEL_PRICING_UNAVAILABLE";
|
|
1465
|
+
constructor() {
|
|
1466
|
+
super("MODEL_PRICING_UNAVAILABLE: Pricing unavailable for model; configure its rates before estimating cost");
|
|
1467
|
+
this.name = "UnknownModelPricingError";
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
});
|
|
1471
|
+
// src/lib/summary-contract.ts
|
|
1472
|
+
var init_summary_contract = __esm(() => {
|
|
1473
|
+
init_pricing();
|
|
590
1474
|
});
|
|
591
1475
|
|
|
592
1476
|
// src/db/database.ts
|
|
@@ -595,7 +1479,62 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
|
595
1479
|
import { hostname, platform } from "os";
|
|
596
1480
|
import { homedir } from "os";
|
|
597
1481
|
import { join, resolve } from "path";
|
|
598
|
-
import {
|
|
1482
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
1483
|
+
import { join as pathsResolverJoin } from "path";
|
|
1484
|
+
function pathsResolverAssertApp(app) {
|
|
1485
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
1486
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
1487
|
+
}
|
|
1488
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
1489
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function pathsResolverAssertKind(kind) {
|
|
1493
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
1494
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
function pathsResolverBaseDir(kind, options) {
|
|
1498
|
+
pathsResolverAssertKind(kind);
|
|
1499
|
+
const env = options.env ?? process.env;
|
|
1500
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
1501
|
+
if (typeof override === "string" && override.length > 0)
|
|
1502
|
+
return override;
|
|
1503
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
1504
|
+
const platform2 = options.platform ?? process.platform;
|
|
1505
|
+
if (platform2 === "darwin") {
|
|
1506
|
+
switch (kind) {
|
|
1507
|
+
case "config":
|
|
1508
|
+
case "data":
|
|
1509
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
1510
|
+
case "cache":
|
|
1511
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
1512
|
+
case "state":
|
|
1513
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
switch (kind) {
|
|
1517
|
+
case "config":
|
|
1518
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
1519
|
+
case "data":
|
|
1520
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
1521
|
+
case "state":
|
|
1522
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
1523
|
+
case "cache":
|
|
1524
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function pathsResolverResolve(kind, options) {
|
|
1528
|
+
pathsResolverAssertApp(options.app);
|
|
1529
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
1530
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
1531
|
+
}
|
|
1532
|
+
function dataDir(options) {
|
|
1533
|
+
return pathsResolverResolve("data", options);
|
|
1534
|
+
}
|
|
1535
|
+
function cacheDir(options) {
|
|
1536
|
+
return pathsResolverResolve("cache", options);
|
|
1537
|
+
}
|
|
599
1538
|
function normalizeMachineId(value) {
|
|
600
1539
|
const id = value?.trim().toLowerCase().split(".")[0];
|
|
601
1540
|
return id && id.length > 0 ? id : null;
|
|
@@ -618,7 +1557,7 @@ function macHostMachineId() {
|
|
|
618
1557
|
return null;
|
|
619
1558
|
}
|
|
620
1559
|
function getMachineId() {
|
|
621
|
-
const envMachine = normalizeMachineId(process.env["
|
|
1560
|
+
const envMachine = normalizeMachineId(process.env["HASNA_ECONOMY_MACHINE_ID"]);
|
|
622
1561
|
if (envMachine)
|
|
623
1562
|
return envMachine;
|
|
624
1563
|
const hostMachine = normalizeMachineId(hostname()) ?? "unknown";
|
|
@@ -676,59 +1615,6 @@ function getDbPath() {
|
|
|
676
1615
|
return process.env["ECONOMY_DB"];
|
|
677
1616
|
return join(getDataDir(), "economy.db");
|
|
678
1617
|
}
|
|
679
|
-
function isSqliteBusyError(error) {
|
|
680
|
-
const candidate = error;
|
|
681
|
-
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
682
|
-
const message = typeof candidate.message === "string" ? candidate.message : String(error);
|
|
683
|
-
return code === "SQLITE_BUSY" || code === "SQLITE_BUSY_RECOVERY" || /database is locked|SQLITE_BUSY/i.test(message);
|
|
684
|
-
}
|
|
685
|
-
function retryDelayMs(attempt) {
|
|
686
|
-
return Math.min(1000, 50 * 2 ** attempt);
|
|
687
|
-
}
|
|
688
|
-
function withSqliteBusyRetry(operation, context) {
|
|
689
|
-
const maxAttempts = 8;
|
|
690
|
-
let lastError;
|
|
691
|
-
for (let attempt = 0;attempt < maxAttempts; attempt++) {
|
|
692
|
-
try {
|
|
693
|
-
return operation();
|
|
694
|
-
} catch (error) {
|
|
695
|
-
if (!isSqliteBusyError(error))
|
|
696
|
-
throw error;
|
|
697
|
-
lastError = error;
|
|
698
|
-
if (attempt === maxAttempts - 1)
|
|
699
|
-
break;
|
|
700
|
-
Bun.sleepSync(retryDelayMs(attempt));
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
throw new Error(`SQLite database is locked after ${maxAttempts} attempts while ${context}. Another economy sync/merge may be recovering the database; retry shortly.`, { cause: lastError });
|
|
704
|
-
}
|
|
705
|
-
function openDatabase(dbPath, skipSeed = false) {
|
|
706
|
-
const path = dbPath ?? getDbPath();
|
|
707
|
-
if (path !== ":memory:") {
|
|
708
|
-
const dir = path.substring(0, path.lastIndexOf("/"));
|
|
709
|
-
if (dir && !existsSync(dir))
|
|
710
|
-
mkdirSync(dir, { recursive: true });
|
|
711
|
-
}
|
|
712
|
-
const db = withSqliteBusyRetry(() => {
|
|
713
|
-
const opened = new SqliteAdapter(path);
|
|
714
|
-
try {
|
|
715
|
-
opened.exec("PRAGMA busy_timeout = 10000");
|
|
716
|
-
opened.exec("PRAGMA journal_mode = WAL");
|
|
717
|
-
opened.exec("PRAGMA foreign_keys = ON");
|
|
718
|
-
initSchema(opened);
|
|
719
|
-
return opened;
|
|
720
|
-
} catch (error) {
|
|
721
|
-
try {
|
|
722
|
-
opened.close();
|
|
723
|
-
} catch {}
|
|
724
|
-
throw error;
|
|
725
|
-
}
|
|
726
|
-
}, `opening ${path}`);
|
|
727
|
-
if (!skipSeed) {
|
|
728
|
-
Promise.resolve().then(() => (init_pricing(), exports_pricing)).then(({ ensurePricingSeeded: ensurePricingSeeded2 }) => ensurePricingSeeded2(db)).catch(() => {});
|
|
729
|
-
}
|
|
730
|
-
return db;
|
|
731
|
-
}
|
|
732
1618
|
function quoteSqlIdent(identifier) {
|
|
733
1619
|
return `"${identifier.replace(/"/g, '""')}"`;
|
|
734
1620
|
}
|
|
@@ -750,6 +1636,7 @@ function addColumnIfMissing(db, table, column, definition) {
|
|
|
750
1636
|
}
|
|
751
1637
|
}
|
|
752
1638
|
function initSchema(db) {
|
|
1639
|
+
db.exec(INGEST_SCHEMA_SQL);
|
|
753
1640
|
db.exec(`
|
|
754
1641
|
CREATE TABLE IF NOT EXISTS cost_centers (
|
|
755
1642
|
id TEXT PRIMARY KEY,
|
|
@@ -941,6 +1828,7 @@ function initSchema(db) {
|
|
|
941
1828
|
addColumnIfMissing(db, "requests", "cost_center_id", `TEXT`);
|
|
942
1829
|
addColumnIfMissing(db, "sessions", "cost_center_id", `TEXT`);
|
|
943
1830
|
addColumnIfMissing(db, "budgets", "cost_center_id", `TEXT`);
|
|
1831
|
+
addColumnIfMissing(db, "budgets", "metric", `TEXT NOT NULL DEFAULT 'legacy_request_cost_usd' CHECK (metric IN ('legacy_request_cost_usd', 'metered_api_usd'))`);
|
|
944
1832
|
if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
|
|
945
1833
|
db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
|
|
946
1834
|
}
|
|
@@ -974,6 +1862,12 @@ function initSchema(db) {
|
|
|
974
1862
|
CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id);
|
|
975
1863
|
CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
|
|
976
1864
|
`);
|
|
1865
|
+
for (const [table, columns] of Object.entries(OBSERVATION_COLUMNS)) {
|
|
1866
|
+
for (const [column, type] of Object.entries(columns))
|
|
1867
|
+
addColumnIfMissing(db, table, column, type);
|
|
1868
|
+
}
|
|
1869
|
+
db.exec(SCAN_RECEIPTS_SQL);
|
|
1870
|
+
db.exec(LEGACY_SQLITE_SQL);
|
|
977
1871
|
}
|
|
978
1872
|
function upsertCostCenter(db, center) {
|
|
979
1873
|
const updatedAt = center.updated_at ?? center.created_at;
|
|
@@ -984,7 +1878,7 @@ function upsertCostCenter(db, center) {
|
|
|
984
1878
|
`).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
|
|
985
1879
|
}
|
|
986
1880
|
function upsertRequest(db, req) {
|
|
987
|
-
const now = req.updated_at ??
|
|
1881
|
+
const now = req.updated_at ?? req.timestamp;
|
|
988
1882
|
db.prepare(`
|
|
989
1883
|
INSERT OR REPLACE INTO requests
|
|
990
1884
|
(id, agent, session_id, model, input_tokens, output_tokens,
|
|
@@ -996,7 +1890,7 @@ function upsertRequest(db, req) {
|
|
|
996
1890
|
`).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 ?? "");
|
|
997
1891
|
}
|
|
998
1892
|
function upsertSession(db, session) {
|
|
999
|
-
const now = session.updated_at ??
|
|
1893
|
+
const now = session.updated_at ?? session.ended_at ?? session.started_at;
|
|
1000
1894
|
db.prepare(`
|
|
1001
1895
|
INSERT OR REPLACE INTO sessions
|
|
1002
1896
|
(id, agent, project_path, project_name, started_at, ended_at,
|
|
@@ -1008,30 +1902,30 @@ function upsertSession(db, session) {
|
|
|
1008
1902
|
function rollupSession(db, sessionId) {
|
|
1009
1903
|
db.prepare(`
|
|
1010
1904
|
UPDATE sessions SET
|
|
1011
|
-
total_cost_usd = (SELECT COALESCE(SUM(cost_usd), 0) FROM
|
|
1012
|
-
total_tokens = (SELECT COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) FROM
|
|
1013
|
-
request_count = (SELECT COUNT(*) FROM
|
|
1014
|
-
ended_at = (SELECT MAX(timestamp) FROM
|
|
1905
|
+
total_cost_usd = (SELECT COALESCE(SUM(cost_usd), 0) FROM economy_effective_requests WHERE session_id = ?),
|
|
1906
|
+
total_tokens = (SELECT COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) FROM economy_effective_requests WHERE session_id = ?),
|
|
1907
|
+
request_count = (SELECT COUNT(*) FROM economy_effective_requests WHERE session_id = ?),
|
|
1908
|
+
ended_at = (SELECT MAX(timestamp) FROM economy_effective_requests WHERE session_id = ?),
|
|
1015
1909
|
started_at = CASE WHEN started_at = '' OR started_at IS NULL
|
|
1016
|
-
THEN (SELECT MIN(timestamp) FROM
|
|
1910
|
+
THEN (SELECT MIN(timestamp) FROM economy_effective_requests WHERE session_id = ?)
|
|
1017
1911
|
ELSE started_at END,
|
|
1018
1912
|
account_key = CASE WHEN account_key = '' OR account_key IS NULL
|
|
1019
|
-
THEN COALESCE((SELECT account_key FROM
|
|
1913
|
+
THEN COALESCE((SELECT account_key FROM economy_effective_requests WHERE session_id = ? AND account_key != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
1020
1914
|
ELSE account_key END,
|
|
1021
1915
|
account_tool = CASE WHEN account_tool = '' OR account_tool IS NULL
|
|
1022
|
-
THEN COALESCE((SELECT account_tool FROM
|
|
1916
|
+
THEN COALESCE((SELECT account_tool FROM economy_effective_requests WHERE session_id = ? AND account_tool != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
1023
1917
|
ELSE account_tool END,
|
|
1024
1918
|
account_name = CASE WHEN account_name = '' OR account_name IS NULL
|
|
1025
|
-
THEN COALESCE((SELECT account_name FROM
|
|
1919
|
+
THEN COALESCE((SELECT account_name FROM economy_effective_requests WHERE session_id = ? AND account_name != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
1026
1920
|
ELSE account_name END,
|
|
1027
1921
|
account_email = CASE WHEN account_email = '' OR account_email IS NULL
|
|
1028
|
-
THEN COALESCE((SELECT account_email FROM
|
|
1922
|
+
THEN COALESCE((SELECT account_email FROM economy_effective_requests WHERE session_id = ? AND account_email != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
1029
1923
|
ELSE account_email END,
|
|
1030
1924
|
account_source = CASE WHEN account_source = '' OR account_source IS NULL
|
|
1031
|
-
THEN COALESCE((SELECT account_source FROM
|
|
1925
|
+
THEN COALESCE((SELECT account_source FROM economy_effective_requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
|
|
1032
1926
|
ELSE account_source END,
|
|
1033
1927
|
cost_center_id = CASE WHEN cost_center_id = '' OR cost_center_id IS NULL
|
|
1034
|
-
THEN COALESCE((SELECT cost_center_id FROM
|
|
1928
|
+
THEN COALESCE((SELECT cost_center_id FROM economy_effective_requests WHERE session_id = ? AND cost_center_id IS NOT NULL AND cost_center_id != '' ORDER BY timestamp DESC LIMIT 1), NULL)
|
|
1035
1929
|
ELSE cost_center_id END
|
|
1036
1930
|
WHERE id = ?
|
|
1037
1931
|
`).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
|
|
@@ -1067,32 +1961,91 @@ function seedModelPricing(db, defaults) {
|
|
|
1067
1961
|
});
|
|
1068
1962
|
}
|
|
1069
1963
|
}
|
|
1964
|
+
var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE;
|
|
1070
1965
|
var init_database = __esm(() => {
|
|
1966
|
+
init_observation_schema();
|
|
1967
|
+
init_observation_provenance();
|
|
1968
|
+
init_ingest_validation();
|
|
1969
|
+
init_legacy_schema();
|
|
1970
|
+
init_ingest();
|
|
1971
|
+
init_ingest_errors();
|
|
1972
|
+
init_summary_contract();
|
|
1973
|
+
PATHS_RESOLVER_KIND_ENV = {
|
|
1974
|
+
config: "HASNA_CONFIG_HOME",
|
|
1975
|
+
data: "HASNA_DATA_HOME",
|
|
1976
|
+
state: "HASNA_STATE_HOME",
|
|
1977
|
+
cache: "HASNA_CACHE_HOME"
|
|
1978
|
+
};
|
|
1979
|
+
PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
// src/db/sqlite-store.ts
|
|
1983
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
1984
|
+
function isSqliteBusyError(error) {
|
|
1985
|
+
const candidate = error;
|
|
1986
|
+
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
1987
|
+
const message = typeof candidate.message === "string" ? candidate.message : String(error);
|
|
1988
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_BUSY_RECOVERY" || /database is locked|SQLITE_BUSY/i.test(message);
|
|
1989
|
+
}
|
|
1990
|
+
function retryDelayMs(attempt) {
|
|
1991
|
+
return Math.min(1000, 50 * 2 ** attempt);
|
|
1992
|
+
}
|
|
1993
|
+
function withSqliteBusyRetry(operation, context) {
|
|
1994
|
+
const maxAttempts = 8;
|
|
1995
|
+
let lastError;
|
|
1996
|
+
for (let attempt = 0;attempt < maxAttempts; attempt++) {
|
|
1997
|
+
try {
|
|
1998
|
+
return operation();
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
if (!isSqliteBusyError(error))
|
|
2001
|
+
throw error;
|
|
2002
|
+
lastError = error;
|
|
2003
|
+
if (attempt === maxAttempts - 1)
|
|
2004
|
+
break;
|
|
2005
|
+
Bun.sleepSync(retryDelayMs(attempt));
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
throw new Error(`SQLite database is locked after ${maxAttempts} attempts while ${context}. Another economy sync/merge may be recovering the database; retry shortly.`, { cause: lastError });
|
|
2009
|
+
}
|
|
2010
|
+
function openDatabase(dbPath, skipSeed = false) {
|
|
2011
|
+
const path = dbPath ?? getDbPath();
|
|
2012
|
+
if (path !== ":memory:") {
|
|
2013
|
+
const dir = path.substring(0, path.lastIndexOf("/"));
|
|
2014
|
+
if (dir && !existsSync2(dir))
|
|
2015
|
+
mkdirSync2(dir, { recursive: true });
|
|
2016
|
+
}
|
|
2017
|
+
const db = withSqliteBusyRetry(() => {
|
|
2018
|
+
const opened = new SqliteAdapter(path);
|
|
2019
|
+
try {
|
|
2020
|
+
opened.exec("PRAGMA busy_timeout = 10000");
|
|
2021
|
+
opened.exec("PRAGMA journal_mode = WAL");
|
|
2022
|
+
opened.exec("PRAGMA foreign_keys = ON");
|
|
2023
|
+
initSchema(opened);
|
|
2024
|
+
return opened;
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
try {
|
|
2027
|
+
opened.close();
|
|
2028
|
+
} catch {}
|
|
2029
|
+
throw error;
|
|
2030
|
+
}
|
|
2031
|
+
}, `opening ${path}`);
|
|
2032
|
+
if (!skipSeed) {
|
|
2033
|
+
Promise.resolve().then(() => (init_pricing(), exports_pricing)).then(({ ensurePricingSeeded: ensurePricingSeeded2 }) => ensurePricingSeeded2(db)).catch(() => {});
|
|
2034
|
+
}
|
|
2035
|
+
return db;
|
|
2036
|
+
}
|
|
2037
|
+
var init_sqlite_store = __esm(() => {
|
|
1071
2038
|
init_sqlite_adapter();
|
|
2039
|
+
init_database();
|
|
2040
|
+
init_database();
|
|
1072
2041
|
});
|
|
1073
2042
|
|
|
1074
2043
|
// src/otel/index.ts
|
|
1075
|
-
|
|
2044
|
+
init_sqlite_store();
|
|
1076
2045
|
|
|
1077
2046
|
// src/ingest/otel.ts
|
|
1078
2047
|
init_database();
|
|
1079
|
-
|
|
1080
|
-
// src/lib/agents.ts
|
|
1081
|
-
var AGENTS = [
|
|
1082
|
-
"claude",
|
|
1083
|
-
"takumi",
|
|
1084
|
-
"codex",
|
|
1085
|
-
"gemini",
|
|
1086
|
-
"opencode",
|
|
1087
|
-
"cursor",
|
|
1088
|
-
"pi",
|
|
1089
|
-
"hermes"
|
|
1090
|
-
];
|
|
1091
|
-
function isAgent(value) {
|
|
1092
|
-
return AGENTS.includes(value);
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// src/ingest/otel.ts
|
|
2048
|
+
init_agents();
|
|
1096
2049
|
function attrValue(attr) {
|
|
1097
2050
|
if (!attr?.value)
|
|
1098
2051
|
return;
|
|
@@ -1413,19 +2366,1109 @@ async function ingestOtelRows(db, rows) {
|
|
|
1413
2366
|
rollupSession(db, sessionId);
|
|
1414
2367
|
return { requests, sessions: sessions.size };
|
|
1415
2368
|
}
|
|
2369
|
+
|
|
2370
|
+
// src/lib/cloud-ingest.ts
|
|
2371
|
+
init_database();
|
|
2372
|
+
init_pricing();
|
|
2373
|
+
|
|
2374
|
+
// src/ingest/collection-outcome.ts
|
|
2375
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2376
|
+
var context = new AsyncLocalStorage;
|
|
2377
|
+
|
|
2378
|
+
// src/ingest/claude.ts
|
|
2379
|
+
init_database();
|
|
2380
|
+
init_pricing();
|
|
2381
|
+
import { homedir as homedir3 } from "os";
|
|
2382
|
+
import { join as join3, basename } from "path";
|
|
2383
|
+
|
|
2384
|
+
// src/lib/accounts-store.ts
|
|
2385
|
+
import { homedir as homedir2 } from "os";
|
|
2386
|
+
import { join as join2 } from "path";
|
|
2387
|
+
import { z } from "zod";
|
|
2388
|
+
import { resolveClientTransport, resolveCredential } from "@hasna/contracts/client";
|
|
2389
|
+
|
|
2390
|
+
// src/lib/cloud-storage.ts
|
|
2391
|
+
import {
|
|
2392
|
+
appConfigDiskValue,
|
|
2393
|
+
ClientTransportConfigurationError,
|
|
2394
|
+
clientTransportEnvKeys,
|
|
2395
|
+
createClientTransport,
|
|
2396
|
+
credentialDiskSources,
|
|
2397
|
+
credentialOverrideEnvKey,
|
|
2398
|
+
credentialPointerEnvKey,
|
|
2399
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
2400
|
+
keychainConfigValue
|
|
2401
|
+
} from "@hasna/contracts/client";
|
|
2402
|
+
import { createHasnaStorageClient } from "@hasna/contracts/client/storage";
|
|
2403
|
+
var ECONOMY_APP = "economy";
|
|
2404
|
+
var LOCAL_STORAGE_OPT_IN_KEYS = ["HASNA_ECONOMY_LOCAL", "ECONOMY_LOCAL"];
|
|
2405
|
+
function envFlagSet(env, key) {
|
|
2406
|
+
const raw = env[key];
|
|
2407
|
+
if (raw === undefined)
|
|
2408
|
+
return false;
|
|
2409
|
+
const value = raw.trim().toLowerCase();
|
|
2410
|
+
return value !== "" && value !== "0" && value !== "false" && value !== "no" && value !== "off";
|
|
2411
|
+
}
|
|
2412
|
+
function localStorageExplicitlyOptedIn(env) {
|
|
2413
|
+
return LOCAL_STORAGE_OPT_IN_KEYS.some((key) => envFlagSet(env, key));
|
|
2414
|
+
}
|
|
2415
|
+
function economyAuthorityEnvKeys() {
|
|
2416
|
+
const keys = clientTransportEnvKeys(ECONOMY_APP);
|
|
2417
|
+
return [
|
|
2418
|
+
...keys.apiUrlKeys,
|
|
2419
|
+
...keys.apiKeyKeys,
|
|
2420
|
+
credentialOverrideEnvKey(ECONOMY_APP),
|
|
2421
|
+
credentialPointerEnvKey(ECONOMY_APP),
|
|
2422
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
2423
|
+
];
|
|
2424
|
+
}
|
|
2425
|
+
function hasEconomyEnvAuthorityIntent(env) {
|
|
2426
|
+
return economyAuthorityEnvKeys().some((key) => (env[key] ?? "").trim().length > 0);
|
|
2427
|
+
}
|
|
2428
|
+
function hostedAuthorityConfigured(env, options = {}) {
|
|
2429
|
+
const keys = clientTransportEnvKeys(ECONOMY_APP);
|
|
2430
|
+
if (keys.apiUrlKeys.some((key) => (env[key] ?? "").trim().length > 0))
|
|
2431
|
+
return true;
|
|
2432
|
+
if (keychainConfigValue(ECONOMY_APP, env, options.credentials?.keychain) !== null)
|
|
2433
|
+
return true;
|
|
2434
|
+
return appConfigDiskValue(ECONOMY_APP, env, keys.apiUrlKeys) !== null;
|
|
2435
|
+
}
|
|
2436
|
+
function economyResolverEnv(env) {
|
|
2437
|
+
const blanks = economyAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
2438
|
+
if (blanks.length === 0)
|
|
2439
|
+
return env;
|
|
2440
|
+
const next = { ...env };
|
|
2441
|
+
for (const key of blanks)
|
|
2442
|
+
delete next[key];
|
|
2443
|
+
return next;
|
|
2444
|
+
}
|
|
2445
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2446
|
+
function isAmbientEconomyEnv(env) {
|
|
2447
|
+
if (typeof process !== "undefined" && env === process.env)
|
|
2448
|
+
return true;
|
|
2449
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
2450
|
+
}
|
|
2451
|
+
function economyResolverInputs(env, credentials = {}) {
|
|
2452
|
+
const normalised = economyResolverEnv(env);
|
|
2453
|
+
if (normalised === env)
|
|
2454
|
+
return { env: normalised, credentials };
|
|
2455
|
+
const keychain = { ...credentials.keychain };
|
|
2456
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
2457
|
+
keychain.enabled = isAmbientEconomyEnv(env);
|
|
2458
|
+
}
|
|
2459
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
2460
|
+
}
|
|
2461
|
+
function resolveEconomyStorageClient(name, env, overrides = {}) {
|
|
2462
|
+
const { env: resolverEnv, credentials } = economyResolverInputs(env, overrides.credentials);
|
|
2463
|
+
const wired = createClientTransport(name, resolverEnv, {
|
|
2464
|
+
credentials,
|
|
2465
|
+
...overrides.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2466
|
+
...overrides.headers ? { headers: overrides.headers } : {},
|
|
2467
|
+
...overrides.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2468
|
+
...overrides.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2469
|
+
...overrides.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2470
|
+
});
|
|
2471
|
+
return {
|
|
2472
|
+
transport: "http",
|
|
2473
|
+
client: createHasnaStorageClient(name, wired.client),
|
|
2474
|
+
resolution: wired.resolution
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
function localEconomyNotice() {
|
|
2478
|
+
return `economy: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 reading and writing the on-box ` + `SQLite store, not the hosted fleet. Data in the shared economy API is NOT included.`;
|
|
2479
|
+
}
|
|
2480
|
+
function credentialRequiredError(env, cause) {
|
|
2481
|
+
const keys = clientTransportEnvKeys(ECONOMY_APP);
|
|
2482
|
+
const clientEnv = env;
|
|
2483
|
+
const detail = cause instanceof Error ? cause.message : `No API key could be resolved for '${ECONOMY_APP}'. Looked in the Keychain (macOS only), ` + `then for a credential file at ${credentialDiskSources(ECONOMY_APP, clientEnv).join(" or ") || "<no HOME set>"}, ` + `then for ${keys.apiKeyKeys[0]} in the environment.`;
|
|
2484
|
+
const wayOut = hostedAuthorityConfigured(clientEnv) ? `${keys.apiUrlKeys[0]} (or the Keychain hasna.credentials.${ECONOMY_APP}.api-url item, or ` + `~/.hasna/${ECONOMY_APP}/config/credentials) selects a hosted service, so ${LOCAL_STORAGE_OPT_IN_KEYS[0]} ` + `does not apply; unset the authority to use the local store.` : `Or set ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 to explicitly opt into the local store.`;
|
|
2485
|
+
return new Error(`Economy fails closed instead of silently serving the local SQLite store: ${detail} ` + `Set ${keys.apiUrlKeys[0]} and ${keys.apiKeyKeys[0]} (or store the key in the Keychain item ` + `hasna.credentials.${ECONOMY_APP}.api-key, or in ~/.hasna/${ECONOMY_APP}/config/credentials) to use the ` + `hosted economy API at ${keys.apiUrlKeys[0]} or the default fleet gateway. ` + wayOut);
|
|
2486
|
+
}
|
|
2487
|
+
var cache;
|
|
2488
|
+
var localNoticePrinted = false;
|
|
2489
|
+
function resolveEconomyCloudStorage(env = process.env, overrides) {
|
|
2490
|
+
const clientEnv = env;
|
|
2491
|
+
if (localStorageExplicitlyOptedIn(env) && !hasEconomyEnvAuthorityIntent(clientEnv)) {
|
|
2492
|
+
return { active: false, client: null };
|
|
2493
|
+
}
|
|
2494
|
+
let resolved;
|
|
2495
|
+
try {
|
|
2496
|
+
resolved = resolveEconomyStorageClient(ECONOMY_APP, clientEnv, overrides ?? {});
|
|
2497
|
+
} catch (error) {
|
|
2498
|
+
if (error instanceof ClientTransportConfigurationError) {
|
|
2499
|
+
throw credentialRequiredError(env, error);
|
|
2500
|
+
}
|
|
2501
|
+
throw error;
|
|
2502
|
+
}
|
|
2503
|
+
return { active: true, client: resolved.client };
|
|
2504
|
+
}
|
|
2505
|
+
function economyCloudStorage(env = process.env) {
|
|
2506
|
+
if (cache === undefined) {
|
|
2507
|
+
cache = resolveEconomyCloudStorage(env);
|
|
2508
|
+
if (!cache.active && !localNoticePrinted) {
|
|
2509
|
+
localNoticePrinted = true;
|
|
2510
|
+
if (typeof process !== "undefined")
|
|
2511
|
+
process.stderr.write(`${localEconomyNotice()}
|
|
2512
|
+
`);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
return cache;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/lib/accounts-store.ts
|
|
2519
|
+
var profileNameSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
|
|
2520
|
+
var profileSchema = z.object({
|
|
2521
|
+
name: z.string(),
|
|
2522
|
+
tool: z.string(),
|
|
2523
|
+
email: z.string().optional(),
|
|
2524
|
+
displayName: z.string().optional(),
|
|
2525
|
+
dir: z.string(),
|
|
2526
|
+
description: z.string().optional(),
|
|
2527
|
+
createdAt: z.string(),
|
|
2528
|
+
lastUsedAt: z.string().optional()
|
|
2529
|
+
});
|
|
2530
|
+
var toolDefSchema = z.object({
|
|
2531
|
+
id: z.string(),
|
|
2532
|
+
label: z.string(),
|
|
2533
|
+
envVar: z.string(),
|
|
2534
|
+
defaultDir: z.string(),
|
|
2535
|
+
bin: z.string()
|
|
2536
|
+
});
|
|
2537
|
+
var storeSchema = z.object({
|
|
2538
|
+
version: z.literal(1),
|
|
2539
|
+
current: z.record(z.string(), z.string()).default({}),
|
|
2540
|
+
applied: z.record(z.string(), z.string()).default({}),
|
|
2541
|
+
toolLocks: z.record(z.string(), z.string()).default({}),
|
|
2542
|
+
profiles: z.array(profileSchema).default([]),
|
|
2543
|
+
tools: z.array(toolDefSchema).default([])
|
|
2544
|
+
});
|
|
2545
|
+
var BUILTIN_TOOLS = [
|
|
2546
|
+
{ id: "claude", label: "Claude Code", envVar: "CLAUDE_CONFIG_DIR", defaultDir: join2(homedir2(), ".claude"), bin: "claude" },
|
|
2547
|
+
{ id: "codex-app", label: "Codex App", envVar: "CODEX_HOME", defaultDir: join2(homedir2(), ".codex"), bin: "/Applications/Codex.app/Contents/MacOS/Codex" },
|
|
2548
|
+
{ id: "codex", label: "Codex CLI", envVar: "CODEX_HOME", defaultDir: join2(homedir2(), ".codex"), bin: "codex" },
|
|
2549
|
+
{ id: "codewith", label: "Codewith", envVar: "CODEWITH_HOME", defaultDir: join2(homedir2(), ".codewith"), bin: "codewith" },
|
|
2550
|
+
{ id: "takumi", label: "Takumi", envVar: "TAKUMI_CONFIG_DIR", defaultDir: join2(homedir2(), ".takumi"), bin: "takumi" },
|
|
2551
|
+
{ id: "gemini", label: "Gemini CLI", envVar: "GEMINI_CONFIG_DIR", defaultDir: join2(homedir2(), ".gemini"), bin: "gemini" },
|
|
2552
|
+
{ id: "opencode", label: "opencode", envVar: "OPENCODE_CONFIG_DIR", defaultDir: join2(homedir2(), ".config", "opencode"), bin: "opencode" },
|
|
2553
|
+
{ id: "cursor", label: "Cursor Agent", envVar: "CURSOR_CONFIG_DIR", defaultDir: join2(homedir2(), ".cursor"), bin: "cursor-agent" },
|
|
2554
|
+
{ id: "pi", label: "Pi Coding Agent", envVar: "PI_CODING_AGENT_HOME", defaultDir: join2(homedir2(), ".pi"), bin: "pi" },
|
|
2555
|
+
{ id: "hermes", label: "Hermes", envVar: "HERMES_HOME", defaultDir: join2(homedir2(), ".hermes"), bin: "hermes" },
|
|
2556
|
+
{ id: "kimi", label: "Kimi Code", envVar: "KIMI_CODE_HOME", defaultDir: join2(homedir2(), ".kimi-code"), bin: "kimi" },
|
|
2557
|
+
{ id: "grok", label: "Grok Build", envVar: "HOME", defaultDir: join2(homedir2(), ".grok"), bin: "grok" }
|
|
2558
|
+
];
|
|
2559
|
+
|
|
2560
|
+
// src/ingest/claude.ts
|
|
2561
|
+
var CLAUDE_PROJECTS_DIR = process.env["HASNA_ECONOMY_CLAUDE_PROJECTS_DIR"] ?? join3(homedir3(), ".claude", "projects");
|
|
2562
|
+
var TAKUMI_PROJECTS_DIR = process.env["HASNA_ECONOMY_TAKUMI_PROJECTS_DIR"] ?? join3(homedir3(), ".takumi", "projects");
|
|
2563
|
+
|
|
2564
|
+
// src/ingest/codex.ts
|
|
2565
|
+
init_database();
|
|
2566
|
+
init_pricing();
|
|
2567
|
+
import { homedir as homedir4 } from "os";
|
|
2568
|
+
import { join as join4, basename as basename2 } from "path";
|
|
2569
|
+
var DEFAULT_CODEX_DB_PATH = join4(homedir4(), ".codex", "state_5.sqlite");
|
|
2570
|
+
var DEFAULT_CODEX_CONFIG_PATH = join4(homedir4(), ".codex", "config.toml");
|
|
2571
|
+
var DEFAULT_CODEWITH_DB_PATH = join4(homedir4(), ".codewith", "state_5.sqlite");
|
|
2572
|
+
var DEFAULT_CODEWITH_CONFIG_PATH = join4(homedir4(), ".codewith", "config.toml");
|
|
2573
|
+
|
|
2574
|
+
// src/ingest/gemini.ts
|
|
2575
|
+
init_database();
|
|
2576
|
+
init_pricing();
|
|
2577
|
+
import { homedir as homedir5 } from "os";
|
|
2578
|
+
import { join as join5, basename as basename3 } from "path";
|
|
2579
|
+
var DEFAULT_GEMINI_TMP_DIR = join5(homedir5(), ".gemini", "tmp");
|
|
2580
|
+
var DEFAULT_GEMINI_HISTORY_DIR = join5(homedir5(), ".gemini", "history");
|
|
2581
|
+
|
|
2582
|
+
// src/ingest/opencode.ts
|
|
2583
|
+
init_database();
|
|
2584
|
+
init_pricing();
|
|
2585
|
+
import { homedir as homedir6 } from "os";
|
|
2586
|
+
import { join as join6 } from "path";
|
|
2587
|
+
var OPENCODE_STORAGE = join6(homedir6(), ".local", "share", "opencode", "storage");
|
|
2588
|
+
|
|
2589
|
+
// src/ingest/quota-observation.ts
|
|
2590
|
+
init_database();
|
|
2591
|
+
init_observation_provenance();
|
|
2592
|
+
|
|
2593
|
+
// src/ingest/pi.ts
|
|
2594
|
+
init_database();
|
|
2595
|
+
import { homedir as homedir7 } from "os";
|
|
2596
|
+
import { join as join7 } from "path";
|
|
2597
|
+
var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join7(homedir7(), ".pi", "agent", "sessions");
|
|
2598
|
+
|
|
2599
|
+
// src/ingest/hermes.ts
|
|
2600
|
+
init_database();
|
|
2601
|
+
import { homedir as homedir8 } from "os";
|
|
2602
|
+
import { join as join8 } from "path";
|
|
2603
|
+
var HERMES_DB = join8(homedir8(), ".hermes", "state.db");
|
|
2604
|
+
|
|
2605
|
+
// src/ingest/loops.ts
|
|
2606
|
+
init_database();
|
|
2607
|
+
init_pricing();
|
|
2608
|
+
|
|
2609
|
+
// src/ingest/codex-quota.ts
|
|
2610
|
+
var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
|
|
2611
|
+
|
|
2612
|
+
// src/lib/sync-all.ts
|
|
2613
|
+
init_database();
|
|
2614
|
+
init_observation_provenance();
|
|
2615
|
+
|
|
2616
|
+
// src/ingest/billing.ts
|
|
2617
|
+
init_database();
|
|
2618
|
+
|
|
2619
|
+
// src/lib/sync-maintenance.ts
|
|
2620
|
+
init_database();
|
|
2621
|
+
|
|
2622
|
+
// src/lib/ingest-batches.ts
|
|
2623
|
+
init_ingest_validation();
|
|
2624
|
+
import { createHash as createHash3 } from "crypto";
|
|
2625
|
+
function* ingestBatches(body) {
|
|
2626
|
+
let rows = {};
|
|
2627
|
+
let count = 0;
|
|
2628
|
+
let bytes = 0;
|
|
2629
|
+
const finish = () => {
|
|
2630
|
+
const digest2 = createHash3("sha256").update(stableJson(rows)).digest("hex");
|
|
2631
|
+
const batch = { ...rows, schema_version: 1, idempotency_key: `scan-${digest2}` };
|
|
2632
|
+
rows = {};
|
|
2633
|
+
count = 0;
|
|
2634
|
+
bytes = 0;
|
|
2635
|
+
if (Buffer.byteLength(JSON.stringify(batch)) > MAX_INGEST_BYTES)
|
|
2636
|
+
throw new Error("Ingest batch exceeds the server byte limit");
|
|
2637
|
+
return batch;
|
|
2638
|
+
};
|
|
2639
|
+
const rowLimit = Math.min(MAX_INGEST_ROWS, 500);
|
|
2640
|
+
const byteLimit = MAX_INGEST_BYTES - 4096;
|
|
2641
|
+
for (const table of Object.keys(body).sort()) {
|
|
2642
|
+
const sorted = body[table].map((row) => ({ row, canonical: stableJson(row) })).sort((a, b) => a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0);
|
|
2643
|
+
for (const { row, canonical } of sorted) {
|
|
2644
|
+
const size = Buffer.byteLength(canonical) + 1;
|
|
2645
|
+
if (size > byteLimit)
|
|
2646
|
+
throw new Error("One ingestion row exceeds the server byte limit");
|
|
2647
|
+
if (count > 0 && (count >= rowLimit || bytes + size > byteLimit))
|
|
2648
|
+
yield finish();
|
|
2649
|
+
(rows[table] ??= []).push(row);
|
|
2650
|
+
count++;
|
|
2651
|
+
bytes += size;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
if (count > 0)
|
|
2655
|
+
yield finish();
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
class IngestBatchFailure extends Error {
|
|
2659
|
+
pending_key;
|
|
2660
|
+
completed;
|
|
2661
|
+
pending_command;
|
|
2662
|
+
constructor(pending_key, completed, pending_command) {
|
|
2663
|
+
super("Ingest batch did not return a verified receipt; retain the exact staged command and reconcile before retrying");
|
|
2664
|
+
this.pending_key = pending_key;
|
|
2665
|
+
this.completed = completed;
|
|
2666
|
+
this.pending_command = pending_command;
|
|
2667
|
+
this.name = "IngestBatchFailure";
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// src/lib/ingest-journal.ts
|
|
2672
|
+
init_ingest_validation();
|
|
2673
|
+
init_database();
|
|
2674
|
+
import { constants, closeSync, fsyncSync, fstatSync, lstatSync, mkdirSync as mkdirSync3, openSync, readFileSync, opendirSync, realpathSync, writeFileSync } from "fs";
|
|
2675
|
+
import { createHash as createHash4 } from "crypto";
|
|
2676
|
+
import { join as join9, resolve as resolve2 } from "path";
|
|
2677
|
+
var MAX_COMMANDS = 1000;
|
|
2678
|
+
var MAX_BYTES = 64 * 1024 * 1024;
|
|
2679
|
+
var RECEIPT_RESERVE = 4096;
|
|
2680
|
+
var MAINTENANCE_RESERVE = 16384;
|
|
2681
|
+
var sha = (s) => createHash4("sha256").update(s).digest("hex");
|
|
2682
|
+
|
|
2683
|
+
class PendingIngestError extends Error {
|
|
2684
|
+
pending_keys;
|
|
2685
|
+
code = "INGEST_RECONCILIATION_REQUIRED";
|
|
2686
|
+
constructor(pending_keys) {
|
|
2687
|
+
super("Pending ingestion requires hosted receipt reconciliation; no command was replayed");
|
|
2688
|
+
this.pending_keys = pending_keys;
|
|
2689
|
+
this.name = "PendingIngestError";
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
function definitiveIngestRefusal(status, body) {
|
|
2693
|
+
const reasons = { 400: "invalid_ingest", 409: "ingest_conflict", 413: "ingest_too_large", 422: "unprocessable_ingest" };
|
|
2694
|
+
if (!Object.hasOwn(reasons, status))
|
|
2695
|
+
return null;
|
|
2696
|
+
const legacy = body?.expected === "legacy_aggregate_requires_migration";
|
|
2697
|
+
return {
|
|
2698
|
+
status,
|
|
2699
|
+
code: legacy ? "legacy_aggregate_requires_migration" : "ingest_refused",
|
|
2700
|
+
reason: legacy ? "legacy_aggregate_requires_migration" : reasons[status]
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function getIngestJournalPath() {
|
|
2704
|
+
return process.env.HASNA_ECONOMY_INGEST_JOURNAL ?? join9(cacheDir({ app: "economy", home: process.env.HOME || process.env.USERPROFILE }), "pending-ingest");
|
|
2705
|
+
}
|
|
2706
|
+
function targetId(cloud) {
|
|
2707
|
+
const url = new URL(cloud.client.transport.baseUrl);
|
|
2708
|
+
if (url.username || url.password || url.search || url.hash)
|
|
2709
|
+
throw new Error("Ingest authority must not contain URL credentials or capabilities");
|
|
2710
|
+
return sha(url.toString().replace(/\/+$/, ""));
|
|
2711
|
+
}
|
|
2712
|
+
function secureDirectory(path) {
|
|
2713
|
+
mkdirSync3(path, { recursive: true, mode: 448 });
|
|
2714
|
+
const stat = lstatSync(path);
|
|
2715
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 63) !== 0 || process.getuid && stat.uid !== process.getuid())
|
|
2716
|
+
throw new Error("Ingest journal must be a private owner directory");
|
|
2717
|
+
}
|
|
2718
|
+
function readJson(path, max) {
|
|
2719
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
2720
|
+
try {
|
|
2721
|
+
const st = fstatSync(fd);
|
|
2722
|
+
if (!st.isFile() || st.size > max || (st.mode & 63) !== 0 || process.getuid && st.uid !== process.getuid())
|
|
2723
|
+
throw new Error("Unsafe ingest journal record");
|
|
2724
|
+
return JSON.parse(readFileSync(fd, "utf8"));
|
|
2725
|
+
} finally {
|
|
2726
|
+
closeSync(fd);
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
function createExact(path, value) {
|
|
2730
|
+
const bytes = JSON.stringify(value);
|
|
2731
|
+
let fd;
|
|
2732
|
+
try {
|
|
2733
|
+
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
2734
|
+
} catch (error) {
|
|
2735
|
+
if (error.code === "EEXIST")
|
|
2736
|
+
return false;
|
|
2737
|
+
throw error;
|
|
2738
|
+
}
|
|
2739
|
+
try {
|
|
2740
|
+
writeFileSync(fd, bytes);
|
|
2741
|
+
fsyncSync(fd);
|
|
2742
|
+
} finally {
|
|
2743
|
+
closeSync(fd);
|
|
2744
|
+
}
|
|
2745
|
+
if (JSON.stringify(readJson(path, MAX_INGEST_BYTES + 4096)) !== bytes)
|
|
2746
|
+
throw new Error("Ingest journal readback failed");
|
|
2747
|
+
const directory = openSync(resolve2(path, ".."), constants.O_RDONLY);
|
|
2748
|
+
try {
|
|
2749
|
+
fsyncSync(directory);
|
|
2750
|
+
} finally {
|
|
2751
|
+
closeSync(directory);
|
|
2752
|
+
}
|
|
2753
|
+
return true;
|
|
2754
|
+
}
|
|
2755
|
+
function unwrap(raw) {
|
|
2756
|
+
return raw && typeof raw === "object" && "data" in raw ? raw.data : raw;
|
|
2757
|
+
}
|
|
2758
|
+
function verifyIngestReceipt(raw, command) {
|
|
2759
|
+
const receipt = unwrap(raw);
|
|
2760
|
+
if (!receipt || typeof receipt !== "object" || !receipt.ingested || typeof receipt.ingested !== "object" || Array.isArray(receipt.ingested) || !Number.isSafeInteger(receipt.total) || receipt.total < 0 || typeof receipt.operation_key !== "string" || !/^[a-f0-9]{64}$/.test(receipt.operation_key) || typeof receipt.replayed !== "boolean")
|
|
2761
|
+
throw new Error("Invalid ingest receipt");
|
|
2762
|
+
let sum = 0;
|
|
2763
|
+
for (const [table, count] of Object.entries(receipt.ingested)) {
|
|
2764
|
+
if (!Array.isArray(command[table]) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0 || count > command[table].length)
|
|
2765
|
+
throw new Error("Invalid ingest receipt");
|
|
2766
|
+
sum += count;
|
|
2767
|
+
}
|
|
2768
|
+
if (sum !== receipt.total)
|
|
2769
|
+
throw new Error("Invalid ingest receipt");
|
|
2770
|
+
const projected = {
|
|
2771
|
+
ingested: Object.fromEntries(Object.entries(receipt.ingested)),
|
|
2772
|
+
total: receipt.total,
|
|
2773
|
+
operation_key: receipt.operation_key,
|
|
2774
|
+
replayed: receipt.replayed,
|
|
2775
|
+
stale: receipt.stale ?? 0,
|
|
2776
|
+
unchanged: receipt.unchanged ?? 0
|
|
2777
|
+
};
|
|
2778
|
+
const rows = Object.values(command).reduce((n, v) => n + (Array.isArray(v) ? v.length : 0), 0);
|
|
2779
|
+
if (![projected.stale, projected.unchanged].every((n) => Number.isSafeInteger(n) && n >= 0 && n <= rows) || Buffer.byteLength(JSON.stringify(projected)) > RECEIPT_RESERVE - 256)
|
|
2780
|
+
throw new Error("Invalid ingest receipt");
|
|
2781
|
+
return projected;
|
|
2782
|
+
}
|
|
2783
|
+
function sameReceipt(left, right) {
|
|
2784
|
+
const meaning = (value) => ({ ingested: value?.ingested, total: value?.total, operation_key: value?.operation_key, stale: value?.stale ?? 0, unchanged: value?.unchanged ?? 0 });
|
|
2785
|
+
return stableJson(meaning(left)) === stableJson(meaning(right));
|
|
2786
|
+
}
|
|
2787
|
+
async function lookupJournalReceipt(cloud, key, bodyHash, timeoutMs = 1e4) {
|
|
2788
|
+
const query = new URLSearchParams({ idempotency_key: key, body_hash: bodyHash });
|
|
2789
|
+
const raw = unwrap(await cloud.client.transport.get(`/ingest/receipts?${query}`, { retry: false, timeoutMs }));
|
|
2790
|
+
if (!raw || raw.body_hash !== bodyHash)
|
|
2791
|
+
throw new Error("Invalid reconciliation response");
|
|
2792
|
+
if (raw.status === "absent")
|
|
2793
|
+
return null;
|
|
2794
|
+
if (raw.status !== "committed")
|
|
2795
|
+
throw new Error("Invalid reconciliation response");
|
|
2796
|
+
return raw.receipt;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
class IngestJournal {
|
|
2800
|
+
directory;
|
|
2801
|
+
target;
|
|
2802
|
+
inventoryBytes = 0;
|
|
2803
|
+
freshlyStaged = new Set;
|
|
2804
|
+
constructor(cloud, path = getIngestJournalPath()) {
|
|
2805
|
+
this.target = targetId(cloud);
|
|
2806
|
+
secureDirectory(path);
|
|
2807
|
+
this.directory = join9(realpathSync(path), this.target);
|
|
2808
|
+
secureDirectory(this.directory);
|
|
2809
|
+
}
|
|
2810
|
+
names() {
|
|
2811
|
+
const names = [];
|
|
2812
|
+
const directory = opendirSync(this.directory);
|
|
2813
|
+
try {
|
|
2814
|
+
for (let entry;entry = directory.readSync(); ) {
|
|
2815
|
+
if (entry.name === ".lease" || entry.name === ".maintenance" || entry.name === ".authority.json" || entry.name === ".recovery")
|
|
2816
|
+
continue;
|
|
2817
|
+
names.push(entry.name);
|
|
2818
|
+
if (names.length > MAX_COMMANDS * 2)
|
|
2819
|
+
throw new Error("Ingest journal capacity reached; preserve and reconcile records");
|
|
2820
|
+
}
|
|
2821
|
+
} finally {
|
|
2822
|
+
directory.closeSync();
|
|
2823
|
+
}
|
|
2824
|
+
this.inventoryBytes = 0;
|
|
2825
|
+
const set = new Set(names);
|
|
2826
|
+
for (const name of names) {
|
|
2827
|
+
if (!/^[a-f0-9]{64}\.(intent|receipt|refusal)\.json$/.test(name))
|
|
2828
|
+
throw new Error("Unexpected ingest journal entry");
|
|
2829
|
+
if (!name.endsWith(".intent.json") && !set.has(name.replace(/\.(receipt|refusal)\./, ".intent.")))
|
|
2830
|
+
throw new Error("Orphaned ingest outcome requires maintenance recovery");
|
|
2831
|
+
if (name.endsWith(".refusal.json") && set.has(name.replace(".refusal.", ".receipt.")))
|
|
2832
|
+
throw new Error("Conflicting ingest receipt and refusal");
|
|
2833
|
+
const stat = lstatSync(join9(this.directory, name));
|
|
2834
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 63) !== 0)
|
|
2835
|
+
throw new Error("Unsafe ingest journal record");
|
|
2836
|
+
if (stat.size > MAX_INGEST_BYTES + 4096)
|
|
2837
|
+
throw new Error("Oversized ingest journal record");
|
|
2838
|
+
this.inventoryBytes += stat.size;
|
|
2839
|
+
if (name.endsWith(".refusal.json"))
|
|
2840
|
+
this.refusal(this.intent(name.replace(".refusal.", ".intent.")).command);
|
|
2841
|
+
}
|
|
2842
|
+
return names;
|
|
2843
|
+
}
|
|
2844
|
+
file(key, kind) {
|
|
2845
|
+
return join9(this.directory, `${sha(key)}.${kind}.json`);
|
|
2846
|
+
}
|
|
2847
|
+
intent(name) {
|
|
2848
|
+
const record = readJson(join9(this.directory, name), MAX_INGEST_BYTES + 4096);
|
|
2849
|
+
if (record.version !== 1 || record.target !== this.target || !record.command || typeof record.command.idempotency_key !== "string" || record.body_hash !== canonicalIngestBodyHash(record.command) || name !== `${sha(record.command.idempotency_key)}.intent.json`)
|
|
2850
|
+
throw new Error("Invalid ingest journal intent");
|
|
2851
|
+
validateIngest(record.command);
|
|
2852
|
+
return record;
|
|
2853
|
+
}
|
|
2854
|
+
status() {
|
|
2855
|
+
const names = this.names(), intents = names.filter((n) => n.endsWith(".intent.json"));
|
|
2856
|
+
const set = new Set(names);
|
|
2857
|
+
const completed = intents.filter((name) => set.has(name.replace(".intent.", ".receipt."))).length;
|
|
2858
|
+
const rejected = intents.filter((name) => set.has(name.replace(".intent.", ".refusal."))).length;
|
|
2859
|
+
const bytes = this.inventoryBytes;
|
|
2860
|
+
return { target: this.target, commands: intents.length, completed, rejected, pending: intents.length - completed - rejected, bytes, reserved_bytes: bytes + (intents.length - completed - rejected) * RECEIPT_RESERVE + MAINTENANCE_RESERVE };
|
|
2861
|
+
}
|
|
2862
|
+
canStage(command, status = this.status()) {
|
|
2863
|
+
const name = `${sha(command.idempotency_key)}.intent.json`;
|
|
2864
|
+
try {
|
|
2865
|
+
if (this.intent(name).body_hash !== canonicalIngestBodyHash(command))
|
|
2866
|
+
throw new Error("Ingest command key is already bound to different content");
|
|
2867
|
+
return true;
|
|
2868
|
+
} catch (error) {
|
|
2869
|
+
if (error.code !== "ENOENT")
|
|
2870
|
+
throw error;
|
|
2871
|
+
}
|
|
2872
|
+
const record = { version: 1, target: this.target, body_hash: canonicalIngestBodyHash(command), command };
|
|
2873
|
+
return status.commands < MAX_COMMANDS && status.reserved_bytes + Buffer.byteLength(JSON.stringify(record)) + RECEIPT_RESERVE <= MAX_BYTES;
|
|
2874
|
+
}
|
|
2875
|
+
receipt(command) {
|
|
2876
|
+
if (this.refusal(command))
|
|
2877
|
+
throw new Error("Conflicting ingest receipt and refusal");
|
|
2878
|
+
const record = readJson(this.file(command.idempotency_key, "receipt"), RECEIPT_RESERVE);
|
|
2879
|
+
if (record.version !== 1 || record.target !== this.target || record.body_hash !== canonicalIngestBodyHash(command))
|
|
2880
|
+
throw new Error("Invalid receipt journal binding");
|
|
2881
|
+
return verifyIngestReceipt(record.receipt, command);
|
|
2882
|
+
}
|
|
2883
|
+
stage(command) {
|
|
2884
|
+
validateIngest(command);
|
|
2885
|
+
const path = this.file(command.idempotency_key, "intent");
|
|
2886
|
+
const record = { version: 1, target: this.target, body_hash: canonicalIngestBodyHash(command), command };
|
|
2887
|
+
if (!this.canStage(command))
|
|
2888
|
+
throw new Error("Ingest journal capacity reached; run economy ingest journal maintain; originals preserved");
|
|
2889
|
+
if (createExact(path, record)) {
|
|
2890
|
+
this.freshlyStaged.add(command.idempotency_key);
|
|
2891
|
+
return true;
|
|
2892
|
+
}
|
|
2893
|
+
this.freshlyStaged.delete(command.idempotency_key);
|
|
2894
|
+
const old = this.intent(`${sha(command.idempotency_key)}.intent.json`);
|
|
2895
|
+
if (old.body_hash !== record.body_hash)
|
|
2896
|
+
throw new Error("Ingest command key is already bound to different content");
|
|
2897
|
+
return false;
|
|
2898
|
+
}
|
|
2899
|
+
accept(command, raw) {
|
|
2900
|
+
if (this.refusal(command))
|
|
2901
|
+
throw new Error("A refused command cannot accept a receipt");
|
|
2902
|
+
this.freshlyStaged.delete(command.idempotency_key);
|
|
2903
|
+
const receipt = verifyIngestReceipt(raw, command);
|
|
2904
|
+
const intent = this.intent(`${sha(command.idempotency_key)}.intent.json`);
|
|
2905
|
+
if (intent.body_hash !== canonicalIngestBodyHash(command))
|
|
2906
|
+
throw new Error("Ingest receipt content conflicts with exact intent");
|
|
2907
|
+
const record = { version: 1, target: this.target, body_hash: canonicalIngestBodyHash(command), receipt };
|
|
2908
|
+
const path = this.file(command.idempotency_key, "receipt");
|
|
2909
|
+
if (!this.names().includes(`${sha(command.idempotency_key)}.receipt.json`) && this.inventoryBytes + Buffer.byteLength(JSON.stringify(record)) > MAX_BYTES)
|
|
2910
|
+
throw new Error("Receipt capacity exhausted; exact intent retained for reconciliation");
|
|
2911
|
+
if (!createExact(path, record)) {
|
|
2912
|
+
const old = readJson(path, MAX_INGEST_BYTES);
|
|
2913
|
+
if (old.body_hash !== record.body_hash || old.target !== this.target || !sameReceipt(old.receipt, receipt))
|
|
2914
|
+
throw new Error("Ingest receipt journal conflict");
|
|
2915
|
+
}
|
|
2916
|
+
return receipt;
|
|
2917
|
+
}
|
|
2918
|
+
recordRefusal(command, refusal) {
|
|
2919
|
+
if (!this.freshlyStaged.delete(command.idempotency_key))
|
|
2920
|
+
throw new Error("Refusal requires this invocation's fresh command");
|
|
2921
|
+
const intent = this.intent(`${sha(command.idempotency_key)}.intent.json`);
|
|
2922
|
+
if (intent.body_hash !== canonicalIngestBodyHash(command))
|
|
2923
|
+
throw new Error("Refusal conflicts with exact intent");
|
|
2924
|
+
const expected = definitiveIngestRefusal(refusal.status, refusal.code === "legacy_aggregate_requires_migration" ? { expected: refusal.code } : null);
|
|
2925
|
+
if (!expected || stableJson(expected) !== stableJson(refusal))
|
|
2926
|
+
throw new Error("Invalid definitive refusal");
|
|
2927
|
+
const names = this.names();
|
|
2928
|
+
if (names.includes(`${sha(command.idempotency_key)}.receipt.json`))
|
|
2929
|
+
throw new Error("A completed command cannot be refused");
|
|
2930
|
+
const record = { version: 1, target: this.target, body_hash: intent.body_hash, refusal };
|
|
2931
|
+
if (Buffer.byteLength(JSON.stringify(record)) > RECEIPT_RESERVE || this.inventoryBytes + Buffer.byteLength(JSON.stringify(record)) > MAX_BYTES)
|
|
2932
|
+
throw new Error("Refusal capacity exhausted; exact intent retained");
|
|
2933
|
+
if (!createExact(this.file(command.idempotency_key, "refusal"), record))
|
|
2934
|
+
throw new Error("Existing refusal requires inspection");
|
|
2935
|
+
this.refusal(command);
|
|
2936
|
+
}
|
|
2937
|
+
refusal(command) {
|
|
2938
|
+
let record;
|
|
2939
|
+
try {
|
|
2940
|
+
record = readJson(this.file(command.idempotency_key, "refusal"), RECEIPT_RESERVE);
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
if (error.code === "ENOENT")
|
|
2943
|
+
return null;
|
|
2944
|
+
throw error;
|
|
2945
|
+
}
|
|
2946
|
+
const intent = this.intent(`${sha(command.idempotency_key)}.intent.json`), r = record?.refusal;
|
|
2947
|
+
const expected = definitiveIngestRefusal(r?.status, r?.code === "legacy_aggregate_requires_migration" ? { expected: r.code } : null);
|
|
2948
|
+
if (record?.version !== 1 || record.target !== this.target || record.body_hash !== intent.body_hash || intent.body_hash !== canonicalIngestBodyHash(command) || !expected || stableJson(expected) !== stableJson(r))
|
|
2949
|
+
throw new Error("Invalid refusal journal binding");
|
|
2950
|
+
try {
|
|
2951
|
+
lstatSync(this.file(command.idempotency_key, "receipt"));
|
|
2952
|
+
throw new Error("Conflicting ingest receipt and refusal");
|
|
2953
|
+
} catch (error) {
|
|
2954
|
+
if (error.code !== "ENOENT")
|
|
2955
|
+
throw error;
|
|
2956
|
+
}
|
|
2957
|
+
return expected;
|
|
2958
|
+
}
|
|
2959
|
+
async reconcileCommand(cloud, command, timeoutMs = 1e4) {
|
|
2960
|
+
const receipt = await this.lookupReceipt(cloud, command, timeoutMs);
|
|
2961
|
+
return receipt ? this.accept(command, receipt) : null;
|
|
2962
|
+
}
|
|
2963
|
+
assertCloud(cloud) {
|
|
2964
|
+
if (targetId(cloud) !== this.target)
|
|
2965
|
+
throw new Error("Ingest journal authority changed");
|
|
2966
|
+
}
|
|
2967
|
+
async lookupReceipt(cloud, command, timeoutMs = 1e4) {
|
|
2968
|
+
this.assertCloud(cloud);
|
|
2969
|
+
const receipt = await lookupJournalReceipt(cloud, command.idempotency_key, canonicalIngestBodyHash(command), timeoutMs);
|
|
2970
|
+
return receipt === null ? null : verifyIngestReceipt(receipt, command);
|
|
2971
|
+
}
|
|
2972
|
+
async reconcilePending(cloud) {
|
|
2973
|
+
const names = this.names(), unresolved = [];
|
|
2974
|
+
const deadline = performance.now() + 30000;
|
|
2975
|
+
for (const name of names.filter((n) => n.endsWith(".intent.json"))) {
|
|
2976
|
+
const intent = this.intent(name);
|
|
2977
|
+
if (names.includes(`${sha(intent.command.idempotency_key)}.refusal.json`)) {
|
|
2978
|
+
this.refusal(intent.command);
|
|
2979
|
+
continue;
|
|
2980
|
+
}
|
|
2981
|
+
if (names.includes(`${sha(intent.command.idempotency_key)}.receipt.json`)) {
|
|
2982
|
+
const record = readJson(this.file(intent.command.idempotency_key, "receipt"), MAX_INGEST_BYTES);
|
|
2983
|
+
if (record.target !== this.target || record.body_hash !== intent.body_hash)
|
|
2984
|
+
throw new Error("Invalid receipt journal binding");
|
|
2985
|
+
verifyIngestReceipt(record.receipt, intent.command);
|
|
2986
|
+
continue;
|
|
2987
|
+
}
|
|
2988
|
+
if (performance.now() >= deadline) {
|
|
2989
|
+
unresolved.push(intent.command.idempotency_key);
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2992
|
+
try {
|
|
2993
|
+
if (!await this.reconcileCommand(cloud, intent.command, Math.max(1, Math.min(1e4, deadline - performance.now()))))
|
|
2994
|
+
unresolved.push(intent.command.idempotency_key);
|
|
2995
|
+
} catch {
|
|
2996
|
+
unresolved.push(intent.command.idempotency_key);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
if (unresolved.length)
|
|
3000
|
+
throw new PendingIngestError(unresolved);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// src/lib/ingest-journal-maintenance.ts
|
|
3005
|
+
import { constants as constants2, closeSync as closeSync2, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
|
|
3006
|
+
import { createHash as createHash5 } from "crypto";
|
|
3007
|
+
import { join as join10 } from "path";
|
|
3008
|
+
var uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
3009
|
+
var digest2 = /^[a-f0-9]{64}$/;
|
|
3010
|
+
var sha2 = (bytes) => createHash5("sha256").update(bytes).digest("hex");
|
|
3011
|
+
|
|
3012
|
+
class JournalMaintenanceError extends Error {
|
|
3013
|
+
reason;
|
|
3014
|
+
target;
|
|
3015
|
+
code = "INGEST_JOURNAL_MAINTENANCE_REQUIRED";
|
|
3016
|
+
constructor(reason, target) {
|
|
3017
|
+
super(`Ingest journal maintenance required (${reason}); run economy ingest journal maintain; originals or Trash recovery state preserved`);
|
|
3018
|
+
this.reason = reason;
|
|
3019
|
+
this.target = target;
|
|
3020
|
+
this.name = "JournalMaintenanceError";
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
function exists(path) {
|
|
3024
|
+
try {
|
|
3025
|
+
lstatSync2(path);
|
|
3026
|
+
return true;
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
if (error.code === "ENOENT")
|
|
3029
|
+
return false;
|
|
3030
|
+
throw error;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
function snapshot(path) {
|
|
3034
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
3035
|
+
try {
|
|
3036
|
+
const stat = fstatSync2(fd);
|
|
3037
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 4 * 1024 * 1024 + 4096 || (stat.mode & 63) !== 0 || process.getuid && stat.uid !== process.getuid())
|
|
3038
|
+
throw new Error("Unsafe maintenance source");
|
|
3039
|
+
const bytes = readFileSync2(fd), after = fstatSync2(fd);
|
|
3040
|
+
if (after.ctimeMs !== stat.ctimeMs || bytes.length !== stat.size)
|
|
3041
|
+
throw new Error("Maintenance source changed");
|
|
3042
|
+
return { sha256: sha2(bytes), sizeBytes: stat.size, mode: stat.mode & 511, device: String(stat.dev), inode: String(stat.ino), ctime: stat.ctimeMs };
|
|
3043
|
+
} finally {
|
|
3044
|
+
closeSync2(fd);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
function sameSource(path, expected) {
|
|
3048
|
+
const { capture_sha256: _capture, ...original } = expected;
|
|
3049
|
+
if (JSON.stringify(snapshot(path)) !== JSON.stringify(original))
|
|
3050
|
+
throw new Error("Maintenance source changed; preserve for inspection");
|
|
3051
|
+
}
|
|
3052
|
+
function validAnchor(value, target) {
|
|
3053
|
+
const a = value;
|
|
3054
|
+
if (a?.version !== 1 || a.target !== target || !/^[A-Za-z0-9._:-]{1,200}$/.test(a.key) || !digest2.test(a.body_hash) || !digest2.test(a.receipt?.operation_key))
|
|
3055
|
+
throw new Error("Invalid journal authority anchor");
|
|
3056
|
+
return a;
|
|
3057
|
+
}
|
|
3058
|
+
async function confirm(cloud, anchor, deadline) {
|
|
3059
|
+
if (Date.now() >= deadline)
|
|
3060
|
+
throw new Error("Maintenance deadline reached");
|
|
3061
|
+
const current = await lookupJournalReceipt(cloud, anchor.key, anchor.body_hash, Math.max(1, Math.min(1e4, deadline - Date.now())));
|
|
3062
|
+
if (current === null || !sameReceipt(current, anchor.receipt))
|
|
3063
|
+
throw new Error("Exact Economy receipt is unavailable or changed");
|
|
3064
|
+
}
|
|
3065
|
+
async function verifyJournalAuthority(journal, cloud) {
|
|
3066
|
+
journal.assertCloud(cloud);
|
|
3067
|
+
const path = join10(journal.directory, ".authority.json");
|
|
3068
|
+
if (!exists(path)) {
|
|
3069
|
+
const names = journal.names();
|
|
3070
|
+
const name = names.find((n) => n.endsWith(".intent.json") && names.includes(n.replace(".intent.", ".receipt.")));
|
|
3071
|
+
if (!name)
|
|
3072
|
+
return;
|
|
3073
|
+
const intent = journal.intent(name), receipt = journal.receipt(intent.command);
|
|
3074
|
+
createExact(path, { version: 1, target: journal.target, key: intent.command.idempotency_key, body_hash: intent.body_hash, receipt });
|
|
3075
|
+
}
|
|
3076
|
+
await confirm(cloud, validAnchor(readJson(path, 4096), journal.target), Date.now() + 1e4);
|
|
3077
|
+
}
|
|
3078
|
+
function recoveryFactory(cloud, env = process.env) {
|
|
3079
|
+
return async (operationRoot, checkpoint, deadline) => {
|
|
3080
|
+
if (env.HASNA_ECONOMY_INGEST_RECOVERY !== "trash")
|
|
3081
|
+
throw new Error("recovery_not_configured");
|
|
3082
|
+
const economy = new URL(cloud.client.transport.baseUrl);
|
|
3083
|
+
const fleet = economy.origin === "https://api.hasna.com" && /^\/economy(?:\/v1)?\/?$/.test(economy.pathname);
|
|
3084
|
+
if (!fleet && !env.HASNA_TRASH_API_URL)
|
|
3085
|
+
throw new Error("self_host_trash_authority_required");
|
|
3086
|
+
const sdk = await import("@hasna/trash/sdk");
|
|
3087
|
+
if (sdk.VERSION !== "0.2.3")
|
|
3088
|
+
throw new Error("trash_version_mismatch");
|
|
3089
|
+
const boundedFetch = (input, init) => {
|
|
3090
|
+
const remaining = deadline - Date.now();
|
|
3091
|
+
if (remaining <= 0)
|
|
3092
|
+
throw new Error("maintenance_deadline");
|
|
3093
|
+
const signal = AbortSignal.timeout(remaining);
|
|
3094
|
+
return fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal, redirect: "error" });
|
|
3095
|
+
};
|
|
3096
|
+
return Object.assign(sdk.createTrash({ env, operationRoot, checkpoint, fetchImpl: boundedFetch, transferFetch: boundedFetch, lockTimeoutMs: 100 }), { snapshot: sdk.snapshotIdentity });
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
class IngestJournalMaintenance {
|
|
3101
|
+
journal;
|
|
3102
|
+
cloud;
|
|
3103
|
+
factory;
|
|
3104
|
+
control;
|
|
3105
|
+
operationRoot;
|
|
3106
|
+
constructor(journal, cloud, factory = recoveryFactory(cloud)) {
|
|
3107
|
+
this.journal = journal;
|
|
3108
|
+
this.cloud = cloud;
|
|
3109
|
+
this.factory = factory;
|
|
3110
|
+
journal.assertCloud(cloud);
|
|
3111
|
+
this.control = join10(journal.directory, ".maintenance");
|
|
3112
|
+
this.operationRoot = join10(journal.directory, ".recovery");
|
|
3113
|
+
}
|
|
3114
|
+
manifest() {
|
|
3115
|
+
secureDirectory(this.control);
|
|
3116
|
+
const names = readdirSync2(this.control);
|
|
3117
|
+
if (names.some((n) => !["manifest.json", "intent.operation.json", "receipt.operation.json"].includes(n)))
|
|
3118
|
+
throw new Error("Unexpected maintenance entry");
|
|
3119
|
+
let size = 0;
|
|
3120
|
+
for (const name of names)
|
|
3121
|
+
size += lstatSync2(join10(this.control, name)).size;
|
|
3122
|
+
if (size > MAINTENANCE_RESERVE)
|
|
3123
|
+
throw new Error("Maintenance metadata exceeds reserve");
|
|
3124
|
+
const m = validAnchor(readJson(join10(this.control, "manifest.json"), 8192), this.journal.target);
|
|
3125
|
+
for (const part of [m.intent, m.receipt_file])
|
|
3126
|
+
if (!part || !digest2.test(part.sha256) || !digest2.test(part.capture_sha256 ?? "") || !Number.isSafeInteger(part.sizeBytes) || part.sizeBytes < 1 || part.sizeBytes > 4198400 || part.mode !== 384 || !/^\d+$/.test(part.device) || !/^\d+$/.test(part.inode) || !Number.isFinite(part.ctime))
|
|
3127
|
+
throw new Error("Invalid maintenance snapshot");
|
|
3128
|
+
return m;
|
|
3129
|
+
}
|
|
3130
|
+
pending(backend) {
|
|
3131
|
+
const pending = backend.pending(4);
|
|
3132
|
+
if (pending.length > 3 || pending.some((p) => !uuid.test(p.id) || p.phase === "unreadable"))
|
|
3133
|
+
throw new Error("Unexpected Trash recovery state");
|
|
3134
|
+
return pending;
|
|
3135
|
+
}
|
|
3136
|
+
async backend(deadline) {
|
|
3137
|
+
const backend = await this.factory(this.operationRoot, async (point, operation) => {
|
|
3138
|
+
if (operation.path === this.control)
|
|
3139
|
+
return;
|
|
3140
|
+
const m = this.manifest();
|
|
3141
|
+
const kind = operation.path === this.journal.file(m.key, "receipt") ? "receipt" : operation.path === this.journal.file(m.key, "intent") ? "intent" : null;
|
|
3142
|
+
if (!kind || operation.kind !== "capture" || !uuid.test(operation.id) || operation.id !== operation.entryId)
|
|
3143
|
+
throw new Error("Unexpected Trash operation");
|
|
3144
|
+
const expected = kind === "receipt" ? m.receipt_file : m.intent;
|
|
3145
|
+
if (point === "afterSnapshot") {
|
|
3146
|
+
if (!operation.receipt || operation.receipt.sha256 !== expected.capture_sha256 || operation.receipt.sizeBytes !== expected.sizeBytes || operation.receipt.mode !== expected.mode)
|
|
3147
|
+
throw new Error("Trash snapshot mismatch");
|
|
3148
|
+
sameSource(operation.path, expected);
|
|
3149
|
+
const path = join10(this.control, `${kind}.operation.json`);
|
|
3150
|
+
createExact(path, { id: operation.id });
|
|
3151
|
+
if (readJson(path, 1024).id !== operation.id)
|
|
3152
|
+
throw new Error("Conflicting Trash operation");
|
|
3153
|
+
}
|
|
3154
|
+
if (point === "beforeStage") {
|
|
3155
|
+
await confirm(this.cloud, m, deadline);
|
|
3156
|
+
sameSource(operation.path, expected);
|
|
3157
|
+
}
|
|
3158
|
+
}, deadline);
|
|
3159
|
+
const status = await backend.doctor();
|
|
3160
|
+
if (!status.station || !uuid.test(status.station.id) || !Number.isInteger(status.retentionDays) || status.retentionDays < 1)
|
|
3161
|
+
throw new Error("Trash station authority unavailable");
|
|
3162
|
+
return backend;
|
|
3163
|
+
}
|
|
3164
|
+
async entry(backend, id, path, expected) {
|
|
3165
|
+
const e = await backend.api.get(id);
|
|
3166
|
+
if (e.id !== id || e.originalPath !== path || e.kind !== "file" || e.sha256 !== expected.capture_sha256 || e.sizeBytes !== expected.sizeBytes || e.mode !== expected.mode || e.state !== "trashed")
|
|
3167
|
+
throw new Error("Trash preservation readback refused");
|
|
3168
|
+
if (exists(path))
|
|
3169
|
+
throw new Error("Retired source reappeared; preserve for inspection");
|
|
3170
|
+
}
|
|
3171
|
+
async pairFile(backend, m, kind, deadline) {
|
|
3172
|
+
await confirm(this.cloud, m, deadline);
|
|
3173
|
+
const path = this.journal.file(m.key, kind), expected = kind === "receipt" ? m.receipt_file : m.intent;
|
|
3174
|
+
const saved = join10(this.control, `${kind}.operation.json`);
|
|
3175
|
+
let id;
|
|
3176
|
+
if (exists(saved))
|
|
3177
|
+
id = readJson(saved, 1024).id;
|
|
3178
|
+
const pending = this.pending(backend);
|
|
3179
|
+
const matches = pending.filter((p) => p.path === path);
|
|
3180
|
+
if (matches.length > 1 || id && !uuid.test(id))
|
|
3181
|
+
throw new Error("Conflicting Trash operation");
|
|
3182
|
+
if (matches[0]) {
|
|
3183
|
+
if (id && id !== matches[0].id)
|
|
3184
|
+
throw new Error("Conflicting Trash operation");
|
|
3185
|
+
id = matches[0].id;
|
|
3186
|
+
const operation = backend.journal.read(id);
|
|
3187
|
+
if (operation.kind !== "capture" || operation.path !== path || operation.entryId !== id)
|
|
3188
|
+
throw new Error("Invalid Trash operation binding");
|
|
3189
|
+
if (exists(path))
|
|
3190
|
+
sameSource(path, expected);
|
|
3191
|
+
await backend.recover(id, { takeAbandonedLock: true });
|
|
3192
|
+
} else if (!id) {
|
|
3193
|
+
sameSource(path, expected);
|
|
3194
|
+
const status = await backend.doctor();
|
|
3195
|
+
const e = await backend.put(path, { retentionDays: status.retentionDays });
|
|
3196
|
+
id = e.id;
|
|
3197
|
+
if (!exists(saved) || readJson(saved, 1024).id !== id)
|
|
3198
|
+
throw new Error("Trash did not persist its capture binding");
|
|
3199
|
+
}
|
|
3200
|
+
await this.entry(backend, id, path, expected);
|
|
3201
|
+
}
|
|
3202
|
+
async recover() {
|
|
3203
|
+
if (!exists(this.control) && !exists(this.operationRoot))
|
|
3204
|
+
return;
|
|
3205
|
+
if (exists(this.operationRoot))
|
|
3206
|
+
secureDirectory(this.operationRoot);
|
|
3207
|
+
if (!exists(this.control) && readdirSync2(this.operationRoot).every((n) => n === ".locks"))
|
|
3208
|
+
return;
|
|
3209
|
+
const deadline = Date.now() + 30000;
|
|
3210
|
+
try {
|
|
3211
|
+
await this.finish(await this.backend(deadline), deadline);
|
|
3212
|
+
} catch (error) {
|
|
3213
|
+
if (error instanceof JournalMaintenanceError)
|
|
3214
|
+
throw error;
|
|
3215
|
+
throw new JournalMaintenanceError("recovery_unresolved", this.journal.target);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
async finish(backend, deadline) {
|
|
3219
|
+
const pending = this.pending(backend);
|
|
3220
|
+
const control = pending.filter((p) => p.path === this.control);
|
|
3221
|
+
if (control.length) {
|
|
3222
|
+
if (control.length !== 1 || pending.length !== 1)
|
|
3223
|
+
throw new Error("Conflicting maintenance cleanup");
|
|
3224
|
+
const op = backend.journal.read(control[0].id);
|
|
3225
|
+
if (op.kind !== "capture" || op.path !== this.control)
|
|
3226
|
+
throw new Error("Invalid maintenance cleanup");
|
|
3227
|
+
await backend.recover(op.id, { takeAbandonedLock: true });
|
|
3228
|
+
if (exists(this.control) || this.pending(backend).length)
|
|
3229
|
+
throw new Error("Maintenance cleanup unresolved");
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
if (!exists(this.control)) {
|
|
3233
|
+
if (pending.length)
|
|
3234
|
+
throw new Error("Orphaned maintenance operation");
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
if (readdirSync2(this.control).length === 0) {
|
|
3238
|
+
if (pending.length)
|
|
3239
|
+
throw new Error("Orphaned maintenance operation");
|
|
3240
|
+
const status2 = await backend.doctor();
|
|
3241
|
+
await backend.put(this.control, { retentionDays: status2.retentionDays });
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
const m = this.manifest();
|
|
3245
|
+
if (pending.some((p) => p.path !== this.journal.file(m.key, "receipt") && p.path !== this.journal.file(m.key, "intent")))
|
|
3246
|
+
throw new Error("Unexpected recovery path");
|
|
3247
|
+
await this.pairFile(backend, m, "receipt", deadline);
|
|
3248
|
+
await this.pairFile(backend, m, "intent", deadline);
|
|
3249
|
+
if (this.pending(backend).length)
|
|
3250
|
+
throw new Error("Unfinished pair capture");
|
|
3251
|
+
await confirm(this.cloud, m, deadline);
|
|
3252
|
+
const status = await backend.doctor();
|
|
3253
|
+
await backend.put(this.control, { retentionDays: status.retentionDays });
|
|
3254
|
+
if (exists(this.control) || this.pending(backend).length)
|
|
3255
|
+
throw new Error("Maintenance cleanup unresolved");
|
|
3256
|
+
}
|
|
3257
|
+
async maintain(limit = 32) {
|
|
3258
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 32)
|
|
3259
|
+
throw new Error("Journal maintenance limit must be 1\u201332");
|
|
3260
|
+
await this.recover();
|
|
3261
|
+
await verifyJournalAuthority(this.journal, this.cloud);
|
|
3262
|
+
const deadline = Date.now() + 30000;
|
|
3263
|
+
let retired = 0, bytes = 0;
|
|
3264
|
+
let backend;
|
|
3265
|
+
try {
|
|
3266
|
+
const names = this.journal.names();
|
|
3267
|
+
for (const name of names.filter((n) => n.endsWith(".intent.json"))) {
|
|
3268
|
+
if (retired >= limit || Date.now() >= deadline || retired > 0 && bytes >= 1048576)
|
|
3269
|
+
break;
|
|
3270
|
+
if (!names.includes(name.replace(".intent.", ".receipt.")))
|
|
3271
|
+
continue;
|
|
3272
|
+
const intent = this.journal.intent(name), receipt = this.journal.receipt(intent.command);
|
|
3273
|
+
backend ??= await this.backend(deadline);
|
|
3274
|
+
const m = {
|
|
3275
|
+
version: 1,
|
|
3276
|
+
target: this.journal.target,
|
|
3277
|
+
key: intent.command.idempotency_key,
|
|
3278
|
+
body_hash: intent.body_hash,
|
|
3279
|
+
receipt,
|
|
3280
|
+
intent: snapshot(join10(this.journal.directory, name)),
|
|
3281
|
+
receipt_file: snapshot(this.journal.file(intent.command.idempotency_key, "receipt"))
|
|
3282
|
+
};
|
|
3283
|
+
await confirm(this.cloud, m, deadline);
|
|
3284
|
+
m.intent.capture_sha256 = backend.snapshot(this.journal.file(m.key, "intent")).sha256;
|
|
3285
|
+
m.receipt_file.capture_sha256 = backend.snapshot(this.journal.file(m.key, "receipt")).sha256;
|
|
3286
|
+
secureDirectory(this.control);
|
|
3287
|
+
if (!createExact(join10(this.control, "manifest.json"), m))
|
|
3288
|
+
throw new Error("Existing maintenance transaction");
|
|
3289
|
+
await this.finish(backend, deadline);
|
|
3290
|
+
retired++;
|
|
3291
|
+
bytes += m.intent.sizeBytes + m.receipt_file.sizeBytes;
|
|
3292
|
+
}
|
|
3293
|
+
return { retired, remaining: this.journal.status().completed };
|
|
3294
|
+
} catch (error) {
|
|
3295
|
+
if (error instanceof JournalMaintenanceError)
|
|
3296
|
+
throw error;
|
|
3297
|
+
const reason = error instanceof Error && ["recovery_not_configured", "self_host_trash_authority_required", "trash_version_mismatch"].includes(error.message) ? error.message : backend ? "recovery_unresolved" : "recovery_authority_unavailable";
|
|
3298
|
+
throw new JournalMaintenanceError(reason, this.journal.target);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
async admit(command) {
|
|
3302
|
+
const status = this.journal.status();
|
|
3303
|
+
const fits = this.journal.canStage(command, status);
|
|
3304
|
+
if (status.commands < 64 && status.reserved_bytes < 50331648 && fits)
|
|
3305
|
+
return;
|
|
3306
|
+
try {
|
|
3307
|
+
await this.maintain();
|
|
3308
|
+
} catch (error) {
|
|
3309
|
+
if (exists(this.control) || !this.journal.canStage(command))
|
|
3310
|
+
throw error;
|
|
3311
|
+
}
|
|
3312
|
+
if (!this.journal.canStage(command))
|
|
3313
|
+
throw new JournalMaintenanceError("capacity", this.journal.target);
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
// src/lib/ingest-journal-lock.ts
|
|
3318
|
+
import { constants as constants3, closeSync as closeSync3, fstatSync as fstatSync3, lstatSync as lstatSync3, openSync as openSync3 } from "fs";
|
|
3319
|
+
import { join as join11 } from "path";
|
|
3320
|
+
async function withIngestJournalLock(directory, action) {
|
|
3321
|
+
if (process.platform !== "linux" && process.platform !== "darwin")
|
|
3322
|
+
throw new Error("Ingest journal locking requires Linux or macOS");
|
|
3323
|
+
const { dlopen, FFIType } = await import("bun:ffi");
|
|
3324
|
+
const library = dlopen(process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6", {
|
|
3325
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 }
|
|
3326
|
+
});
|
|
3327
|
+
const path = join11(directory, ".lease");
|
|
3328
|
+
let fd;
|
|
3329
|
+
try {
|
|
3330
|
+
fd = openSync3(path, constants3.O_RDWR | constants3.O_CREAT | constants3.O_NOFOLLOW | constants3.O_NONBLOCK, 384);
|
|
3331
|
+
const stat = fstatSync3(fd), current = lstatSync3(path);
|
|
3332
|
+
if (!stat.isFile() || stat.size !== 0 || stat.nlink !== 1 || stat.ino !== current.ino || stat.dev !== current.dev || (stat.mode & 63) !== 0 || process.getuid && stat.uid !== process.getuid())
|
|
3333
|
+
throw new Error("Unsafe ingest journal lease");
|
|
3334
|
+
if (library.symbols.flock(fd, 6) !== 0) {
|
|
3335
|
+
const error = new Error("Ingest journal is busy; retry after the current operation finishes");
|
|
3336
|
+
Object.assign(error, { code: "INGEST_JOURNAL_BUSY" });
|
|
3337
|
+
throw error;
|
|
3338
|
+
}
|
|
3339
|
+
return await action();
|
|
3340
|
+
} finally {
|
|
3341
|
+
if (fd !== undefined)
|
|
3342
|
+
closeSync3(fd);
|
|
3343
|
+
library.close();
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// src/lib/ingest-journal-operation.ts
|
|
3348
|
+
import { z as z2 } from "zod";
|
|
3349
|
+
var ingestJournalOperationSchema = z2.discriminatedUnion("action", [
|
|
3350
|
+
z2.object({ action: z2.literal("status") }).strict(),
|
|
3351
|
+
z2.object({ action: z2.literal("reconcile") }).strict(),
|
|
3352
|
+
z2.object({ action: z2.literal("maintain"), confirm: z2.literal(true), limit: z2.number().int().min(1).max(32).default(32) }).strict()
|
|
3353
|
+
]);
|
|
3354
|
+
|
|
3355
|
+
// src/lib/cloud-ingest.ts
|
|
3356
|
+
import { HasnaHttpError } from "@hasna/contracts/client";
|
|
3357
|
+
var CLOUD_INGEST_TABLES = ["requests", "sessions", "billing_daily", "usage_snapshots", "subscriptions", "scan_receipts"];
|
|
3358
|
+
function exportIngestRows(db) {
|
|
3359
|
+
const body = {};
|
|
3360
|
+
for (const table of CLOUD_INGEST_TABLES) {
|
|
3361
|
+
const rows = db.prepare(`SELECT * FROM ${table}`).all();
|
|
3362
|
+
if (rows.length > 0)
|
|
3363
|
+
body[table] = rows;
|
|
3364
|
+
}
|
|
3365
|
+
return body;
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
class IngestRefused extends Error {
|
|
3369
|
+
status;
|
|
3370
|
+
code;
|
|
3371
|
+
reason;
|
|
3372
|
+
constructor(status, code, reason = code) {
|
|
3373
|
+
super(`Ingestion refused: ${reason}; exact command intent retained`);
|
|
3374
|
+
this.status = status;
|
|
3375
|
+
this.code = code;
|
|
3376
|
+
this.reason = reason;
|
|
3377
|
+
this.name = "IngestRefused";
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
async function postIngest(cloud, body) {
|
|
3381
|
+
const journal = new IngestJournal(cloud);
|
|
3382
|
+
return withIngestJournalLock(journal.directory, async () => {
|
|
3383
|
+
const maintenance = new IngestJournalMaintenance(journal, cloud);
|
|
3384
|
+
await maintenance.recover();
|
|
3385
|
+
await journal.reconcilePending(cloud);
|
|
3386
|
+
await verifyJournalAuthority(journal, cloud);
|
|
3387
|
+
const ingested = {};
|
|
3388
|
+
const completed = [];
|
|
3389
|
+
let total = 0;
|
|
3390
|
+
for (const batch of ingestBatches(body)) {
|
|
3391
|
+
try {
|
|
3392
|
+
const refused = journal.refusal(batch);
|
|
3393
|
+
if (refused)
|
|
3394
|
+
throw new IngestRefused(refused.status, refused.code, refused.reason);
|
|
3395
|
+
await maintenance.admit(batch);
|
|
3396
|
+
const known = await journal.lookupReceipt(cloud, batch);
|
|
3397
|
+
const fresh = journal.stage(batch);
|
|
3398
|
+
const sendFresh = async () => {
|
|
3399
|
+
let response;
|
|
3400
|
+
try {
|
|
3401
|
+
response = await cloud.client.transport.post("/ingest", batch, { retry: false });
|
|
3402
|
+
} catch (error) {
|
|
3403
|
+
const refusal = error instanceof HasnaHttpError ? definitiveIngestRefusal(error.status, error.body) : null;
|
|
3404
|
+
if (refusal) {
|
|
3405
|
+
journal.recordRefusal(batch, refusal);
|
|
3406
|
+
throw new IngestRefused(refusal.status, refusal.code, refusal.reason);
|
|
3407
|
+
}
|
|
3408
|
+
throw error;
|
|
3409
|
+
}
|
|
3410
|
+
return journal.accept(batch, response);
|
|
3411
|
+
};
|
|
3412
|
+
const payload = known ? journal.accept(batch, known) : fresh ? await sendFresh() : await journal.reconcileCommand(cloud, batch);
|
|
3413
|
+
await verifyJournalAuthority(journal, cloud);
|
|
3414
|
+
if (!payload)
|
|
3415
|
+
throw new PendingIngestError([batch.idempotency_key]);
|
|
3416
|
+
verifyIngestReceipt(payload, batch);
|
|
3417
|
+
const counts = payload["ingested"];
|
|
3418
|
+
if (!counts || typeof counts !== "object" || Array.isArray(counts) || typeof payload["total"] !== "number" || !Number.isSafeInteger(payload["total"]) || payload["total"] < 0)
|
|
3419
|
+
throw new Error("Invalid ingest receipt");
|
|
3420
|
+
let sum = 0;
|
|
3421
|
+
for (const [table, count] of Object.entries(counts)) {
|
|
3422
|
+
if (!CLOUD_INGEST_TABLES.includes(table) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)
|
|
3423
|
+
throw new Error("Invalid ingest receipt");
|
|
3424
|
+
sum += count;
|
|
3425
|
+
ingested[table] = (ingested[table] ?? 0) + count;
|
|
3426
|
+
}
|
|
3427
|
+
if (sum !== payload["total"])
|
|
3428
|
+
throw new Error("Invalid ingest receipt");
|
|
3429
|
+
total += sum;
|
|
3430
|
+
const operation = payload["operation_key"];
|
|
3431
|
+
if (operation !== undefined && (typeof operation !== "string" || !/^[a-f0-9]{64}$/.test(operation)))
|
|
3432
|
+
throw new Error("Invalid ingest receipt");
|
|
3433
|
+
completed.push({
|
|
3434
|
+
idempotency_key: batch.idempotency_key,
|
|
3435
|
+
...typeof operation === "string" ? { operation_key: operation } : {},
|
|
3436
|
+
replayed: payload["replayed"] === true
|
|
3437
|
+
});
|
|
3438
|
+
} catch (error) {
|
|
3439
|
+
if (error instanceof PendingIngestError || error instanceof JournalMaintenanceError || error instanceof IngestRefused)
|
|
3440
|
+
throw error;
|
|
3441
|
+
if (error instanceof HasnaHttpError && error.status >= 400 && error.status < 500 && error.status !== 408) {
|
|
3442
|
+
const body2 = error.body;
|
|
3443
|
+
throw new IngestRefused(error.status, body2?.expected === "legacy_aggregate_requires_migration" ? "legacy_aggregate_requires_migration" : "ingest_refused");
|
|
3444
|
+
}
|
|
3445
|
+
throw new IngestBatchFailure(batch.idempotency_key, completed, batch);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
return { ingested, total, receipts: completed };
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
async function pushIngestRows(cloud, scratch) {
|
|
3452
|
+
const body = exportIngestRows(scratch);
|
|
3453
|
+
if (Object.keys(body).length === 0)
|
|
3454
|
+
return { posted: false, ingested: {}, total: 0, receipts: [] };
|
|
3455
|
+
const response = await postIngest(cloud, body);
|
|
3456
|
+
return { posted: true, ...response };
|
|
3457
|
+
}
|
|
1416
3458
|
// package.json
|
|
1417
3459
|
var package_default = {
|
|
1418
3460
|
name: "@hasna/economy",
|
|
1419
|
-
version: "0.
|
|
1420
|
-
description: "AI coding cost tracker \u2014 CLI + MCP server + REST API
|
|
3461
|
+
version: "0.5.0",
|
|
3462
|
+
description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
|
|
1421
3463
|
repository: {
|
|
1422
3464
|
type: "git",
|
|
1423
|
-
url: "
|
|
3465
|
+
url: "https://github.com/hasna/apps.git",
|
|
3466
|
+
directory: "apps/economy"
|
|
1424
3467
|
},
|
|
1425
3468
|
bugs: {
|
|
1426
|
-
url: "https://github.com/hasna/
|
|
3469
|
+
url: "https://github.com/hasna/apps/issues"
|
|
1427
3470
|
},
|
|
1428
|
-
homepage: "https://github.com/hasna/economy#readme",
|
|
3471
|
+
homepage: "https://github.com/hasna/apps/tree/main/apps/economy#readme",
|
|
1429
3472
|
type: "module",
|
|
1430
3473
|
main: "dist/index.js",
|
|
1431
3474
|
types: "dist/index.d.ts",
|
|
@@ -1448,22 +3491,21 @@ var package_default = {
|
|
|
1448
3491
|
files: [
|
|
1449
3492
|
"dist",
|
|
1450
3493
|
"docs",
|
|
1451
|
-
"dashboard/dist",
|
|
1452
3494
|
"postinstall.js",
|
|
1453
3495
|
"LICENSE",
|
|
1454
3496
|
"README.md",
|
|
1455
3497
|
"CHANGELOG.md",
|
|
1456
3498
|
"SECURITY.md",
|
|
1457
3499
|
"CONTRIBUTING.md",
|
|
1458
|
-
"CODE_OF_CONDUCT.md"
|
|
3500
|
+
"CODE_OF_CONDUCT.md",
|
|
3501
|
+
"!dist/lib/test-hermetic-*"
|
|
1459
3502
|
],
|
|
1460
3503
|
scripts: {
|
|
1461
|
-
build:
|
|
1462
|
-
"build:cli":
|
|
1463
|
-
"build:mcp":
|
|
1464
|
-
"build:server": "bun build src/server/index.ts --outdir dist/server --target bun --packages external",
|
|
1465
|
-
"build:lib":
|
|
1466
|
-
"build:dashboard": "cd dashboard && bun run build",
|
|
3504
|
+
build: 'bun build src/cli/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]" && bun build src/mcp/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]" && bun build src/server/index.ts --outdir dist/server --target bun --packages external && bun build src/db/pg-sync-worker.ts --outdir dist/server --target bun --packages external && bun build src/server/service-worker.ts --outdir dist/server --target bun --packages external && bun build src/otel/index.ts --outdir dist/otel --target bun --packages external && bun build src/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]" && tsc --emitDeclarationOnly --outDir dist',
|
|
3505
|
+
"build:cli": 'bun build src/cli/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]"',
|
|
3506
|
+
"build:mcp": 'bun build src/mcp/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]"',
|
|
3507
|
+
"build:server": "bun build src/server/index.ts --outdir dist/server --target bun --packages external && bun build src/db/pg-sync-worker.ts --outdir dist/server --target bun --packages external && bun build src/server/service-worker.ts --outdir dist/server --target bun --packages external",
|
|
3508
|
+
"build:lib": 'bun build src/index.ts --outdir dist --root src --target bun --packages external --splitting --chunk-naming "chunks/[name]-[hash].[ext]"',
|
|
1467
3509
|
"artifact-scan": "bun scripts/contracts-cli.mjs artifact-scan dist",
|
|
1468
3510
|
prepack: "bun run build && bun run artifact-scan",
|
|
1469
3511
|
typecheck: "tsc --noEmit",
|
|
@@ -1493,21 +3535,18 @@ var package_default = {
|
|
|
1493
3535
|
access: "public"
|
|
1494
3536
|
},
|
|
1495
3537
|
dependencies: {
|
|
1496
|
-
"@hasna/
|
|
1497
|
-
"@hasna/agent-registry": "^0.1.0",
|
|
1498
|
-
"@hasna/contracts": "0.13.4",
|
|
3538
|
+
"@hasna/contracts": "1.0.2",
|
|
1499
3539
|
"@hasna/events": "^0.1.16",
|
|
1500
|
-
"@hasna/mcp-harness": "^0.1.0",
|
|
1501
|
-
"@hasna/paths": "0.1.0",
|
|
1502
3540
|
"@hasna/projects": "1.0.0",
|
|
1503
3541
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1504
3542
|
chalk: "^5.4.1",
|
|
1505
3543
|
commander: "^13.1.0",
|
|
1506
3544
|
pg: "8.13.1",
|
|
1507
|
-
zod: "^3.24.2"
|
|
3545
|
+
zod: "^3.24.2",
|
|
3546
|
+
"@hasna/trash": "0.2.3"
|
|
1508
3547
|
},
|
|
1509
3548
|
devDependencies: {
|
|
1510
|
-
"@types/bun": "
|
|
3549
|
+
"@types/bun": "1.3.14",
|
|
1511
3550
|
"@types/pg": "^8.20.0",
|
|
1512
3551
|
"bun-types": "latest",
|
|
1513
3552
|
typescript: "^5.7.2"
|
|
@@ -1547,7 +3586,7 @@ if (args.includes("--version") || args.includes("-V")) {
|
|
|
1547
3586
|
if (args.includes("--help") || args.includes("-h")) {
|
|
1548
3587
|
console.log(`Usage: economy-otel [options]
|
|
1549
3588
|
|
|
1550
|
-
OTLP/HTTP metrics sidecar \u2014 ingests *.cost.* / *.token.* metrics into
|
|
3589
|
+
OTLP/HTTP metrics sidecar \u2014 ingests *.cost.* / *.token.* metrics into Economy
|
|
1551
3590
|
|
|
1552
3591
|
Options:
|
|
1553
3592
|
-p, --port <port> Port to bind (default: ECONOMY_OTEL_PORT or 4318)
|
|
@@ -1558,6 +3597,13 @@ Environment:
|
|
|
1558
3597
|
ECONOMY_OTEL_PORT Override the default port (4318)
|
|
1559
3598
|
ECONOMY_OTEL_BIND Bind address (default: 127.0.0.1)
|
|
1560
3599
|
|
|
3600
|
+
Storage (decided at startup, before the listener binds):
|
|
3601
|
+
A credential resolved by @hasna/contracts (Keychain item
|
|
3602
|
+
hasna.credentials.economy.api-key, ~/.hasna/economy/config/credentials,
|
|
3603
|
+
HASNA_ECONOMY_API_KEY) forwards every accepted payload to the hosted
|
|
3604
|
+
API's /v1/ingest. HASNA_ECONOMY_LOCAL=1 writes the on-box SQLite store
|
|
3605
|
+
instead. With neither the sidecar fails closed (exit 1, no listener).
|
|
3606
|
+
|
|
1561
3607
|
Endpoints:
|
|
1562
3608
|
POST /v1/metrics OTLP JSON metrics
|
|
1563
3609
|
POST /ingest Simplified single-event JSON
|
|
@@ -1565,7 +3611,28 @@ Endpoints:
|
|
|
1565
3611
|
process.exit(0);
|
|
1566
3612
|
}
|
|
1567
3613
|
var port = resolvePort(args);
|
|
1568
|
-
|
|
3614
|
+
function resolveStorage() {
|
|
3615
|
+
try {
|
|
3616
|
+
return economyCloudStorage();
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
console.error(`economy-otel: ${error instanceof Error ? error.message : String(error)}`);
|
|
3619
|
+
process.exit(1);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
var storage = resolveStorage();
|
|
3623
|
+
var localDb = storage.active ? null : openDatabase();
|
|
3624
|
+
async function forwardRows(cloud, rows) {
|
|
3625
|
+
const scratch = openDatabase(":memory:", true);
|
|
3626
|
+
try {
|
|
3627
|
+
const result = await ingestOtelRows(scratch, rows);
|
|
3628
|
+
const pushed = await pushIngestRows(cloud, scratch);
|
|
3629
|
+
return { ...result, forwarded: pushed.total };
|
|
3630
|
+
} finally {
|
|
3631
|
+
try {
|
|
3632
|
+
scratch.close();
|
|
3633
|
+
} catch {}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
1569
3636
|
var server = Bun.serve({
|
|
1570
3637
|
port,
|
|
1571
3638
|
hostname: process.env["ECONOMY_OTEL_BIND"] ?? "127.0.0.1",
|
|
@@ -1590,7 +3657,16 @@ var server = Bun.serve({
|
|
|
1590
3657
|
if (rows.length === 0) {
|
|
1591
3658
|
return Response.json({ ingested: 0, message: "no matching metrics" });
|
|
1592
3659
|
}
|
|
1593
|
-
|
|
3660
|
+
if (storage.active) {
|
|
3661
|
+
try {
|
|
3662
|
+
const result2 = await forwardRows(storage, rows);
|
|
3663
|
+
return Response.json({ ingested: result2.requests, sessions: result2.sessions, forwarded: result2.forwarded });
|
|
3664
|
+
} catch (error) {
|
|
3665
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3666
|
+
return Response.json({ error: `forward to the economy API failed: ${reason}` }, { status: 502 });
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
const result = await ingestOtelRows(localDb, rows);
|
|
1594
3670
|
return Response.json({ ingested: result.requests, sessions: result.sessions });
|
|
1595
3671
|
}
|
|
1596
3672
|
});
|