openbroker 1.2.0 → 1.3.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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to Open Broker will be documented in this file.
4
4
 
5
+ ## [1.3.0] - 2026-05-07
6
+
7
+ ### Added
8
+ - **`openbroker auto prune`**: New subcommand for trimming the local audit DB
9
+ - `--older-than <duration>` accepts `7d`, `24h`, `30m`, etc.
10
+ - `--status <list>` filters by run status (default: `stopped,error,stale`)
11
+ - `--keep-last <N>` retains the N most-recent runs per `automation_id`
12
+ - `--all` deletes everything except runs whose process is still alive
13
+ - `--vacuum` reclaims disk after deletion
14
+ - `--dry` previews matches without writing — also reconcile is dry-safe
15
+
16
+ ### Changed
17
+ - **`openbroker auto clean`**: Now also reconciles the audit DB. Runs whose pid is dead but whose row still says `status='running'` get marked `stopped` with `stop_reason='reconciled (process exited)'`, fixing dashboards that previously kept rendering them as live/stale long after `auto stop`.
18
+
5
19
  ## [1.0.59] - 2026-03-11
6
20
 
7
21
  ### Fixed
package/bin/openbroker.js CHANGED
@@ -25,6 +25,9 @@ const child = spawn(
25
25
  OPENBROKER_CWD: process.cwd(),
26
26
  // Suppress Node.js experimental warnings
27
27
  NODE_NO_WARNINGS: '1',
28
+ // node:sqlite is stable in Node 24+, experimental-with-flag in 22/23.
29
+ // The flag is accepted (as a no-op) on 24+, so it's safe to set unconditionally.
30
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --experimental-sqlite`.trim(),
28
31
  },
29
32
  }
30
33
  );
@@ -42,6 +45,7 @@ child.on('error', (err) => {
42
45
  ...process.env,
43
46
  OPENBROKER_CWD: process.cwd(),
44
47
  NODE_NO_WARNINGS: '1',
48
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --experimental-sqlite`.trim(),
45
49
  },
46
50
  }
47
51
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbroker",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,6 +49,7 @@
49
49
  "dependencies": {
50
50
  "@nktkas/hyperliquid": "^0.30.3",
51
51
  "dotenv": "^17.2.3",
52
+ "openbroker-monitoring": "file:../openbroker-monitoring",
52
53
  "tsx": "^4.19.0",
53
54
  "viem": "^2.21.0"
54
55
  },
