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 +14 -0
- package/README.md +54 -5
- package/bin/cli.ts +26 -2
- package/bin/openbroker.js +4 -0
- package/package.json +4 -1
- package/scripts/auto/cli.ts +89 -3
- package/scripts/auto/prune.ts +252 -0
- package/scripts/auto/runtime.ts +26 -11
- package/scripts/core/client.ts +363 -1
- package/scripts/core/types.ts +50 -0
- package/scripts/info/all-markets.ts +47 -4
- package/scripts/info/outcomes.ts +200 -0
- package/scripts/info/search-markets.ts +40 -5
- package/scripts/operations/outcome-order.ts +185 -0
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
|
|
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
|
|
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.
|
|
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
|
},
|
package/scripts/auto/cli.ts
CHANGED
|
@@ -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
|
+
}
|