decision-gate 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 +202 -0
- package/NOTICE +11 -0
- package/README.md +321 -0
- package/lib/budget.mjs +71 -0
- package/lib/cache.mjs +95 -0
- package/lib/config.mjs +113 -0
- package/lib/errors.mjs +27 -0
- package/lib/index.mjs +115 -0
- package/lib/ledger.mjs +73 -0
- package/lib/limits.mjs +101 -0
- package/lib/providers/index.mjs +8 -0
- package/lib/providers/typesafe.mjs +189 -0
- package/lib/providers/vercel-ai-gateway.mjs +20 -0
- package/lib/redaction.mjs +355 -0
- package/lib/state.mjs +146 -0
- package/package.json +45 -0
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync, statSync, appendFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
+
import { StateError } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
export const clock = { now: Date.now, sleep: (ms, signal) => delay(Math.min(ms, 2147483647), undefined, { signal }) };
|
|
9
|
+
|
|
10
|
+
// Holders never keep a lock across network or sleep, so a lock this old belongs to a stopped
|
|
11
|
+
// process or a reused PID, never to a slow live one
|
|
12
|
+
const STALE_MS = 30000;
|
|
13
|
+
|
|
14
|
+
export function privateDir(dir) {
|
|
15
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
+
const stat = statSync(dir);
|
|
17
|
+
if (!stat.isDirectory() || (process.getuid && stat.uid !== process.getuid())) throw new StateError("local state directory must be owned by this user");
|
|
18
|
+
if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readJson(file, fallback) {
|
|
22
|
+
try { return JSON.parse(readFileSync(file, "utf8")); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code === "ENOENT") return fallback;
|
|
25
|
+
throw new StateError("cannot read local state; refusing to spend");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Replaces a file atomically, so readers see the old or the new contents and never a torn write
|
|
30
|
+
export function replaceFile(file, text) {
|
|
31
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
32
|
+
let fd;
|
|
33
|
+
try {
|
|
34
|
+
fd = openSync(temp, "wx", 0o600);
|
|
35
|
+
writeFileSync(fd, text);
|
|
36
|
+
fsyncSync(fd);
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
fd = undefined;
|
|
39
|
+
renameSync(temp, file);
|
|
40
|
+
} finally {
|
|
41
|
+
if (fd !== undefined) closeSync(fd);
|
|
42
|
+
rmSync(temp, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const writeJson = (file, value) => replaceFile(file, JSON.stringify(value));
|
|
47
|
+
|
|
48
|
+
export function appendRecords(file, values) {
|
|
49
|
+
// No-follow keeps a planted symlink from redirecting private records elsewhere
|
|
50
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
51
|
+
try { appendFileSync(fd, values.map((value) => `${JSON.stringify(value)}\n`).join("")); fsyncSync(fd); }
|
|
52
|
+
finally { closeSync(fd); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const appendRecord = (file, value) => appendRecords(file, [value]);
|
|
56
|
+
|
|
57
|
+
const held = new Set();
|
|
58
|
+
|
|
59
|
+
// Only an owner on this host can be proved dead; another host's pid means nothing here. `live`
|
|
60
|
+
// holds the tokens this process still owns
|
|
61
|
+
export function dead({ host, pid, token }, live = held) {
|
|
62
|
+
if (host !== os.hostname()) return false;
|
|
63
|
+
// A container restart can hand a killed owner's pid to this process
|
|
64
|
+
if (pid === process.pid) return !live.has(token);
|
|
65
|
+
try { process.kill(pid, 0); return false; }
|
|
66
|
+
catch (error) { return error.code === "ESRCH"; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ownerOf(lock) {
|
|
70
|
+
try {
|
|
71
|
+
const owner = JSON.parse(readFileSync(path.join(lock, "owner.json"), "utf8"));
|
|
72
|
+
return Number.isInteger(owner?.pid) && typeof owner?.host === "string" && /^[0-9a-f-]{36}$/.test(owner?.token) ? owner : null;
|
|
73
|
+
} catch { return null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tryAcquire(lock, token) {
|
|
77
|
+
const temp = `${lock}.${token}.tmp`;
|
|
78
|
+
mkdirSync(temp, { mode: 0o700 });
|
|
79
|
+
try {
|
|
80
|
+
writeJson(path.join(temp, "owner.json"), { host: os.hostname(), pid: process.pid, token });
|
|
81
|
+
// Rename publishes the lock with its owner already inside, so a crash never leaves an ownerless lock
|
|
82
|
+
renameSync(temp, lock);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code === "EEXIST" || error.code === "ENOTEMPTY") return false;
|
|
86
|
+
throw error;
|
|
87
|
+
} finally { rmSync(temp, { recursive: true, force: true }); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sweep(lock) {
|
|
91
|
+
const prefix = `${path.basename(lock)}.`;
|
|
92
|
+
for (const name of readdirSync(path.dirname(lock))) {
|
|
93
|
+
if (!name.startsWith(prefix) || !/\.(?:tmp|stale|done)$/.test(name)) continue;
|
|
94
|
+
const full = path.join(path.dirname(lock), name);
|
|
95
|
+
try { if (Date.now() - statSync(full).mtimeMs > STALE_MS) rmSync(full, { recursive: true, force: true }); }
|
|
96
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function reapIfStale(lock) {
|
|
101
|
+
let stat;
|
|
102
|
+
try { stat = statSync(lock); }
|
|
103
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
104
|
+
const owner = ownerOf(lock);
|
|
105
|
+
if (!(Date.now() - stat.mtimeMs > STALE_MS || (owner && dead(owner)))) return;
|
|
106
|
+
// The tombstone is named after the stale owner and kept for a while, so a second reaper that
|
|
107
|
+
// read the same owner fails to move a newer lock onto it instead of stealing that lock
|
|
108
|
+
const tomb = `${lock}.${owner?.token ?? Math.floor(stat.mtimeMs)}.stale`;
|
|
109
|
+
try {
|
|
110
|
+
renameSync(lock, tomb);
|
|
111
|
+
writeFileSync(path.join(tomb, "reaped"), "");
|
|
112
|
+
} catch (error) { if (!["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) throw error; }
|
|
113
|
+
sweep(lock);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function release(lock, token) {
|
|
117
|
+
if (ownerOf(lock)?.token !== token) return;
|
|
118
|
+
// Moving the lock away first means no other process ever sees it half-deleted
|
|
119
|
+
const done = `${lock}.${token}.done`;
|
|
120
|
+
try { renameSync(lock, done); }
|
|
121
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
122
|
+
rmSync(done, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Callbacks are synchronous: no network or sleeps while holding a filesystem lock
|
|
126
|
+
export async function locked(file, job, { signal, time = clock, timeoutMs = 10000 } = {}) {
|
|
127
|
+
privateDir(path.dirname(file));
|
|
128
|
+
const lock = `${file}.lock`;
|
|
129
|
+
const token = randomUUID();
|
|
130
|
+
const start = time.now();
|
|
131
|
+
for (;;) {
|
|
132
|
+
signal?.throwIfAborted();
|
|
133
|
+
if (tryAcquire(lock, token)) { held.add(token); break; }
|
|
134
|
+
reapIfStale(lock);
|
|
135
|
+
if (time.now() - start >= timeoutMs) throw new StateError("local state lock timed out; no request was sent");
|
|
136
|
+
await time.sleep(20, signal);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = job();
|
|
140
|
+
if (result?.then) throw new StateError("state transaction must be synchronous");
|
|
141
|
+
return result;
|
|
142
|
+
} finally {
|
|
143
|
+
held.delete(token);
|
|
144
|
+
release(lock, token);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decision-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rate and spend limits for calling TypeSafe Jev from Node: an account-wide 429 pause with backoff, a shared rate limiter, a per-key daily spend ceiling, an answer cache that stores no text, and never-send checks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib/",
|
|
8
|
+
"NOTICE"
|
|
9
|
+
],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./lib/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"lint": "for f in $(find lib test -name '*.mjs'); do node --check \"$f\" || exit 1; done"
|
|
19
|
+
},
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/zachlandes/decision-gate.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/zachlandes/decision-gate/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/zachlandes/decision-gate#readme",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"jev",
|
|
31
|
+
"typesafe",
|
|
32
|
+
"decision-model",
|
|
33
|
+
"spend-limit",
|
|
34
|
+
"rate-limit",
|
|
35
|
+
"rate-limiter",
|
|
36
|
+
"429",
|
|
37
|
+
"retry",
|
|
38
|
+
"backoff",
|
|
39
|
+
"cache",
|
|
40
|
+
"budget"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@typesafe-ai/sdk": "0.6.0"
|
|
44
|
+
}
|
|
45
|
+
}
|