openbroker 1.1.2 → 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/README.md CHANGED
@@ -106,36 +106,38 @@ openbroker markets --sort change --top 10 # Top movers
106
106
 
107
107
  #### `all-markets` — All Markets
108
108
 
109
- Browse all available markets across main perps, HIP-3 perps, and spot — grouped by type.
109
+ Browse all available markets across main perps, HIP-3 perps, spot, and HIP-4 outcomes — grouped by type.
110
110
 
111
111
  ```bash
112
112
  openbroker all-markets # Everything
113
113
  openbroker all-markets --type perp # Main perps only
114
114
  openbroker all-markets --type hip3 # HIP-3 perps only
115
115
  openbroker all-markets --type spot # Spot only
116
+ openbroker all-markets --type outcome # HIP-4 outcomes only
116
117
  openbroker all-markets --top 20 # Top 20 by volume
117
118
  ```
118
119
 
119
120
  | Flag | Description | Default |
120
121
  |------|-------------|---------|
121
- | `--type` | Filter: `perp`, `spot`, `hip3`, or `all` | `all` |
122
+ | `--type` | Filter: `perp`, `spot`, `hip3`, `outcome`, or `all` | `all` |
122
123
  | `--top` | Limit to top N by volume | — |
123
124
  | `--verbose` | Show detailed output | — |
124
125
 
125
126
  #### `search` — Search Markets
126
127
 
127
- Search for assets by name across all providers (perps, HIP-3, spot). Shows funding comparison when an asset is listed on multiple venues.
128
+ Search for assets by name across all providers (perps, HIP-3, spot, HIP-4 outcomes). Shows funding comparison when an asset is listed on multiple venues.
128
129
 
129
130
  ```bash
130
131
  openbroker search --query GOLD # Find all GOLD markets
131
132
  openbroker search --query ETH --type perp # ETH perps only
132
133
  openbroker search --query PURR --type spot # PURR spot only
134
+ openbroker search --query BTC --type outcome # HIP-4 outcomes only
133
135
  ```
134
136
 
135
137
  | Flag | Description | Default |
136
138
  |------|-------------|---------|
137
139
  | `--query` | Search term (matches coin name) | **required** |
138
- | `--type` | Filter: `perp`, `spot`, `hip3`, or `all` | `all` |
140
+ | `--type` | Filter: `perp`, `spot`, `hip3`, `outcome`, or `all` | `all` |
139
141
  | `--verbose` | Show detailed output | — |
140
142
 
141
143
  #### `spot` — Spot Markets & Balances
@@ -154,6 +156,26 @@ openbroker spot --top 20 # Top 20 by volume
154
156
  | `--top` | Limit to top N by volume | — |
155
157
  | `--verbose` | Show token metadata | — |
156
158
 
159
+ #### `outcomes` — HIP-4 Outcome Markets
160
+
161
+ Search and inspect prediction/outcome markets. Outcome sides use Hyperliquid's encoded spot-like coin form: `#<encoding>`, where `encoding = 10 * outcomeId + side`; side `0` is usually YES and side `1` is usually NO.
162
+
163
+ ```bash
164
+ openbroker outcomes --query BTC
165
+ openbroker outcomes --outcome 123
166
+ openbroker outcomes --outcome 123 --side yes --json
167
+ openbroker outcomes --balances
168
+ ```
169
+
170
+ | Flag | Description | Default |
171
+ |------|-------------|---------|
172
+ | `--query` | Search market name, description, underlying, expiry, or target price | — |
173
+ | `--outcome` | Outcome id, `#<encoding>`, or `+<encoding>` | — |
174
+ | `--side` | Outcome side for plain ids: `yes`, `no`, `0`, or `1` | `yes` |
175
+ | `--balances` | Show outcome token balances | — |
176
+ | `--top` | Limit to top N matches | — |
177
+ | `--json` | Machine-readable output | — |
178
+
157
179
  #### `fills` — Trade Fill History
158
180
 
159
181
  View your trade executions with prices, fees, and realized PnL.
@@ -379,6 +401,30 @@ openbroker cancel --all --dry # Preview what would be cancelled
379
401
  | `--all` | Cancel all open orders | — |
380
402
  | `--dry` | Show orders without cancelling | — |
381
403
 