@@ -23,7 +23,8 @@ Usage:
23
23
  openbroker auto stop <id> Unregister an automation (won't restart)
24
24
  openbroker auto list List available automations
25
25
  openbroker auto status Show running automations
26
- openbroker auto clean Remove stale entries from registry
26
+ openbroker auto clean Remove stale entries from registry + reconcile audit DB
27
+ openbroker auto prune [options] Delete stale runs from the audit DB
27
28
 
28
29
  Options (for run):
29
30
  --example <name> Run a bundled example (dca, grid, funding-arb, mm-spread, mm-maker)
@@ -34,6 +35,14 @@ Options (for run):
34
35
  --poll <ms> Poll interval in milliseconds (default: 10000)
35
36
  --no-ws Disable WebSocket; fall back to REST-only polling
36
37
 
38
+ Options (for prune):
39
+ --older-than <d> Only prune runs started before this duration ago (e.g. 7d, 24h)
40
+ --status <list> CSV of statuses to consider (default: stopped,error,stale)
41
+ --keep-last <N> Keep the N most-recent runs per automation_id
42
+ --all Prune everything except runs that are still alive
43
+ --vacuum VACUUM the DB after deletion to reclaim disk space
44
+ --dry Preview what would be deleted without writing
45
+
37
46
  Scripts are loaded from:
38
47
  1. Absolute or relative path
39
48
  2. ~/.openbroker/automations/<name>.ts
@@ -288,9 +297,83 @@ function stopCommand(positional: string[]) {
288
297
  console.log(`Unregistered: ${id} (will not restart on next gateway start)`);
289
298
  }
290
299
 
291
- function cleanCommand() {
300
+ async function cleanCommand() {
292
301
  cleanRegistry();
293
302
  console.log('Cleaned stale entries from registry');
303
+
304
+ // Also reconcile the audit DB so dead processes whose rows still say
305
+ // 'running' get marked 'stopped'. Without this, the dashboard keeps showing
306
+ // 'stale' badges for automations the operator already cleaned out of the
307
+ // registry.
308
+ try {
309
+ const { prune } = await import('./prune.js');
310
+ const result = prune({ reconcileOnly: true });
311
+ if (result.reconciled > 0) {
312
+ console.log(`Reconciled ${result.reconciled} orphan run row${result.reconciled === 1 ? '' : 's'} in audit DB (status: running → stopped)`);
313
+ } else {
314
+ console.log('Audit DB already consistent');
315
+ }
316
+ } catch (err) {
317
+ console.warn(`Could not reconcile audit DB: ${err instanceof Error ? err.message : String(err)}`);
318
+ }
319
+ }
320
+
321
+ async function pruneCommand(args: Record<string, string | boolean>) {
322
+ const { fmtBytes, parseDuration, prune } = await import('./prune.js');
323
+ const olderThanRaw = args['older-than'];
324
+ const olderThanMs = typeof olderThanRaw === 'string' ? parseDuration(olderThanRaw) : undefined;
325
+ const statusesRaw = typeof args.status === 'string' ? args.status : undefined;
326
+ const statuses = statusesRaw
327
+ ? new Set(statusesRaw.split(',').map((s) => s.trim()).filter(Boolean))
328
+ : undefined;
329
+ const keepLastRaw = args['keep-last'];
330
+ const keepLast = typeof keepLastRaw === 'string' ? Number(keepLastRaw)
331
+ : typeof keepLastRaw === 'number' ? keepLastRaw
332
+ : undefined;
333
+ if (keepLast !== undefined && (!Number.isFinite(keepLast) || keepLast < 0)) {
334
+ console.error('Error: --keep-last must be a non-negative integer');
335
+ process.exit(1);
336
+ }
337
+
338
+ const opts = {
339
+ olderThanMs,
340
+ statuses,
341
+ keepLastPerAutomation: keepLast,
342
+ all: args.all === true,
343
+ vacuum: args.vacuum === true,
344
+ dryRun: args.dry === true,
345
+ };
346
+
347
+ const result = prune(opts);
348
+
349
+ if (result.reconciled > 0) {
350
+ const verb = result.dryRun ? 'Would reconcile' : 'Reconciled';
351
+ console.log(`${verb} ${result.reconciled} orphan run row${result.reconciled === 1 ? '' : 's'} (running → stopped)`);
352
+ }
353
+
354
+ const verb = result.dryRun ? 'Would delete' : 'Deleted';
355
+ if (result.candidateRunIds.length === 0) {
356
+ console.log('No runs matched pruning filters.');
357
+ return;
358
+ }
359
+ console.log(`${verb} ${result.candidateRunIds.length} automation run${result.candidateRunIds.length === 1 ? '' : 's'}:`);
360
+ for (const id of result.candidateRunIds.slice(0, 25)) {
361
+ console.log(` · ${id}`);
362
+ }
363
+ if (result.candidateRunIds.length > 25) {
364
+ console.log(` … and ${result.candidateRunIds.length - 25} more`);
365
+ }
366
+ if (!result.dryRun) {
367
+ const totalChild = Object.values(result.deletedRows).reduce((a, b) => a + b, 0);
368
+ console.log(`Removed ${totalChild.toLocaleString()} child rows across ${Object.keys(result.deletedRows).length} tables`);
369
+ if (result.freedBytes > 0) {
370
+ console.log(`Reclaimed ~${fmtBytes(result.freedBytes)}${opts.vacuum ? ' (post-VACUUM)' : ''}`);
371
+ } else if (opts.vacuum) {
372
+ console.log('No disk reclaimed (VACUUM completed)');
373
+ } else {
374
+ console.log('Run again with --vacuum to reclaim disk space.');
375
+ }
376
+ }
294
377
  }
295
378
 
296
379
  function reportCommand(rawArgs: string[]) {
@@ -364,7 +447,10 @@ async function main() {
364
447
  statusCommand();
365
448
  break;
366
449
  case 'clean':
367
- cleanCommand();
450
+ await cleanCommand();
451
+ break;
452
+ case 'prune':
453
+ await pruneCommand(args);
368
454
  break;
369
455
  case 'report':
370
456
  reportCommand(restArgs);
@@ -0,0 +1,252 @@
1
+ // Audit DB pruning — delete stale automation runs and their child rows.
2
+ //
3
+ // The audit DB is the same SQLite file the daemon writes to and the dashboard
4
+ // reads from. WAL mode lets us open it from another process for delete writes
5
+ // without blocking the daemon. We always protect runs whose status is 'running'
6
+ // AND whose pid is alive.
7
+ //
8
+ // Used by: `openbroker auto prune` and as a sub-step of `openbroker auto clean`.
9
+
10
+ import os from 'os';
11
+ import path from 'path';
12
+ import { existsSync } from 'fs';
13
+ import { DatabaseSync } from 'node:sqlite';
14
+ import { ensureConfigDir } from '../core/config.js';
15
+
16
+ export const AUDIT_DB_PATH = process.env.OPENBROKER_AUDIT_DB_PATH
17
+ || path.join(ensureConfigDir(), 'automation-audit.sqlite');
18
+
19
+ export interface PruneFilters {
20
+ /** Delete runs whose started_at < (now - olderThanMs). Falsy = no age filter. */
21
+ olderThanMs?: number;
22
+ /** Delete runs whose status is in this set. Default: stopped, error, stale. */
23
+ statuses?: Set<string>;
24
+ /** For each automation_id, keep the N most recent runs regardless of other filters. */
25
+ keepLastPerAutomation?: number;
26
+ /** Delete every run that is not currently alive (overrides status/age). */
27
+ all?: boolean;
28
+ }
29
+
30
+ export interface PruneOptions extends PruneFilters {
31
+ dbPath?: string;
32
+ dryRun?: boolean;
33
+ vacuum?: boolean;
34
+ /**
35
+ * When true, skip the deletion phase and only update status of orphaned
36
+ * 'running' rows whose pid is dead — used by `auto clean` to reconcile state
37
+ * without losing history.
38
+ */
39
+ reconcileOnly?: boolean;
40
+ }
41
+
42
+ export interface PruneResult {
43
+ reconciled: number;
44
+ candidateRunIds: string[];
45
+ deletedRows: Record<string, number>;
46
+ freedBytes: number;
47
+ dryRun: boolean;
48
+ }
49
+
50
+ const CHILD_TABLES = [
51
+ 'automation_logs',
52
+ 'automation_events',
53
+ 'automation_actions',
54
+ 'automation_snapshots',
55
+ 'automation_order_updates',
56
+ 'automation_fills',
57
+ 'automation_user_events',
58
+ 'automation_state_changes',
59
+ 'automation_publishes',
60
+ 'automation_errors',
61
+ 'automation_notes',
62
+ 'automation_metrics',
63
+ ] as const;
64
+
65
+ const DEFAULT_STATUSES = new Set(['stopped', 'error', 'stale']);
66
+
67
+ function isProcessAlive(pid: number | null | undefined): boolean {
68
+ if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) return false;
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /** Parse human-friendly durations like `7d`, `24h`, `30m`, `45s`. */
78
+ export function parseDuration(input: string): number {
79
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?\s*$/.exec(input);
80
+ if (!m) throw new Error(`invalid duration: ${input}`);
81
+ const n = Number(m[1]);
82
+ const unit = m[2] ?? 'ms';
83
+ const mult: Record<string, number> = {
84
+ ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 7 * 86_400_000,
85
+ };
86
+ return n * mult[unit];
87
+ }
88
+
89
+ /** Reconcile orphan-running rows in the DB (process dead → mark stopped). */
90
+ export function reconcileStaleRuns(db: DatabaseSync, opts: { dryRun?: boolean; now?: number } = {}): number {
91
+ const now = opts.now ?? Date.now();
92
+ const runningRows = db.prepare(`
93
+ SELECT run_id, pid FROM automation_runs WHERE status = 'running'
94
+ `).all() as { run_id: string; pid: number | null }[];
95
+
96
+ const orphans = runningRows.filter((r) => !isProcessAlive(r.pid));
97
+ if (orphans.length === 0) return 0;
98
+ if (opts.dryRun) return orphans.length;
99
+
100
+ const update = db.prepare(`
101
+ UPDATE automation_runs
102
+ SET status = 'stopped',
103
+ stop_reason = COALESCE(stop_reason, 'reconciled (process exited)'),
104
+ stopped_at = COALESCE(stopped_at, ?)
105
+ WHERE run_id = ?
106
+ `);
107
+ let n = 0;
108
+ for (const o of orphans) {
109
+ update.run(now, o.run_id);
110
+ n++;
111
+ }
112
+ return n;
113
+ }
114
+
115
+ export function prune(opts: PruneOptions = {}): PruneResult {
116
+ const dbPath = opts.dbPath ?? AUDIT_DB_PATH;
117
+ if (!existsSync(dbPath)) {
118
+ return {
119
+ reconciled: 0,
120
+ candidateRunIds: [],
121
+ deletedRows: {},
122
+ freedBytes: 0,
123
+ dryRun: !!opts.dryRun,
124
+ };
125
+ }
126
+
127
+ const db = new DatabaseSync(dbPath);
128
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
129
+
130
+ try {
131
+ const reconciled = reconcileStaleRuns(db, { dryRun: opts.dryRun });
132
+ if (opts.reconcileOnly) {
133
+ return {
134
+ reconciled,
135
+ candidateRunIds: [],
136
+ deletedRows: {},
137
+ freedBytes: 0,
138
+ dryRun: !!opts.dryRun,
139
+ };
140
+ }
141
+
142
+ const allRuns = db.prepare(`
143
+ SELECT run_id, automation_id, status, pid, started_at
144
+ FROM automation_runs
145
+ ORDER BY started_at DESC
146
+ `).all() as {
147
+ run_id: string;
148
+ automation_id: string;
149
+ status: string;
150
+ pid: number | null;
151
+ started_at: number;
152
+ }[];
153
+
154
+ const statuses = opts.statuses ?? DEFAULT_STATUSES;
155
+ const cutoff = opts.olderThanMs && opts.olderThanMs > 0 ? Date.now() - opts.olderThanMs : null;
156
+
157
+ // group runs per automation_id (already sorted DESC by started_at)
158
+ const byAuto = new Map<string, typeof allRuns>();
159
+ for (const r of allRuns) {
160
+ const arr = byAuto.get(r.automation_id) ?? [];
161
+ arr.push(r);
162
+ byAuto.set(r.automation_id, arr);
163
+ }
164
+
165
+ const candidates: typeof allRuns = [];
166
+
167
+ for (const [, runs] of byAuto) {
168
+ const protectedIdx = new Set<number>();
169
+ if (opts.keepLastPerAutomation && opts.keepLastPerAutomation > 0) {
170
+ for (let i = 0; i < Math.min(opts.keepLastPerAutomation, runs.length); i++) {
171
+ protectedIdx.add(i);
172
+ }
173
+ }
174
+ runs.forEach((r, i) => {
175
+ if (protectedIdx.has(i)) return;
176
+ // never delete a truly-running automation
177
+ if (r.status === 'running' && isProcessAlive(r.pid)) return;
178
+ if (opts.all) {
179
+ candidates.push(r);
180
+ return;
181
+ }
182
+ if (!statuses.has(r.status)) {
183
+ // 'running' rows that aren't actually alive were just reconciled to
184
+ // 'stopped' above, so the status check catches them.
185
+ return;
186
+ }
187
+ if (cutoff !== null && r.started_at >= cutoff) return;
188
+ candidates.push(r);
189
+ });
190
+ }
191
+
192
+ const candidateRunIds = candidates.map((r) => r.run_id);
193
+ const deletedRows: Record<string, number> = {};
194
+ let freedBytes = 0;
195
+
196
+ if (!opts.dryRun && candidateRunIds.length > 0) {
197
+ const sizeBefore = db.prepare('PRAGMA page_count').get() as { page_count: number };
198
+ const pageSize = db.prepare('PRAGMA page_size').get() as { page_size: number };
199
+
200
+ db.exec('BEGIN');
201
+ try {
202
+ for (const table of CHILD_TABLES) {
203
+ let n = 0;
204
+ const stmt = db.prepare(`DELETE FROM ${table} WHERE run_id = ?`);
205
+ for (const id of candidateRunIds) {
206
+ const info = stmt.run(id) as { changes?: number };
207
+ n += Number(info.changes ?? 0);
208
+ }
209
+ deletedRows[table] = n;
210
+ }
211
+ const runStmt = db.prepare('DELETE FROM automation_runs WHERE run_id = ?');
212
+ let runChanges = 0;
213
+ for (const id of candidateRunIds) {
214
+ const info = runStmt.run(id) as { changes: number };
215
+ runChanges += Number(info.changes ?? 0);
216
+ }
217
+ deletedRows.automation_runs = runChanges;
218
+ db.exec('COMMIT');
219
+ } catch (err) {
220
+ db.exec('ROLLBACK');
221
+ throw err;
222
+ }
223
+
224
+ if (opts.vacuum) {
225
+ // VACUUM cannot run inside a transaction
226
+ db.exec('VACUUM');
227
+ }
228
+
229
+ const sizeAfter = db.prepare('PRAGMA page_count').get() as { page_count: number };
230
+ freedBytes = (Number(sizeBefore.page_count) - Number(sizeAfter.page_count)) * Number(pageSize.page_size);
231
+ }
232
+
233
+ return {
234
+ reconciled,
235
+ candidateRunIds,
236
+ deletedRows,
237
+ freedBytes: Math.max(0, freedBytes),
238
+ dryRun: !!opts.dryRun,
239
+ };
240
+ } finally {
241
+ db.close();
242
+ }
243
+ }
244
+
245
+ export function fmtBytes(n: number): string {
246
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
247
+ const u = ['B', 'KB', 'MB', 'GB'];
248
+ let i = 0;
249
+ let v = n;
250
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
251
+ return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${u[i]}`;
252
+ }
@@ -288,6 +288,12 @@ function createAuditedClient(
288
288
  async function buildSnapshot(
289
289
  client: HyperliquidClient,
290
290
  ): Promise<AutomationSnapshot> {
291
+ // `metaAndAssetCtxs` contains both mostly-static market metadata and live
292
+ // funding/premium values. The client caches it for market lookups, so clear
293
+ // it before each automation poll snapshot or funding_update events freeze at
294
+ // the first fetched value.
295
+ client.invalidateMetaCache();
296
+
291
297
  const [state, mids, metaCtxs] = await Promise.all([
292
298
  client.getUserStateAll(),
293
299
  client.getAllMids(),
@@ -321,20 +327,29 @@ async function buildSnapshot(
321
327
 
322
328
  // Build funding rates from asset contexts
323
329
  const fundingRates = new Map<string, { rate: number; premium: number }>();
330
+ const addFundingRates = (
331
+ universe: Array<{ name?: string }> | undefined,
332
+ assetCtxs: Array<{ funding?: string | number | null; premium?: string | number | null }> | undefined,
333
+ ) => {
334
+ if (!universe || !assetCtxs) return;
335
+ for (let i = 0; i < universe.length; i++) {
336
+ const meta = universe[i];
337
+ const ctx = assetCtxs[i];
338
+ if (ctx && meta?.name) {
339
+ fundingRates.set(meta.name, {
340
+ rate: parseFloat(String(ctx.funding || '0')),
341
+ premium: parseFloat(String(ctx.premium || '0')),
342
+ });
343
+ }
344
+ }
345
+ };
346
+
324
347
  if (metaCtxs && Array.isArray(metaCtxs)) {
325
348
  for (const group of metaCtxs) {
326
- if (!group.universe || !group.assetCtxs) continue;
327
- for (let i = 0; i < group.universe.length; i++) {
328
- const meta = group.universe[i];
329
- const ctx = group.assetCtxs[i];
330
- if (ctx && meta) {
331
- fundingRates.set(meta.name, {
332
- rate: parseFloat(ctx.funding || '0'),
333
- premium: parseFloat(ctx.premium || '0'),
334
- });
335
- }
336
- }
349
+ addFundingRates(group.universe, group.assetCtxs);
337
350
  }
351
+ } else if (metaCtxs) {
352
+ addFundingRates(metaCtxs.meta?.universe, metaCtxs.assetCtxs);
338
353
  }
339
354
 
340
355
  return {
@@ -1067,7 +1067,11 @@ export class HyperliquidClient {
1067
1067
 
1068
1068
  const response = await fetch(baseUrl + '/info', {
1069
1069
  method: 'POST',
1070
- headers: { 'Content-Type': 'application/json' },
1070
+ headers: {
1071
+ 'Content-Type': 'application/json',
1072
+ 'Cache-Control': 'no-cache, no-store, max-age=0',
1073
+ Pragma: 'no-cache',
1074
+ },
1071
1075
  body: JSON.stringify({ type: 'predictedFundings' }),
1072
1076
  });
1073
1077
  const data = await response.json() as Array<[