@openplan/dsh-fuse 0.1.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/LICENSE +21 -0
- package/README.md +214 -0
- package/cordis.patch.yml +29 -0
- package/dist/budget-tool.d.ts +66 -0
- package/dist/budget-tool.js +108 -0
- package/dist/config.d.ts +155 -0
- package/dist/config.js +125 -0
- package/dist/fuse.d.ts +59 -0
- package/dist/fuse.js +83 -0
- package/dist/harness.d.ts +48 -0
- package/dist/harness.js +773 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +55 -0
- package/dist/meter.d.ts +71 -0
- package/dist/meter.js +73 -0
- package/dist/pricing.d.ts +193 -0
- package/dist/pricing.js +450 -0
- package/dist/router.d.ts +30 -0
- package/dist/router.js +34 -0
- package/dist/store.d.ts +168 -0
- package/dist/store.js +412 -0
- package/dist/sync.d.ts +70 -0
- package/dist/sync.js +153 -0
- package/dist/wire.d.ts +52 -0
- package/dist/wire.js +15 -0
- package/package.json +64 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh local store — libsql persistence for the plugin (proposal §1: "Store
|
|
3
|
+
* local libsql — sem build nativa, distribuível sem compilação").
|
|
4
|
+
*
|
|
5
|
+
* The local fuse's spentUsd comes from HERE in local-only mode: spend is
|
|
6
|
+
* recorded per project and windowed on demand, surviving restarts, and the
|
|
7
|
+
* sync path pulls exactly the rows not yet acknowledged by the SaaS. The
|
|
8
|
+
* store is pure persistence — enforcement math stays in fuseDecision.
|
|
9
|
+
*
|
|
10
|
+
* ## Fidelity
|
|
11
|
+
*
|
|
12
|
+
* A stored row carries everything the SaaS needs to answer "quanto queimamos,
|
|
13
|
+
* quem é o vilão, o que a política travou hoje" (proposal §3): the hashed
|
|
14
|
+
* session identity, the reasoning effort actually used, and the full disjoint
|
|
15
|
+
* token accounting the harness reports (`TokenUsage`: uncached input, output,
|
|
16
|
+
* cache reads, cache writes). Dropping any of them makes a server-side policy
|
|
17
|
+
* unevaluable or a session dimension unanswerable, so they are part of the
|
|
18
|
+
* schema rather than of the sync mapping.
|
|
19
|
+
*
|
|
20
|
+
* The `usage` table is migrated in place ({@link migrate}) so an existing
|
|
21
|
+
* `file:local.db` from an earlier version keeps its spend history instead of
|
|
22
|
+
* being discarded.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { createClient } from "@libsql/client";
|
|
28
|
+
const COLUMNS = {
|
|
29
|
+
usage: [
|
|
30
|
+
["session_id", "TEXT NOT NULL DEFAULT ''"],
|
|
31
|
+
["project", "TEXT NOT NULL"],
|
|
32
|
+
["cost_usd", "REAL NOT NULL"],
|
|
33
|
+
["at", "TEXT NOT NULL"],
|
|
34
|
+
["synced", "INTEGER NOT NULL DEFAULT 0"],
|
|
35
|
+
["model", "TEXT NOT NULL DEFAULT ''"],
|
|
36
|
+
["provider", "TEXT NOT NULL DEFAULT ''"],
|
|
37
|
+
["reasoning_effort", "TEXT NOT NULL DEFAULT ''"],
|
|
38
|
+
["input_tokens", "INTEGER NOT NULL DEFAULT 0"],
|
|
39
|
+
["output_tokens", "INTEGER NOT NULL DEFAULT 0"],
|
|
40
|
+
["cache_read_tokens", "INTEGER NOT NULL DEFAULT 0"],
|
|
41
|
+
["cache_write_tokens", "INTEGER NOT NULL DEFAULT 0"],
|
|
42
|
+
["duration_ms", "INTEGER"],
|
|
43
|
+
["unpriced", "INTEGER NOT NULL DEFAULT 0"],
|
|
44
|
+
["event_id", "TEXT NOT NULL DEFAULT ''"],
|
|
45
|
+
["tools", "TEXT NOT NULL DEFAULT '[]'"],
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Create the tables and add any column a previous version lacked. SQLite has
|
|
50
|
+
* no `ADD COLUMN IF NOT EXISTS`, so the existing column set is read first —
|
|
51
|
+
* an upgrade keeps its history, a fresh database creates everything at once.
|
|
52
|
+
*/
|
|
53
|
+
async function migrate(client) {
|
|
54
|
+
await client.execute(`CREATE TABLE IF NOT EXISTS usage (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
57
|
+
project TEXT NOT NULL,
|
|
58
|
+
cost_usd REAL NOT NULL,
|
|
59
|
+
at TEXT NOT NULL,
|
|
60
|
+
synced INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
model TEXT NOT NULL DEFAULT '',
|
|
62
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
63
|
+
reasoning_effort TEXT NOT NULL DEFAULT '',
|
|
64
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
duration_ms INTEGER,
|
|
69
|
+
unpriced INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
event_id TEXT NOT NULL DEFAULT '',
|
|
71
|
+
tools TEXT NOT NULL DEFAULT '[]'
|
|
72
|
+
)`);
|
|
73
|
+
const existing = await client.execute("PRAGMA table_info(usage)");
|
|
74
|
+
const present = new Set(existing.rows.map((row) => String(row.name)));
|
|
75
|
+
for (const [name, definition] of COLUMNS.usage) {
|
|
76
|
+
if (present.has(name))
|
|
77
|
+
continue;
|
|
78
|
+
await client.execute(`ALTER TABLE usage ADD COLUMN ${name} ${definition}`);
|
|
79
|
+
}
|
|
80
|
+
await client.batch([
|
|
81
|
+
{
|
|
82
|
+
sql: `CREATE TABLE IF NOT EXISTS cuts (
|
|
83
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
84
|
+
project TEXT NOT NULL,
|
|
85
|
+
rule TEXT NOT NULL,
|
|
86
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
87
|
+
)`,
|
|
88
|
+
args: [],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
sql: `CREATE TABLE IF NOT EXISTS state (
|
|
92
|
+
key TEXT PRIMARY KEY,
|
|
93
|
+
value TEXT NOT NULL
|
|
94
|
+
)`,
|
|
95
|
+
args: [],
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the DSH home (harness root) the same way the harness itself does:
|
|
101
|
+
* `$DSH_HOME` when set and non-blank, else `~/.dsh`. The default store path is
|
|
102
|
+
* anchored here so the ledger is stable regardless of the working directory
|
|
103
|
+
* the harness happened to be started from — a CWD-relative default silently
|
|
104
|
+
* split spend across one empty ledger per launch directory.
|
|
105
|
+
*/
|
|
106
|
+
export function dshHome() {
|
|
107
|
+
const override = process.env.DSH_HOME?.trim();
|
|
108
|
+
return override ? override : join(homedir(), ".dsh");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The default store location: `$DSH_HOME/dsh-fuse/local.db` (or the
|
|
112
|
+
* equivalent under `~/.dsh`). The parent directory is created on demand by
|
|
113
|
+
* {@link createLocalStore}, so a fresh harness home needs no manual setup.
|
|
114
|
+
*/
|
|
115
|
+
export function defaultStoreUrl() {
|
|
116
|
+
return `file:${join(dshHome(), "dsh-fuse", "local.db")}`;
|
|
117
|
+
}
|
|
118
|
+
/** Ensure the parent directory of a `file:` store URL exists (libsql does not
|
|
119
|
+
* create missing directories). A `:memory:` or remote URL is untouched.
|
|
120
|
+
*/
|
|
121
|
+
function ensureStoreParentDir(url) {
|
|
122
|
+
const filePath = url.startsWith("file:") ? url.slice("file:".length) : "";
|
|
123
|
+
if (!filePath)
|
|
124
|
+
return;
|
|
125
|
+
const parent = dirname(filePath);
|
|
126
|
+
if (parent && parent !== ".")
|
|
127
|
+
mkdirSync(parent, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
/** `url`: file path for the installed plugin, `:memory:` for tests. A relative
|
|
130
|
+
* `file:` path is only honored when explicitly configured — the default is
|
|
131
|
+
* the harness-home-anchored {@link defaultStoreUrl}, never the CWD.
|
|
132
|
+
*/
|
|
133
|
+
export function createLocalStore(url = defaultStoreUrl()) {
|
|
134
|
+
ensureStoreParentDir(url);
|
|
135
|
+
const client = createClient({ url });
|
|
136
|
+
const ready = migrate(client);
|
|
137
|
+
function number(value, fallback = 0) {
|
|
138
|
+
const parsed = Number(value);
|
|
139
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
140
|
+
}
|
|
141
|
+
/** Parse the JSON-encoded tool-name column, tolerating legacy garbage. */
|
|
142
|
+
function parseTools(value) {
|
|
143
|
+
if (typeof value !== "string" || !value)
|
|
144
|
+
return [];
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(value);
|
|
147
|
+
return Array.isArray(parsed)
|
|
148
|
+
? parsed.filter((item) => typeof item === "string")
|
|
149
|
+
: [];
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function rowToUsage(row) {
|
|
156
|
+
const duration = row.duration_ms;
|
|
157
|
+
return {
|
|
158
|
+
id: number(row.id),
|
|
159
|
+
sessionId: String(row.session_id ?? ""),
|
|
160
|
+
project: String(row.project),
|
|
161
|
+
costUsd: number(row.cost_usd),
|
|
162
|
+
at: String(row.at),
|
|
163
|
+
synced: Boolean(row.synced),
|
|
164
|
+
model: String(row.model ?? ""),
|
|
165
|
+
provider: String(row.provider ?? ""),
|
|
166
|
+
reasoningEffort: String(row.reasoning_effort ?? ""),
|
|
167
|
+
inputTokens: number(row.input_tokens),
|
|
168
|
+
outputTokens: number(row.output_tokens),
|
|
169
|
+
cacheReadTokens: number(row.cache_read_tokens),
|
|
170
|
+
cacheWriteTokens: number(row.cache_write_tokens),
|
|
171
|
+
durationMs: duration === null || duration === undefined ? null : number(duration),
|
|
172
|
+
unpriced: Boolean(row.unpriced),
|
|
173
|
+
eventId: String(row.event_id ?? ""),
|
|
174
|
+
tools: parseTools(row.tools),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
async record(event) {
|
|
179
|
+
await ready;
|
|
180
|
+
await client.execute({
|
|
181
|
+
sql: `INSERT INTO usage (
|
|
182
|
+
session_id, project, cost_usd, at, synced, model, provider,
|
|
183
|
+
reasoning_effort, input_tokens, output_tokens,
|
|
184
|
+
cache_read_tokens, cache_write_tokens, duration_ms, unpriced,
|
|
185
|
+
event_id, tools
|
|
186
|
+
) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
187
|
+
args: [
|
|
188
|
+
event.sessionId ?? "",
|
|
189
|
+
event.project,
|
|
190
|
+
event.costUsd,
|
|
191
|
+
event.at,
|
|
192
|
+
event.model ?? "",
|
|
193
|
+
event.provider ?? "",
|
|
194
|
+
event.reasoningEffort ?? "",
|
|
195
|
+
event.inputTokens ?? 0,
|
|
196
|
+
event.outputTokens ?? 0,
|
|
197
|
+
event.cacheReadTokens ?? 0,
|
|
198
|
+
event.cacheWriteTokens ?? 0,
|
|
199
|
+
event.durationMs ?? null,
|
|
200
|
+
event.unpriced ? 1 : 0,
|
|
201
|
+
event.eventId ?? "",
|
|
202
|
+
JSON.stringify(event.tools ?? []),
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
async spentForWindow({ project, dev, since }) {
|
|
207
|
+
await ready;
|
|
208
|
+
const conditions = ["at >= ?"];
|
|
209
|
+
const args = [since];
|
|
210
|
+
if (project) {
|
|
211
|
+
conditions.push("project = ?");
|
|
212
|
+
args.push(project);
|
|
213
|
+
}
|
|
214
|
+
if (dev) {
|
|
215
|
+
conditions.push("dev = ?");
|
|
216
|
+
args.push(dev);
|
|
217
|
+
}
|
|
218
|
+
const res = await client.execute({
|
|
219
|
+
sql: `SELECT coalesce(sum(cost_usd), 0) AS total FROM usage WHERE ${conditions.join(" AND ")}`,
|
|
220
|
+
args,
|
|
221
|
+
});
|
|
222
|
+
return number(res.rows[0]?.total);
|
|
223
|
+
},
|
|
224
|
+
async pendingSync(limit = 500) {
|
|
225
|
+
await ready;
|
|
226
|
+
const res = await client.execute({
|
|
227
|
+
sql: `SELECT id, session_id, project, cost_usd, at, synced, model,
|
|
228
|
+
provider, reasoning_effort, input_tokens, output_tokens,
|
|
229
|
+
cache_read_tokens, cache_write_tokens, duration_ms, unpriced,
|
|
230
|
+
event_id, tools
|
|
231
|
+
FROM usage WHERE synced = 0 ORDER BY id LIMIT ?`,
|
|
232
|
+
args: [limit],
|
|
233
|
+
});
|
|
234
|
+
return res.rows.map((row) => rowToUsage(row));
|
|
235
|
+
},
|
|
236
|
+
async markSynced(ids) {
|
|
237
|
+
await ready;
|
|
238
|
+
for (const id of ids) {
|
|
239
|
+
await client.execute({
|
|
240
|
+
sql: "UPDATE usage SET synced = 1 WHERE id = ?",
|
|
241
|
+
args: [id],
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
async recordCut(event) {
|
|
246
|
+
await ready;
|
|
247
|
+
await client.execute({
|
|
248
|
+
sql: "INSERT INTO cuts (project, rule, synced) VALUES (?, ?, 0)",
|
|
249
|
+
args: [event.project, event.rule],
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
async pendingCuts(limit = 100) {
|
|
253
|
+
await ready;
|
|
254
|
+
const res = await client.execute({
|
|
255
|
+
sql: "SELECT id, project, rule FROM cuts WHERE synced = 0 ORDER BY id LIMIT ?",
|
|
256
|
+
args: [limit],
|
|
257
|
+
});
|
|
258
|
+
return res.rows.map((row) => ({
|
|
259
|
+
id: number(row.id),
|
|
260
|
+
project: String(row.project),
|
|
261
|
+
rule: String(row.rule),
|
|
262
|
+
}));
|
|
263
|
+
},
|
|
264
|
+
async markCutsSynced(ids) {
|
|
265
|
+
await ready;
|
|
266
|
+
for (const id of ids) {
|
|
267
|
+
await client.execute({
|
|
268
|
+
sql: "UPDATE cuts SET synced = 1 WHERE id = ?",
|
|
269
|
+
args: [id],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
async setRemoteBlock(block) {
|
|
274
|
+
await ready;
|
|
275
|
+
if (block === null) {
|
|
276
|
+
await client.execute({
|
|
277
|
+
sql: "DELETE FROM state WHERE key = 'remote_blocks'",
|
|
278
|
+
args: [],
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Replacing the whole scope's block: one org, one project, one dev.
|
|
283
|
+
const res = await client.execute({
|
|
284
|
+
sql: "SELECT value FROM state WHERE key = 'remote_blocks'",
|
|
285
|
+
args: [],
|
|
286
|
+
});
|
|
287
|
+
let blocks = [];
|
|
288
|
+
const raw = res.rows[0]?.value;
|
|
289
|
+
if (raw) {
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(String(raw));
|
|
292
|
+
if (Array.isArray(parsed))
|
|
293
|
+
blocks = parsed;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
blocks = [];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const scope = block.scope ?? "org";
|
|
300
|
+
const reference = block.reference ?? "";
|
|
301
|
+
// Replacing one scope's block only: same (scope, reference) key.
|
|
302
|
+
blocks = blocks.filter((b) => (b.scope ?? "org") !== scope || (b.reference ?? "") !== reference);
|
|
303
|
+
// An expired block self-clears — windows are the release.
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
blocks = blocks.filter((b) => new Date(b.resetAt).getTime() > now);
|
|
306
|
+
blocks.push(block);
|
|
307
|
+
await client.execute({
|
|
308
|
+
sql: "INSERT INTO state (key, value) VALUES ('remote_blocks', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
309
|
+
args: [JSON.stringify(blocks)],
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
async remoteBlockFor({ project, dev }) {
|
|
313
|
+
await ready;
|
|
314
|
+
const res = await client.execute({
|
|
315
|
+
sql: "SELECT value FROM state WHERE key = 'remote_blocks'",
|
|
316
|
+
args: [],
|
|
317
|
+
});
|
|
318
|
+
const raw = res.rows[0]?.value;
|
|
319
|
+
if (!raw)
|
|
320
|
+
return null;
|
|
321
|
+
let blocks = [];
|
|
322
|
+
try {
|
|
323
|
+
const parsed = JSON.parse(String(raw));
|
|
324
|
+
if (Array.isArray(parsed))
|
|
325
|
+
blocks = parsed;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
blocks = [];
|
|
329
|
+
}
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
for (const block of blocks) {
|
|
332
|
+
if (new Date(block.resetAt).getTime() <= now)
|
|
333
|
+
continue;
|
|
334
|
+
const scope = block.scope ?? "org";
|
|
335
|
+
const reference = block.reference ?? "";
|
|
336
|
+
if (scope === "org") {
|
|
337
|
+
return { rule: block.rule, resetAt: block.resetAt };
|
|
338
|
+
}
|
|
339
|
+
if (scope === "project" && reference === project) {
|
|
340
|
+
return { rule: block.rule, resetAt: block.resetAt };
|
|
341
|
+
}
|
|
342
|
+
if (scope === "dev" && reference === dev) {
|
|
343
|
+
return { rule: block.rule, resetAt: block.resetAt };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
},
|
|
348
|
+
async setRemotePolicy(policy) {
|
|
349
|
+
await ready;
|
|
350
|
+
if (policy === null) {
|
|
351
|
+
await client.execute({
|
|
352
|
+
sql: "DELETE FROM state WHERE key = 'remote_policy'",
|
|
353
|
+
args: [],
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
await client.execute({
|
|
358
|
+
sql: "INSERT INTO state (key, value) VALUES ('remote_policy', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
359
|
+
args: [JSON.stringify(policy)],
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
async remotePolicy() {
|
|
363
|
+
await ready;
|
|
364
|
+
const res = await client.execute({
|
|
365
|
+
sql: "SELECT value FROM state WHERE key = 'remote_policy'",
|
|
366
|
+
args: [],
|
|
367
|
+
});
|
|
368
|
+
const raw = res.rows[0]?.value;
|
|
369
|
+
if (!raw)
|
|
370
|
+
return null;
|
|
371
|
+
try {
|
|
372
|
+
return JSON.parse(String(raw));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
async setPricingTable(table) {
|
|
379
|
+
await ready;
|
|
380
|
+
if (table === null) {
|
|
381
|
+
await client.execute({
|
|
382
|
+
sql: "DELETE FROM state WHERE key = 'pricing_table'",
|
|
383
|
+
args: [],
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
await client.execute({
|
|
388
|
+
sql: "INSERT INTO state (key, value) VALUES ('pricing_table', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
389
|
+
args: [JSON.stringify(table)],
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
async pricingTable() {
|
|
393
|
+
await ready;
|
|
394
|
+
const res = await client.execute({
|
|
395
|
+
sql: "SELECT value FROM state WHERE key = 'pricing_table'",
|
|
396
|
+
args: [],
|
|
397
|
+
});
|
|
398
|
+
const raw = res.rows[0]?.value;
|
|
399
|
+
if (!raw)
|
|
400
|
+
return null;
|
|
401
|
+
try {
|
|
402
|
+
return JSON.parse(String(raw));
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
async close() {
|
|
409
|
+
client.close();
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch sync to the SaaS — the secondary gate returns 429 when blocked, and
|
|
3
|
+
* `GET /v1/policy` pulls the org's enforcement state the other way.
|
|
4
|
+
*
|
|
5
|
+
* ## A failed batch must never look like a delivered one
|
|
6
|
+
*
|
|
7
|
+
* The sync watermark (`markSynced`) is what stops a row from being re-sent, so
|
|
8
|
+
* treating any non-429 response as success silently DESTROYS usage rows: a 500,
|
|
9
|
+
* a 502 from the proxy, or a stray HTML error page would retire the rows
|
|
10
|
+
* without the SaaS ever storing them. {@link SyncResult.delivered} therefore
|
|
11
|
+
* states explicitly whether the events reached the SaaS, and the caller only
|
|
12
|
+
* advances the watermark when it did.
|
|
13
|
+
*/
|
|
14
|
+
import type { RemotePolicy } from "./store.js";
|
|
15
|
+
import type { BatchEvent } from "./wire.js";
|
|
16
|
+
/** A fuse-cut report (proposal §3) — rides the same batch as usage rows. */
|
|
17
|
+
export type { CutEvent } from "./wire.js";
|
|
18
|
+
export interface SyncResult {
|
|
19
|
+
accepted: number;
|
|
20
|
+
status: number;
|
|
21
|
+
blocked: boolean;
|
|
22
|
+
blockedRule?: string;
|
|
23
|
+
resetAt?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Which budget the 429 came from. An `org` block stops every call on this
|
|
26
|
+
* machine; a `project` block stops only the referenced project; a `dev`
|
|
27
|
+
* block only the referenced dev. Absent = treat as org-wide (legacy).
|
|
28
|
+
*/
|
|
29
|
+
blockScope?: "org" | "project" | "dev";
|
|
30
|
+
blockReference?: string;
|
|
31
|
+
/**
|
|
32
|
+
* True only when the SaaS acknowledged the batch (2xx with `ok`). A blocked
|
|
33
|
+
* (429) or failed batch is NOT delivered — the caller keeps the rows.
|
|
34
|
+
*/
|
|
35
|
+
delivered: boolean;
|
|
36
|
+
/** Transport/HTTP failure detail for the log line, when any. */
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Shared request shape for the two key-authed endpoints. */
|
|
40
|
+
export interface OrgKeyTarget {
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
orgKey: string;
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
export declare function syncBatch(input: OrgKeyTarget & {
|
|
46
|
+
events: BatchEvent[];
|
|
47
|
+
}): Promise<SyncResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Pull the org's enforcement state (`GET /v1/policy`, key-authed). This is
|
|
50
|
+
* what makes the panel the control plane for the PRIMARY gate: without it the
|
|
51
|
+
* local fuse can only enforce whatever the deployment wrote into
|
|
52
|
+
* `cordis.yml`, and a budget edited in the panel would reach nothing but the
|
|
53
|
+
* secondary 429.
|
|
54
|
+
*
|
|
55
|
+
* Returns `null` when the SaaS has nothing published, or when the request
|
|
56
|
+
* failed — the caller keeps the last cached policy either way (offline-first:
|
|
57
|
+
* enforcement never depends on the network being up at boot).
|
|
58
|
+
*/
|
|
59
|
+
export declare function fetchPolicy(input: OrgKeyTarget): Promise<{
|
|
60
|
+
policy: RemotePolicy | null;
|
|
61
|
+
error?: string;
|
|
62
|
+
}>;
|
|
63
|
+
/**
|
|
64
|
+
* Validate the published shape before it reaches the fuse: a malformed policy
|
|
65
|
+
* must degrade to "no published policy", never to a half-applied rule set
|
|
66
|
+
* (an `allowedModels` that arrived as a string would otherwise deny every
|
|
67
|
+
* model, and a `limitUsd` that arrived as a string would compare as NaN and
|
|
68
|
+
* never cut).
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseRemotePolicy(json: unknown): RemotePolicy | null;
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch sync to the SaaS — the secondary gate returns 429 when blocked, and
|
|
3
|
+
* `GET /v1/policy` pulls the org's enforcement state the other way.
|
|
4
|
+
*
|
|
5
|
+
* ## A failed batch must never look like a delivered one
|
|
6
|
+
*
|
|
7
|
+
* The sync watermark (`markSynced`) is what stops a row from being re-sent, so
|
|
8
|
+
* treating any non-429 response as success silently DESTROYS usage rows: a 500,
|
|
9
|
+
* a 502 from the proxy, or a stray HTML error page would retire the rows
|
|
10
|
+
* without the SaaS ever storing them. {@link SyncResult.delivered} therefore
|
|
11
|
+
* states explicitly whether the events reached the SaaS, and the caller only
|
|
12
|
+
* advances the watermark when it did.
|
|
13
|
+
*/
|
|
14
|
+
function keyHeaders(orgKey) {
|
|
15
|
+
return { "x-org-key": orgKey };
|
|
16
|
+
}
|
|
17
|
+
export async function syncBatch(input) {
|
|
18
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetchImpl(`${input.baseUrl}/v1/usage/batch`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"content-type": "application/json",
|
|
25
|
+
...keyHeaders(input.orgKey),
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ events: input.events }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return {
|
|
32
|
+
accepted: 0,
|
|
33
|
+
status: 0,
|
|
34
|
+
blocked: false,
|
|
35
|
+
delivered: false,
|
|
36
|
+
error: String(err).slice(0, 200),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const body = (await res.json().catch(() => ({})));
|
|
40
|
+
if (res.status === 429) {
|
|
41
|
+
const scope = body.scope === "org" || body.scope === "project" || body.scope === "dev"
|
|
42
|
+
? body.scope
|
|
43
|
+
: undefined;
|
|
44
|
+
return {
|
|
45
|
+
accepted: 0,
|
|
46
|
+
status: 429,
|
|
47
|
+
blocked: true,
|
|
48
|
+
blockedRule: body.rule,
|
|
49
|
+
resetAt: body.reset_at,
|
|
50
|
+
...(scope ? { blockScope: scope } : {}),
|
|
51
|
+
...(typeof body.reference === "string" && body.reference
|
|
52
|
+
? { blockReference: body.reference }
|
|
53
|
+
: {}),
|
|
54
|
+
delivered: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
return {
|
|
59
|
+
accepted: 0,
|
|
60
|
+
status: res.status,
|
|
61
|
+
blocked: false,
|
|
62
|
+
delivered: false,
|
|
63
|
+
error: body.error ?? `status ${res.status}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
accepted: body.accepted ?? input.events.length,
|
|
68
|
+
status: res.status,
|
|
69
|
+
blocked: false,
|
|
70
|
+
delivered: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Pull the org's enforcement state (`GET /v1/policy`, key-authed). This is
|
|
75
|
+
* what makes the panel the control plane for the PRIMARY gate: without it the
|
|
76
|
+
* local fuse can only enforce whatever the deployment wrote into
|
|
77
|
+
* `cordis.yml`, and a budget edited in the panel would reach nothing but the
|
|
78
|
+
* secondary 429.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` when the SaaS has nothing published, or when the request
|
|
81
|
+
* failed — the caller keeps the last cached policy either way (offline-first:
|
|
82
|
+
* enforcement never depends on the network being up at boot).
|
|
83
|
+
*/
|
|
84
|
+
export async function fetchPolicy(input) {
|
|
85
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetchImpl(`${input.baseUrl}/v1/policy`, {
|
|
88
|
+
method: "GET",
|
|
89
|
+
headers: keyHeaders(input.orgKey),
|
|
90
|
+
});
|
|
91
|
+
if (res.status === 401)
|
|
92
|
+
return { policy: null, error: "unauthorized" };
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
return { policy: null, error: `status ${res.status}` };
|
|
95
|
+
return { policy: parseRemotePolicy(await res.json()) };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { policy: null, error: String(err).slice(0, 200) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate the published shape before it reaches the fuse: a malformed policy
|
|
103
|
+
* must degrade to "no published policy", never to a half-applied rule set
|
|
104
|
+
* (an `allowedModels` that arrived as a string would otherwise deny every
|
|
105
|
+
* model, and a `limitUsd` that arrived as a string would compare as NaN and
|
|
106
|
+
* never cut).
|
|
107
|
+
*/
|
|
108
|
+
export function parseRemotePolicy(json) {
|
|
109
|
+
if (typeof json !== "object" || json === null)
|
|
110
|
+
return null;
|
|
111
|
+
const raw = json;
|
|
112
|
+
const policy = {};
|
|
113
|
+
if (Array.isArray(raw.budgets)) {
|
|
114
|
+
const budgets = [];
|
|
115
|
+
for (const entry of raw.budgets) {
|
|
116
|
+
if (typeof entry !== "object" || entry === null)
|
|
117
|
+
continue;
|
|
118
|
+
const { limitUsd, window, scope, reference } = entry;
|
|
119
|
+
const limit = Number(limitUsd);
|
|
120
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
121
|
+
continue;
|
|
122
|
+
if (window !== "month" && window !== "day")
|
|
123
|
+
continue;
|
|
124
|
+
const parsedScope = scope === "org" || scope === "project" || scope === "dev"
|
|
125
|
+
? scope
|
|
126
|
+
: undefined;
|
|
127
|
+
budgets.push({
|
|
128
|
+
limitUsd: limit,
|
|
129
|
+
window,
|
|
130
|
+
...(parsedScope ? { scope: parsedScope } : {}),
|
|
131
|
+
...(typeof reference === "string" && reference ? { reference } : {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (budgets.length > 0)
|
|
135
|
+
policy.budgets = budgets;
|
|
136
|
+
}
|
|
137
|
+
if (typeof raw.maxReasoningEffort === "string" && raw.maxReasoningEffort) {
|
|
138
|
+
policy.maxReasoningEffort = raw.maxReasoningEffort;
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(raw.allowedModels)) {
|
|
141
|
+
const models = raw.allowedModels.filter((model) => typeof model === "string" && model.length > 0);
|
|
142
|
+
if (models.length > 0)
|
|
143
|
+
policy.allowedModels = models;
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(raw.denylistedProjects)) {
|
|
146
|
+
const projects = raw.denylistedProjects.filter((project) => typeof project === "string" && project.length > 0);
|
|
147
|
+
if (projects.length > 0)
|
|
148
|
+
policy.denylistedProjects = projects;
|
|
149
|
+
}
|
|
150
|
+
if (typeof raw.updatedAt === "string")
|
|
151
|
+
policy.updatedAt = raw.updatedAt;
|
|
152
|
+
return policy;
|
|
153
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's outbound wire contract.
|
|
3
|
+
*
|
|
4
|
+
* These types are the plugin's OWN because the plugin is distributed
|
|
5
|
+
* standalone (`dsh plugin add`, npm/tarball) and therefore cannot depend on a
|
|
6
|
+
* workspace package that is never published. The SaaS declares the same pair
|
|
7
|
+
* in `apps/dsh/shared` for its own routes; the two are the same wire format,
|
|
8
|
+
* so a change here is a change there — the api's ingest route is the consumer
|
|
9
|
+
* and validates every field it reads.
|
|
10
|
+
*
|
|
11
|
+
* Only metrics travel: model, provider, tokens, cost, timing and the HASHED
|
|
12
|
+
* session identity. No prompt, message, tool argument or result ever crosses
|
|
13
|
+
* this boundary.
|
|
14
|
+
*/
|
|
15
|
+
/** One model call, projected from the harness session log (metrics only). */
|
|
16
|
+
export interface UsageEvent {
|
|
17
|
+
v: 1;
|
|
18
|
+
/**
|
|
19
|
+
* Client-generated stable id for this exact call (UUID). The SaaS inserts
|
|
20
|
+
* idempotently on (organization_id, event_id), so re-sends after a crash
|
|
21
|
+
* or a 429 can never double-count spend, tokens or budgets.
|
|
22
|
+
*/
|
|
23
|
+
event_id: string;
|
|
24
|
+
/**
|
|
25
|
+
* SHA-256 of the harness session id, computed on the client — the raw id
|
|
26
|
+
* never leaves the machine.
|
|
27
|
+
*/
|
|
28
|
+
session_id: string;
|
|
29
|
+
project: string;
|
|
30
|
+
dev: string;
|
|
31
|
+
provider: string;
|
|
32
|
+
model: string;
|
|
33
|
+
reasoning_effort?: string;
|
|
34
|
+
input_tokens: number;
|
|
35
|
+
output_tokens: number;
|
|
36
|
+
cache_read_tokens?: number;
|
|
37
|
+
cache_write_tokens?: number;
|
|
38
|
+
cost_usd: number;
|
|
39
|
+
/** True when no price resolved for this call — visible, never silent. */
|
|
40
|
+
unpriced?: boolean;
|
|
41
|
+
started_at: string;
|
|
42
|
+
duration_ms?: number;
|
|
43
|
+
tools?: string[];
|
|
44
|
+
}
|
|
45
|
+
/** A fuse-cut report — the local enforcement the panel surfaces (proposal §3). */
|
|
46
|
+
export interface CutEvent {
|
|
47
|
+
kind: "cut";
|
|
48
|
+
project: string;
|
|
49
|
+
rule: string;
|
|
50
|
+
}
|
|
51
|
+
/** Anything that rides a `POST /v1/usage/batch` payload. */
|
|
52
|
+
export type BatchEvent = UsageEvent | CutEvent;
|