404
+ #### `outcome-buy` / `outcome-sell` / `outcome-order` — HIP-4 Outcome Orders
405
+
406
+ Buy or sell a YES/NO outcome token. Buying opens exposure to that side; selling reduces or closes that token balance. Market orders are IOC limits with slippage protection.
407
+
408
+ ```bash
409
+ openbroker outcomes --query BTC
410
+ openbroker outcome-buy --outcome 123 --outcome-side yes --size 10 --dry
411
+ openbroker outcome-buy --outcome 123 --outcome-side no --size 5 --price 0.42
412
+ openbroker outcome-sell --outcome #1230 --size 10
413
+ openbroker outcome-order --outcome 123 --outcome-side yes --side buy --size 10
414
+ ```
415
+
416
+ | Flag | Description | Default |
417
+ |------|-------------|---------|
418
+ | `--outcome` | Outcome id, `#<encoding>`, or `+<encoding>` | **required** |
419
+ | `--outcome-side` | `yes`, `no`, `0`, or `1` when using a plain outcome id | `yes` |
420
+ | `--side` | `buy` or `sell` (auto-set by shortcuts) | **required** |
421
+ | `--size` | Size in outcome token units | **required** |
422
+ | `--price` | Limit price between 0 and 1 (omit for market IOC) | market |
423
+ | `--tif` | Time in force for limit orders: `Gtc`, `Ioc`, `Alo` | `Gtc` |
424
+ | `--slippage` | Slippage tolerance in bps for market orders | `50` |
425
+ | `--sz-decimals` | Override size decimals if metadata omits token decimals | metadata / `0` |
426
+ | `--dry` | Preview without executing | — |
427
+
382
428
  ---
383
429
 
384
430
  ### Advanced Execution
@@ -742,8 +788,9 @@ When loaded, the plugin registers these agent tools:
742
788
  | Info | `ob_candles` | OHLCV candle data for an asset |
743
789
  | Info | `ob_trades` | Recent trades (tape) for an asset |
744
790
  | Info | `ob_markets` | Market data (price, volume, OI) |
745
- | Info | `ob_search` | Search assets across perps, HIP-3, and spot |
791
+ | Info | `ob_search` | Search assets across perps, HIP-3, spot, and HIP-4 outcomes |
746
792
  | Info | `ob_spot` | Spot markets and token balances |
793
+ | Info | `ob_outcomes` | HIP-4 outcome markets and outcome token balances |
747
794
  | Info | `ob_rate_limit` | API rate limit usage and capacity |
748
795
  | Trading | `ob_buy` | Market buy |
749
796
  | Trading | `ob_sell` | Market sell |
@@ -751,6 +798,8 @@ When loaded, the plugin registers these agent tools:
751
798
  | Trading | `ob_trigger` | Trigger order (TP/SL) |
752
799
  | Trading | `ob_tpsl` | Set TP/SL on existing position |
753
800
  | Trading | `ob_cancel` | Cancel orders |
801
+ | Trading | `ob_outcome_buy` | Buy a HIP-4 YES/NO outcome token |
802
+ | Trading | `ob_outcome_sell` | Sell or close a HIP-4 YES/NO outcome token |
754
803
  | Advanced | `ob_twap` | Native TWAP order (exchange-managed) |
755
804
  | Advanced | `ob_twap_cancel` | Cancel a running TWAP order |
756
805
  | Advanced | `ob_twap_status` | View TWAP order history/status |
