agent-tool-forge 0.4.3 → 0.4.5
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/lib/drift-background.js +69 -7
- package/lib/forge-service.js +48 -1
- package/lib/postgres-store.d.ts +257 -12
- package/lib/postgres-store.js +30 -7
- package/lib/rate-limiter.d.ts +21 -4
- package/lib/rate-limiter.js +67 -3
- package/lib/sidecar.d.ts +6 -0
- package/lib/sidecar.js +5 -2
- package/package.json +1 -1
package/lib/drift-background.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background Drift Monitor — periodically checks all promoted tools for drift.
|
|
3
3
|
*
|
|
4
|
-
* Reuses checkDrift() and computeSuspects() from
|
|
5
|
-
*
|
|
4
|
+
* Reuses checkDrift() and computeSuspects() from drift-monitor.js for the
|
|
5
|
+
* SQLite path. When Postgres is configured, uses an async Postgres path that
|
|
6
|
+
* reads from PostgresEvalStore and PostgresStore instead.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { getAllToolRegistry, insertDriftAlert } from './db.js';
|
|
@@ -14,16 +15,65 @@ const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
14
15
|
* Create a background drift monitor.
|
|
15
16
|
*
|
|
16
17
|
* @param {object} config — forge config (drift.threshold, drift.windowSize)
|
|
17
|
-
* @param {import('better-sqlite3').Database} db
|
|
18
|
+
* @param {import('better-sqlite3').Database} db — SQLite db (used when pgCtx is null)
|
|
18
19
|
* @param {number} [intervalMs] — check interval (default 5 min)
|
|
19
|
-
* @
|
|
20
|
+
* @param {{ pgStore: object, evalStore: object, _pgPool: object } | null} [pgCtx]
|
|
21
|
+
* — Postgres context; when provided, the Postgres async path is used instead of SQLite.
|
|
22
|
+
* @returns {{ start(): void, stop(): void, runOnce(): Promise<void> }}
|
|
20
23
|
*/
|
|
21
|
-
export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS) {
|
|
24
|
+
export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS, pgCtx = null) {
|
|
22
25
|
let timer = null;
|
|
23
26
|
const threshold = config.drift?.threshold ?? 0.1;
|
|
24
27
|
const windowSize = config.drift?.windowSize ?? 5;
|
|
25
28
|
|
|
26
|
-
function
|
|
29
|
+
async function runOncePg({ pgStore, evalStore, _pgPool }) {
|
|
30
|
+
const allTools = await pgStore.getAllToolRegistry();
|
|
31
|
+
const promoted = allTools.filter(r => r.lifecycle_state === 'promoted');
|
|
32
|
+
|
|
33
|
+
for (const tool of promoted) {
|
|
34
|
+
const baseline = tool.baseline_pass_rate;
|
|
35
|
+
if (baseline == null) continue;
|
|
36
|
+
|
|
37
|
+
const history = await evalStore.getPerToolRunHistory(tool.tool_name, windowSize);
|
|
38
|
+
if (!history.length) continue;
|
|
39
|
+
|
|
40
|
+
const avg = history.reduce((s, r) => s + (r.pass_rate || 0), 0) / history.length;
|
|
41
|
+
const delta = baseline - avg;
|
|
42
|
+
if (delta < threshold) continue;
|
|
43
|
+
|
|
44
|
+
// Skip if an open alert already exists
|
|
45
|
+
const { rows: [existing] } = await _pgPool.query(
|
|
46
|
+
`SELECT id FROM drift_alerts WHERE tool_name = $1 AND status = 'open' LIMIT 1`,
|
|
47
|
+
[tool.tool_name]
|
|
48
|
+
);
|
|
49
|
+
if (existing) continue;
|
|
50
|
+
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const client = await _pgPool.connect();
|
|
53
|
+
try {
|
|
54
|
+
await client.query('BEGIN');
|
|
55
|
+
await client.query(
|
|
56
|
+
`INSERT INTO drift_alerts
|
|
57
|
+
(tool_name, detected_at, trigger_tools, baseline_rate, current_rate, delta, status)
|
|
58
|
+
VALUES ($1,$2,$3,$4,$5,$6,'open')`,
|
|
59
|
+
[tool.tool_name, now, '[]', baseline, avg, delta]
|
|
60
|
+
);
|
|
61
|
+
await client.query(
|
|
62
|
+
`UPDATE tool_registry SET lifecycle_state = 'flagged', flagged_at = $1
|
|
63
|
+
WHERE tool_name = $2 AND lifecycle_state != 'flagged'`,
|
|
64
|
+
[now, tool.tool_name]
|
|
65
|
+
);
|
|
66
|
+
await client.query('COMMIT');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
await client.query('ROLLBACK');
|
|
69
|
+
process.stderr.write(`[drift-monitor] Postgres alert insert failed for ${tool.tool_name}: ${e.message}\n`);
|
|
70
|
+
} finally {
|
|
71
|
+
client.release();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runOnceSqlite() {
|
|
27
77
|
try {
|
|
28
78
|
const tools = getAllToolRegistry(db).filter(r => r.lifecycle_state === 'promoted');
|
|
29
79
|
for (const tool of tools) {
|
|
@@ -44,10 +94,22 @@ export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS)
|
|
|
44
94
|
}
|
|
45
95
|
}
|
|
46
96
|
|
|
97
|
+
async function runOnce() {
|
|
98
|
+
if (pgCtx) {
|
|
99
|
+
try {
|
|
100
|
+
await runOncePg(pgCtx);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
process.stderr.write(`[drift-monitor] Postgres error during check: ${err.message}\n`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
runOnceSqlite();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
47
109
|
return {
|
|
48
110
|
start() {
|
|
49
111
|
if (timer) return;
|
|
50
|
-
timer = setInterval(runOnce, intervalMs);
|
|
112
|
+
timer = setInterval(() => { runOnce().catch(() => {}); }, intervalMs);
|
|
51
113
|
timer.unref(); // Don't block process exit
|
|
52
114
|
},
|
|
53
115
|
stop() {
|
package/lib/forge-service.js
CHANGED
|
@@ -135,7 +135,7 @@ export async function buildSidecarContext(config, db, env = {}, opts = {}) {
|
|
|
135
135
|
const conversationStore = makeConversationStore(config, db, pgPool);
|
|
136
136
|
const hitlEngine = makeHitlEngine(config, db, redisClient, pgPool);
|
|
137
137
|
const verifierRunner = new VerifierRunner(db, config, pgPool ?? null);
|
|
138
|
-
const rateLimiter = makeRateLimiter(config, redisClient);
|
|
138
|
+
const rateLimiter = makeRateLimiter(config, redisClient, pgPool);
|
|
139
139
|
|
|
140
140
|
// configPath — used by admin handler to persist overlay changes.
|
|
141
141
|
// Defaults to process.cwd() so library consumers write config to their own
|
|
@@ -264,6 +264,53 @@ export function createSidecarRouter(ctx, options = {}) {
|
|
|
264
264
|
return handleAdminConfig(req, res, ctx);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// ── Eval endpoints ─────────────────────────────────────────────────────
|
|
268
|
+
if (sidecarPath === '/agent-api/evals/summary' && req.method === 'GET') {
|
|
269
|
+
if (ctx.evalStore) {
|
|
270
|
+
try {
|
|
271
|
+
sendJson(res, 200, await ctx.evalStore.getEvalSummary());
|
|
272
|
+
} catch (err) {
|
|
273
|
+
sendJson(res, 500, { error: 'Failed to fetch eval summary' });
|
|
274
|
+
}
|
|
275
|
+
} else if (ctx.db) {
|
|
276
|
+
try {
|
|
277
|
+
const rows = ctx.db.prepare(
|
|
278
|
+
`SELECT tool_name, MAX(run_at) AS last_run,
|
|
279
|
+
SUM(total_cases) AS total_cases, SUM(passed) AS passed,
|
|
280
|
+
SUM(failed) AS failed,
|
|
281
|
+
ROUND(CAST(SUM(passed) AS REAL) / NULLIF(SUM(passed)+SUM(failed),0) * 100, 1) AS pass_rate
|
|
282
|
+
FROM eval_runs GROUP BY tool_name ORDER BY tool_name`
|
|
283
|
+
).all();
|
|
284
|
+
sendJson(res, 200, rows);
|
|
285
|
+
} catch { sendJson(res, 500, { error: 'Failed to fetch eval summary' }); }
|
|
286
|
+
} else {
|
|
287
|
+
sendJson(res, 200, []);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sidecarPath === '/agent-api/evals/runs' && req.method === 'GET') {
|
|
293
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
294
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
295
|
+
if (ctx.evalStore) {
|
|
296
|
+
try {
|
|
297
|
+
sendJson(res, 200, await ctx.evalStore.listRuns(limit, offset));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
sendJson(res, 500, { error: 'Failed to fetch eval runs' });
|
|
300
|
+
}
|
|
301
|
+
} else if (ctx.db) {
|
|
302
|
+
try {
|
|
303
|
+
const rows = ctx.db.prepare(
|
|
304
|
+
'SELECT * FROM eval_runs ORDER BY run_at DESC LIMIT ? OFFSET ?'
|
|
305
|
+
).all(limit, offset);
|
|
306
|
+
sendJson(res, 200, rows);
|
|
307
|
+
} catch { sendJson(res, 500, { error: 'Failed to fetch eval runs' }); }
|
|
308
|
+
} else {
|
|
309
|
+
sendJson(res, 200, []);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
267
314
|
// ── Widget static file serving ─────────────────────────────────────────
|
|
268
315
|
if (url.pathname.startsWith('/widget/')) {
|
|
269
316
|
serveWidgetFile(req, res, widgetDir, sendJson);
|
package/lib/postgres-store.d.ts
CHANGED
|
@@ -1,31 +1,276 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Postgres-backed storage
|
|
2
|
+
* Postgres-backed storage adapters for horizontal scaling (0.4.x).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This module exports seven classes that mirror the SQLite-backed store
|
|
5
|
+
* interfaces from db.js, prompt-store.js, preference-store.js,
|
|
6
|
+
* agent-registry.js, and the eval/audit/verifier sub-systems. All classes
|
|
7
|
+
* accept an existing `pg.Pool` (or create one internally) so they can share
|
|
8
|
+
* a single connection pool in sidecar deployments.
|
|
6
9
|
*
|
|
7
10
|
* Requires: `npm install pg`
|
|
11
|
+
* Optional — only loaded when `database.type === 'postgres'` (or
|
|
12
|
+
* `conversation.store === 'postgres'`) in forge config.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── PostgresStore ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base store that owns the pg.Pool lifecycle and hosts the tool-registry
|
|
19
|
+
* methods. Call `connect()` before using any other method.
|
|
8
20
|
*/
|
|
9
21
|
export class PostgresStore {
|
|
10
22
|
constructor(pgConfig: { connectionString: string });
|
|
11
23
|
|
|
12
24
|
/**
|
|
13
|
-
* Connect to Postgres
|
|
25
|
+
* Connect to Postgres, run schema migrations, and return `this`.
|
|
14
26
|
* Must be called before any other method.
|
|
15
27
|
*/
|
|
16
28
|
connect(): Promise<this>;
|
|
17
29
|
|
|
18
|
-
/**
|
|
30
|
+
/** Drain and close the connection pool. */
|
|
19
31
|
close(): Promise<void>;
|
|
20
32
|
|
|
21
|
-
// ──
|
|
33
|
+
// ── Tool registry ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Return all tool_registry rows where lifecycle_state = 'promoted'. */
|
|
36
|
+
getPromotedTools(): Promise<object[]>;
|
|
37
|
+
|
|
38
|
+
/** Insert or update a tool_registry row. */
|
|
39
|
+
upsertToolRegistry(row: object): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/** Return the tool_registry row for `toolName`, or null if not found. */
|
|
42
|
+
getToolRegistry(toolName: string): Promise<object | null>;
|
|
43
|
+
|
|
44
|
+
/** Return all tool_registry rows. */
|
|
45
|
+
getAllToolRegistry(): Promise<object[]>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Apply lifecycle column updates (lifecycle_state, promoted_at,
|
|
49
|
+
* flagged_at, retired_at, replaced_by, baseline_pass_rate) to a single
|
|
50
|
+
* tool row. Unknown keys in `updates` are silently ignored.
|
|
51
|
+
*/
|
|
52
|
+
updateToolLifecycle(toolName: string, updates: Record<string, unknown>): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── PostgresPromptStore ────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Postgres-backed PromptStore — same interface as PromptStore in
|
|
59
|
+
* prompt-store.js. Accepts an existing pg.Pool created by
|
|
60
|
+
* buildSidecarContext.
|
|
61
|
+
*/
|
|
62
|
+
export class PostgresPromptStore {
|
|
63
|
+
constructor(pool: object);
|
|
64
|
+
|
|
65
|
+
/** Return the content of the currently active prompt, or '' if none. */
|
|
66
|
+
getActivePrompt(): Promise<string>;
|
|
67
|
+
|
|
68
|
+
/** Return all prompt_versions rows ordered by id DESC. */
|
|
69
|
+
getAllVersions(): Promise<object[]>;
|
|
70
|
+
|
|
71
|
+
/** Return a single prompt_versions row by id, or null if not found. */
|
|
72
|
+
getVersion(id: number): Promise<object | null>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Insert a new prompt version (inactive) and return its generated id,
|
|
76
|
+
* or null on failure.
|
|
77
|
+
*/
|
|
78
|
+
createVersion(version: string, content: string, notes?: string | null): Promise<number | null>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deactivate all other versions and activate the row with the given id.
|
|
82
|
+
* Runs inside a transaction.
|
|
83
|
+
*/
|
|
84
|
+
activate(id: number | string): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── PostgresPreferenceStore ────────────────────────────────────────────────
|
|
22
88
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Postgres-backed PreferenceStore — same interface as PreferenceStore in
|
|
91
|
+
* preference-store.js.
|
|
92
|
+
*/
|
|
93
|
+
export class PostgresPreferenceStore {
|
|
94
|
+
constructor(pool: object, config?: object, env?: Record<string, string>);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Return the stored preferences for a user as `{ model, hitlLevel }`,
|
|
98
|
+
* or null if the user has no row.
|
|
99
|
+
*/
|
|
100
|
+
getUserPreferences(userId: string): Promise<object | null>;
|
|
101
|
+
|
|
102
|
+
/** Insert or update the user_preferences row for `userId`. */
|
|
103
|
+
setUserPreferences(userId: string, prefs: object): Promise<void>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the effective runtime settings (model, hitlLevel, provider,
|
|
107
|
+
* apiKey) for a user, merging config defaults with any stored preferences.
|
|
108
|
+
*/
|
|
109
|
+
resolveEffective(userId: string, config: object, env: object): Promise<object>;
|
|
110
|
+
}
|
|
26
111
|
|
|
27
|
-
|
|
112
|
+
// ── PostgresAgentRegistry ──────────────────────────────────────────────────
|
|
28
113
|
|
|
29
|
-
|
|
30
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Postgres-backed AgentRegistry — same interface as AgentRegistry in
|
|
116
|
+
* agent-registry.js.
|
|
117
|
+
*/
|
|
118
|
+
export class PostgresAgentRegistry {
|
|
119
|
+
constructor(config: object, pool: object);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Return the default agent when `agentId` is null/undefined, or look up
|
|
123
|
+
* by id. Returns null when the agent is disabled or not found.
|
|
124
|
+
*/
|
|
125
|
+
resolveAgent(agentId: string | null): Promise<object | null>;
|
|
126
|
+
|
|
127
|
+
/** Return the agent_registry row for `agentId`, or null. */
|
|
128
|
+
getAgent(agentId: string): Promise<object | null>;
|
|
129
|
+
|
|
130
|
+
/** Return all agent_registry rows ordered by display_name. */
|
|
131
|
+
getAllAgents(): Promise<object[]>;
|
|
132
|
+
|
|
133
|
+
/** Insert or update an agent_registry row. */
|
|
134
|
+
upsertAgent(agent: object): Promise<void>;
|
|
135
|
+
|
|
136
|
+
/** Delete the agent_registry row for `agentId`. */
|
|
137
|
+
deleteAgent(agentId: string): Promise<void>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clear is_default on all agents and set it on `agentId`.
|
|
141
|
+
* No-ops (with implicit rollback) if the target agent is disabled.
|
|
142
|
+
* Runs inside a transaction.
|
|
143
|
+
*/
|
|
144
|
+
setDefault(agentId: string): Promise<void>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge agent-level overrides (model, hitlLevel, tool policy, turn/token
|
|
148
|
+
* limits) on top of the base forge config and return the merged object.
|
|
149
|
+
*/
|
|
150
|
+
buildAgentConfig(config: object, agent: object | null): object;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the system prompt for a request: agent.system_prompt >
|
|
154
|
+
* promptStore active prompt > config.systemPrompt > fallback string.
|
|
155
|
+
*/
|
|
156
|
+
resolveSystemPrompt(agent: object | null, promptStore: object, config: object): Promise<string>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Upsert all agents declared in `config.agents` that are either new or
|
|
160
|
+
* were previously seeded from config. Sets the default agent when
|
|
161
|
+
* `isDefault` is provided.
|
|
162
|
+
*/
|
|
163
|
+
seedFromConfig(): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── PostgresEvalStore ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Postgres-backed store for eval run results and per-case records. */
|
|
169
|
+
export class PostgresEvalStore {
|
|
170
|
+
constructor(pool: object);
|
|
171
|
+
|
|
172
|
+
/** Insert an eval_runs header row and return its generated id, or null. */
|
|
173
|
+
insertEvalRun(row: object): Promise<number | null>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bulk-insert eval_run_cases rows inside a single transaction.
|
|
177
|
+
* No-ops when `rows` is empty.
|
|
178
|
+
*/
|
|
179
|
+
insertEvalRunCases(rows: object[]): Promise<void>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return an aggregated summary (last_run, total_cases, passed, failed,
|
|
183
|
+
* pass_rate) grouped by tool_name.
|
|
184
|
+
*/
|
|
185
|
+
getEvalSummary(): Promise<object[]>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return the most recent `windowSize` eval_runs rows for a single tool,
|
|
189
|
+
* ordered newest-first.
|
|
190
|
+
*/
|
|
191
|
+
getPerToolRunHistory(toolName: string, windowSize?: number): Promise<object[]>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Return a paginated list of eval_runs rows ordered by run_at DESC.
|
|
195
|
+
*/
|
|
196
|
+
listRuns(limit?: number, offset?: number): Promise<object[]>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── PostgresChatAuditStore ─────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Postgres-backed store for per-request chat audit records. */
|
|
202
|
+
export class PostgresChatAuditStore {
|
|
203
|
+
constructor(pool: object);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Insert a chat_audit row and return its generated id, or null on
|
|
207
|
+
* failure.
|
|
208
|
+
*/
|
|
209
|
+
insertChatAudit(row: object): Promise<number | null>;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Return aggregate statistics across all chat_audit rows:
|
|
213
|
+
* total sessions, average duration, error rate, and messages in the
|
|
214
|
+
* last 24 hours.
|
|
215
|
+
*/
|
|
216
|
+
getStats(): Promise<{
|
|
217
|
+
totalSessions: number;
|
|
218
|
+
avgDurationMs: number;
|
|
219
|
+
errorRate: number;
|
|
220
|
+
messagesToday: number;
|
|
221
|
+
}>;
|
|
222
|
+
|
|
223
|
+
/** Return a paginated list of chat_audit rows ordered by created_at DESC. */
|
|
224
|
+
getSessions(limit?: number, offset?: number): Promise<object[]>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── PostgresVerifierStore ──────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Postgres-backed store for verifier registry entries, tool bindings, and
|
|
231
|
+
* per-invocation results.
|
|
232
|
+
*/
|
|
233
|
+
export class PostgresVerifierStore {
|
|
234
|
+
constructor(pool: object);
|
|
235
|
+
|
|
236
|
+
/** Insert or update a verifier_registry row. */
|
|
237
|
+
upsertVerifier(row: object): Promise<void>;
|
|
238
|
+
|
|
239
|
+
/** Return the verifier_registry row for `name`, or null. */
|
|
240
|
+
getVerifier(name: string): Promise<object | null>;
|
|
241
|
+
|
|
242
|
+
/** Return all enabled verifier_registry rows ordered by aciru_order. */
|
|
243
|
+
getAllVerifiers(): Promise<object[]>;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Delete a verifier and all of its tool bindings inside a transaction.
|
|
247
|
+
*/
|
|
248
|
+
deleteVerifier(name: string): Promise<void>;
|
|
249
|
+
|
|
250
|
+
/** Insert or update a verifier_tool_bindings row. */
|
|
251
|
+
upsertVerifierBinding(binding: object): Promise<void>;
|
|
252
|
+
|
|
253
|
+
/** Delete the binding between a verifier and a specific tool. */
|
|
254
|
+
removeVerifierBinding(verifierName: string, toolName: string): Promise<void>;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Return all enabled verifier_registry rows that are bound to
|
|
258
|
+
* `toolName`, ordered by aciru_order.
|
|
259
|
+
*/
|
|
260
|
+
getVerifiersForTool(toolName: string): Promise<object[]>;
|
|
261
|
+
|
|
262
|
+
/** Return all verifier_tool_bindings rows for a given verifier. */
|
|
263
|
+
getBindingsForVerifier(verifierName: string): Promise<object[]>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Insert a verifier_results row and return its generated id, or null on
|
|
267
|
+
* failure.
|
|
268
|
+
*/
|
|
269
|
+
insertVerifierResult(row: object): Promise<number | null>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Return verifier_results rows ordered by created_at DESC. When
|
|
273
|
+
* `toolName` is provided, results are filtered to that tool.
|
|
274
|
+
*/
|
|
275
|
+
getResults(toolName?: string | null, limit?: number): Promise<object[]>;
|
|
31
276
|
}
|
package/lib/postgres-store.js
CHANGED
|
@@ -149,6 +149,29 @@ CREATE TABLE IF NOT EXISTS verifier_tool_bindings (
|
|
|
149
149
|
);
|
|
150
150
|
CREATE INDEX IF NOT EXISTS idx_vtb_tool_name
|
|
151
151
|
ON verifier_tool_bindings(tool_name, verifier_name);
|
|
152
|
+
|
|
153
|
+
-- drift_alerts (mirrors SQLite schema in db.js)
|
|
154
|
+
CREATE TABLE IF NOT EXISTS drift_alerts (
|
|
155
|
+
id SERIAL PRIMARY KEY,
|
|
156
|
+
tool_name TEXT NOT NULL,
|
|
157
|
+
detected_at TEXT NOT NULL,
|
|
158
|
+
trigger_tools TEXT,
|
|
159
|
+
baseline_rate REAL,
|
|
160
|
+
current_rate REAL,
|
|
161
|
+
delta REAL,
|
|
162
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
163
|
+
resolved_at TEXT
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_drift_alerts_tool_status
|
|
166
|
+
ON drift_alerts(tool_name, status);
|
|
167
|
+
|
|
168
|
+
-- rate_limit_buckets (for PostgresRateLimiter)
|
|
169
|
+
CREATE TABLE IF NOT EXISTS rate_limit_buckets (
|
|
170
|
+
key TEXT NOT NULL,
|
|
171
|
+
window_start BIGINT NOT NULL,
|
|
172
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
173
|
+
PRIMARY KEY (key, window_start)
|
|
174
|
+
);
|
|
152
175
|
`;
|
|
153
176
|
|
|
154
177
|
export class PostgresStore {
|
|
@@ -633,21 +656,21 @@ export class PostgresChatAuditStore {
|
|
|
633
656
|
async getStats() {
|
|
634
657
|
const { rows } = await this._pool.query(`
|
|
635
658
|
SELECT
|
|
636
|
-
COUNT(*) AS total_sessions,
|
|
659
|
+
COALESCE(COUNT(*), 0) AS total_sessions,
|
|
637
660
|
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
|
638
661
|
COALESCE(
|
|
639
662
|
COUNT(*) FILTER (WHERE status_code >= 400)::float / NULLIF(COUNT(*), 0),
|
|
640
663
|
0
|
|
641
664
|
) AS error_rate,
|
|
642
|
-
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') AS messages_today
|
|
665
|
+
COALESCE(COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours'), 0) AS messages_today
|
|
643
666
|
FROM chat_audit
|
|
644
667
|
`);
|
|
645
|
-
const row = rows[0];
|
|
668
|
+
const row = rows[0] ?? {};
|
|
646
669
|
return {
|
|
647
|
-
totalSessions: parseInt(row.total_sessions, 10),
|
|
648
|
-
avgDurationMs: Math.round(parseFloat(row.avg_duration_ms)),
|
|
649
|
-
errorRate: parseFloat(row.error_rate),
|
|
650
|
-
messagesToday: parseInt(row.messages_today, 10)
|
|
670
|
+
totalSessions: parseInt(row.total_sessions ?? '0', 10),
|
|
671
|
+
avgDurationMs: Math.round(parseFloat(row.avg_duration_ms ?? '0')),
|
|
672
|
+
errorRate: parseFloat(row.error_rate ?? '0'),
|
|
673
|
+
messagesToday: parseInt(row.messages_today ?? '0', 10)
|
|
651
674
|
};
|
|
652
675
|
}
|
|
653
676
|
|
package/lib/rate-limiter.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ export interface RateLimitResult {
|
|
|
7
7
|
/**
|
|
8
8
|
* Fixed-window per-user per-route rate limiter.
|
|
9
9
|
*
|
|
10
|
-
* Backend is selected automatically
|
|
11
|
-
* - Redis — if a Redis client is provided
|
|
10
|
+
* Backend is selected automatically by `makeRateLimiter`:
|
|
11
|
+
* - Redis — if a Redis client is provided
|
|
12
|
+
* - Postgres — if a pg.Pool is provided as the third argument
|
|
12
13
|
* - Memory — in-process Map fallback, resets on window boundary
|
|
13
14
|
*
|
|
14
15
|
* Only counts authenticated requests — limits by `userId`, not IP.
|
|
@@ -24,7 +25,23 @@ export class RateLimiter {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
+
* Postgres-backed rate limiter. Uses an atomic `INSERT … ON CONFLICT DO UPDATE`
|
|
29
|
+
* on the `rate_limit_buckets` table for horizontal-scale durability.
|
|
30
|
+
*/
|
|
31
|
+
export class PostgresRateLimiter {
|
|
32
|
+
constructor(config?: object, pgPool?: object | null);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check whether a request is within the rate limit and increment the counter.
|
|
36
|
+
* Always returns `{ allowed: true }` when rate limiting is disabled.
|
|
37
|
+
* Rejects if the database is unavailable.
|
|
38
|
+
*/
|
|
39
|
+
check(userId: string, route: string): Promise<RateLimitResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Factory — creates a rate limiter from forge config.
|
|
28
44
|
* Reads `config.rateLimit` for `enabled`, `windowMs`, and `maxRequests`.
|
|
45
|
+
* Priority: Redis > Postgres > in-memory.
|
|
29
46
|
*/
|
|
30
|
-
export function makeRateLimiter(config: object, redis?: object | null): RateLimiter;
|
|
47
|
+
export function makeRateLimiter(config: object, redis?: object | null, pgPool?: object | null): RateLimiter | PostgresRateLimiter;
|
package/lib/rate-limiter.js
CHANGED
|
@@ -97,14 +97,78 @@ export class RateLimiter {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Postgres-backed fixed-window rate limiter.
|
|
102
|
+
* Uses an atomic INSERT ... ON CONFLICT DO UPDATE to count requests.
|
|
103
|
+
* Requires the rate_limit_buckets table (created by postgres-store.js SCHEMA).
|
|
104
|
+
*
|
|
105
|
+
* @param {object} config — forge rateLimit config block
|
|
106
|
+
* @param {object} pgPool — pg.Pool instance
|
|
107
|
+
*/
|
|
108
|
+
export class PostgresRateLimiter {
|
|
109
|
+
constructor(config = {}, pgPool) {
|
|
110
|
+
this._enabled = config.enabled ?? false;
|
|
111
|
+
this._windowMs = config.windowMs ?? 60_000;
|
|
112
|
+
this._maxRequests = config.maxRequests ?? 60;
|
|
113
|
+
this._pgPool = pgPool;
|
|
114
|
+
// Cleanup stale windows every 5 minutes
|
|
115
|
+
if (this._enabled) {
|
|
116
|
+
this._cleanupTimer = setInterval(async () => {
|
|
117
|
+
const cutoff = Math.floor(Date.now() / this._windowMs) * this._windowMs - this._windowMs;
|
|
118
|
+
try {
|
|
119
|
+
await this._pgPool.query(
|
|
120
|
+
'DELETE FROM rate_limit_buckets WHERE window_start < $1', [cutoff]);
|
|
121
|
+
} catch { /* non-fatal */ }
|
|
122
|
+
}, 5 * 60 * 1000).unref();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async check(userId, route) {
|
|
127
|
+
if (!this._enabled) return { allowed: true };
|
|
128
|
+
if (!userId || !route) return { allowed: true };
|
|
129
|
+
|
|
130
|
+
const windowMs = this._windowMs;
|
|
131
|
+
const maxRequests = this._maxRequests;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
134
|
+
const key = `\x00${userId}\x00${route}`;
|
|
135
|
+
|
|
136
|
+
let rows;
|
|
137
|
+
try {
|
|
138
|
+
({ rows } = await this._pgPool.query(
|
|
139
|
+
`INSERT INTO rate_limit_buckets (key, window_start, count)
|
|
140
|
+
VALUES ($1, $2, 1)
|
|
141
|
+
ON CONFLICT (key, window_start) DO UPDATE
|
|
142
|
+
SET count = rate_limit_buckets.count + 1
|
|
143
|
+
RETURNING count`,
|
|
144
|
+
[key, windowStart]
|
|
145
|
+
));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('[forge-rate-limiter] pgPool.query failed:', err.message ?? err);
|
|
148
|
+
return { allowed: true }; // fail open on DB error
|
|
149
|
+
}
|
|
150
|
+
const count = rows[0]?.count ?? 1;
|
|
151
|
+
if (count > maxRequests) {
|
|
152
|
+
const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
|
|
153
|
+
return { allowed: false, retryAfter };
|
|
154
|
+
}
|
|
155
|
+
return { allowed: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
/**
|
|
101
160
|
* Factory — creates a RateLimiter from forge config.
|
|
102
161
|
* Auto-passes Redis client if available (set by buildSidecarContext).
|
|
103
162
|
*
|
|
104
163
|
* @param {object} config — merged forge config
|
|
105
164
|
* @param {object} [redis] — optional Redis client
|
|
106
|
-
* @
|
|
165
|
+
* @param {object} [pgPool] — optional pg.Pool instance
|
|
166
|
+
* @returns {RateLimiter|PostgresRateLimiter}
|
|
107
167
|
*/
|
|
108
|
-
export function makeRateLimiter(config, redis = null) {
|
|
109
|
-
|
|
168
|
+
export function makeRateLimiter(config, redis = null, pgPool = null) {
|
|
169
|
+
const rlConfig = config.rateLimit ?? {};
|
|
170
|
+
if (!redis && pgPool) {
|
|
171
|
+
return new PostgresRateLimiter(rlConfig, pgPool);
|
|
172
|
+
}
|
|
173
|
+
return new RateLimiter(rlConfig, redis);
|
|
110
174
|
}
|
package/lib/sidecar.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface SidecarOptions {
|
|
|
12
12
|
autoListen?: boolean;
|
|
13
13
|
enableDrift?: boolean;
|
|
14
14
|
widgetDir?: string;
|
|
15
|
+
customRoutes?: (req: object, res: object, ctx: SidecarContext) => Promise<boolean> | boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface SidecarContext {
|
|
@@ -27,6 +28,11 @@ export interface SidecarContext {
|
|
|
27
28
|
config: SidecarConfig;
|
|
28
29
|
env: Record<string, string>;
|
|
29
30
|
configPath?: string;
|
|
31
|
+
evalStore: object | null;
|
|
32
|
+
chatAuditStore: object | null;
|
|
33
|
+
verifierStore: object | null;
|
|
34
|
+
pgStore: object | null;
|
|
35
|
+
_pgPool: object | null;
|
|
30
36
|
[key: string]: unknown;
|
|
31
37
|
}
|
|
32
38
|
|
package/lib/sidecar.js
CHANGED
|
@@ -78,10 +78,13 @@ export async function createSidecar(config = {}, options = {}) {
|
|
|
78
78
|
// Create HTTP server
|
|
79
79
|
const server = createHttpServer(router);
|
|
80
80
|
|
|
81
|
-
// Optional drift monitor
|
|
81
|
+
// Optional drift monitor — passes Postgres context when available
|
|
82
82
|
let driftMonitor = null;
|
|
83
83
|
if (enableDrift) {
|
|
84
|
-
|
|
84
|
+
const pgCtx = (ctx._pgPool && ctx.pgStore && ctx.evalStore)
|
|
85
|
+
? { pgStore: ctx.pgStore, evalStore: ctx.evalStore, _pgPool: ctx._pgPool }
|
|
86
|
+
: null;
|
|
87
|
+
driftMonitor = createDriftMonitor(merged, db, undefined, pgCtx);
|
|
85
88
|
driftMonitor.start();
|
|
86
89
|
}
|
|
87
90
|
|