package/bin/cli.ts CHANGED
@@ -20,7 +20,7 @@ const commands: Record<string, { script: string; description: string }> = {
20
20
  'positions': { script: 'info/positions.ts', description: 'View open positions' },
21
21
  'funding': { script: 'info/funding.ts', description: 'View funding rates' },
22
22
  'markets': { script: 'info/markets.ts', description: 'View market data' },
23
- 'all-markets': { script: 'info/all-markets.ts', description: 'View all markets (perps, HIP-3, spot)' },
23
+ 'all-markets': { script: 'info/all-markets.ts', description: 'View all markets (perps, HIP-3, spot, HIP-4)' },
24
24
  'search': { script: 'info/search-markets.ts', description: 'Search for assets across providers' },
25
25
  'spot': { script: 'info/spot.ts', description: 'View spot markets and balances' },
26
26
  'fills': { script: 'info/fills.ts', description: 'View trade fill history' },
@@ -32,6 +32,7 @@ const commands: Record<string, { script: string; description: string }> = {
32
32
  'trades': { script: 'info/trades.ts', description: 'View recent trades for an asset' },
33
33
  'rate-limit': { script: 'info/rate-limit.ts', description: 'View API rate limit status' },
34
34
  'funding-scan': { script: 'info/funding-scan.ts', description: 'Scan funding rates across all dexes' },
35
+ 'outcomes': { script: 'info/outcomes.ts', description: 'Search and inspect HIP-4 outcome markets' },
35
36
 
36
37
  // Operations
37
38
  'buy': { script: 'operations/market-order.ts', description: 'Market buy order' },
@@ -50,6 +51,11 @@ const commands: Record<string, { script: string; description: string }> = {
50
51
  'spot-buy': { script: 'operations/spot-order.ts', description: 'Spot buy order' },
51
52
  'spot-sell': { script: 'operations/spot-order.ts', description: 'Spot sell order' },
52
53
  'spot-order': { script: 'operations/spot-order.ts', description: 'Spot order (market or limit)' },
54
+ 'outcome-buy': { script: 'operations/outcome-order.ts', description: 'Buy a HIP-4 outcome token' },
55
+ 'outcome-sell': { script: 'operations/outcome-order.ts', description: 'Sell a HIP-4 outcome token' },
56
+ 'outcome-open': { script: 'operations/outcome-order.ts', description: 'Open a HIP-4 outcome position' },
57
+ 'outcome-close': { script: 'operations/outcome-order.ts', description: 'Close a HIP-4 outcome position' },
58
+ 'outcome-order': { script: 'operations/outcome-order.ts', description: 'HIP-4 outcome order (market or limit)' },
53
59
 
54
60
  // Automations
55
61
  'auto': { script: 'auto/cli.ts', description: 'Run/manage trading automations' },
@@ -76,11 +82,12 @@ Info Commands:
76
82
  candles View OHLCV candle data
77
83
  trades View recent trades (tape) for an asset
78
84
  markets View market data for main perps
79
- all-markets View all markets (perps, HIP-3, spot)
85
+ all-markets View all markets (perps, HIP-3, spot, HIP-4)
80
86
  search Search for assets across all providers
81
87
  spot View spot markets and balances
82
88
  rate-limit View API rate limit status
83
89
  funding-scan Scan funding rates across all dexes (main + HIP-3)
90
+ outcomes Search and inspect HIP-4 outcome markets
84
91
 
85
92
  Trading Commands:
86
93
  buy Market buy order
@@ -96,6 +103,13 @@ Spot Trading:
96
103
  spot-sell Spot sell order
97
104
  spot-order Spot order (market or limit, specify --side)
98
105
 
106
+ HIP-4 Outcome Trading:
107
+ outcome-buy Buy a YES/NO outcome token
108
+ outcome-sell Sell a YES/NO outcome token
109
+ outcome-open Alias for outcome-buy
110
+ outcome-close Alias for outcome-sell
111
+ outcome-order Outcome order (market or limit, specify --side)
112
+
99
113
  Advanced Execution:
100
114
  twap Native TWAP order (exchange-managed)
101
115
  twap-cancel Cancel a running TWAP order
@@ -127,6 +141,8 @@ Examples:
127
141
  openbroker buy --coin ETH --size 0.1 # Market buy 0.1 ETH
128
142
  openbroker limit --coin BTC --side buy --size 0.01 --price 60000
129
143
  openbroker search --query GOLD # Find GOLD across providers
144
+ openbroker outcomes --query BTC # Find HIP-4 outcome markets
145
+ openbroker outcome-buy --outcome 123 --outcome-side yes --size 10 --dry
130
146
  openbroker tpsl --coin HYPE --tp 40 --sl 30 # Set TP/SL on position
131
147
 
132
148
  Documentation: https://github.com/aurracloud/open-broker
@@ -204,6 +220,14 @@ function main() {
204
220
  runScript(commands['spot-order'].script, ['--side', 'sell', ...commandArgs]);
205
221
  return;
206
222
  }
223
+ if (command === 'outcome-buy' || command === 'outcome-open') {
224
+ runScript(commands['outcome-order'].script, ['--side', 'buy', ...commandArgs]);
225
+ return;
226
+ }
227
+ if (command === 'outcome-sell' || command === 'outcome-close') {
228
+ runScript(commands['outcome-order'].script, ['--side', 'sell', ...commandArgs]);
229
+ return;
230
+ }
207
231
 
208
232
  // Handle version
209
233
  if (command === '--version' || command === '-v') {
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.1.2",
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": {
@@ -30,6 +30,7 @@
30
30
  "search-markets": "tsx scripts/info/search-markets.ts",
31
31
  "spot": "tsx scripts/info/spot.ts",
32
32
  "market-order": "tsx scripts/operations/market-order.ts",
33
+ "outcome-order": "tsx scripts/operations/outcome-order.ts",
33
34
  "limit-order": "tsx scripts/operations/limit-order.ts",
34
35
  "trigger-order": "tsx scripts/operations/trigger-order.ts",
35
36
  "set-tpsl": "tsx scripts/operations/set-tpsl.ts",
@@ -41,12 +42,14 @@
41
42
  "bracket": "tsx scripts/operations/bracket.ts",
42
43
  "chase": "tsx scripts/operations/chase.ts",
43
44
  "funding-scan": "tsx scripts/info/funding-scan.ts",
45
+ "outcomes": "tsx scripts/info/outcomes.ts",
44
46
  "prepublishOnly": "npm run test:cli",
45
47
  "test:cli": "node --import tsx bin/cli.ts --help"
46
48
  },
47
49
  "dependencies": {
48
50
  "@nktkas/hyperliquid": "^0.30.3",
49
51
  "dotenv": "^17.2.3",
52
+ "openbroker-monitoring": "file:../openbroker-monitoring",
50
53
  "tsx": "^4.19.0",
51
54
  "viem": "^2.21.0"
52
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
+ }