openbroker 1.2.0 → 1.3.1
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/SKILL.md +264 -0
- package/bin/openbroker.js +4 -0
- package/package.json +2 -1
- package/scripts/auto/cli.ts +92 -3
- package/scripts/auto/keep-awake.ts +100 -0
- package/scripts/auto/prune.ts +252 -0
- package/scripts/auto/runtime.ts +43 -11
- package/scripts/core/client.ts +5 -1
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/SKILL.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openbroker
|
|
3
|
+
description: Hyperliquid trading CLI skill for agents. Use when an agent needs to inspect markets/accounts, place or manage perp/spot/HIP-4 orders, or write and run Hyperliquid trading automations directly through the `openbroker` CLI without requiring the OpenClaw plugin.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires Node.js 22+, network access to api.hyperliquid.xyz
|
|
6
|
+
homepage: https://www.npmjs.com/package/openbroker
|
|
7
|
+
metadata: {"author": "monemetrics", "version": "1.3.1"}
|
|
8
|
+
allowed-tools: Bash(openbroker:*)
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# OpenBroker — Hyperliquid CLI skill
|
|
12
|
+
|
|
13
|
+
OpenBroker is first a CLI. The OpenClaw plugin is optional: use `ob_*` tools when present for common structured calls, but keep the CLI model in mind because it is the complete surface area and the safest fallback.
|
|
14
|
+
|
|
15
|
+
## Operating rules
|
|
16
|
+
|
|
17
|
+
- For unfamiliar assets, **search before trading**. Hyperliquid has main perps, HIP-3 perps, spot markets, and HIP-4 outcomes that can share names.
|
|
18
|
+
- Prefer `--json` for machine-readable info commands.
|
|
19
|
+
- Before any write, verify the asset, account, open positions/orders, size, and whether the action should be reduce-only.
|
|
20
|
+
- For new or changed trading logic, start with `--dry`, inspect the plan/audit trail, then go live only when that matches the user’s intent.
|
|
21
|
+
- Treat CLI output as exchange state, not just prose: parse order IDs, balances, fills, and errors instead of assuming success.
|
|
22
|
+
|
|
23
|
+
## Setup and identity
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g openbroker
|
|
27
|
+
openbroker setup
|
|
28
|
+
openbroker account --json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`setup` supports:
|
|
32
|
+
|
|
33
|
+
1. **Fresh wallet** — simplest for agents; builder fee approval is handled automatically.
|
|
34
|
+
2. **Imported key** — use an existing wallet.
|
|
35
|
+
3. **API wallet** — can trade but not withdraw; the human owner must approve it in a browser.
|
|
36
|
+
|
|
37
|
+
For API wallets, `HYPERLIQUID_PRIVATE_KEY` is the signing key and `HYPERLIQUID_ACCOUNT_ADDRESS` must be the funded master account. If account output shows `$0` equity unexpectedly, check that mapping first.
|
|
38
|
+
|
|
39
|
+
Common globals:
|
|
40
|
+
|
|
41
|
+
| Flag | Meaning |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `-c, --config <path>` | Use a specific `.env` file |
|
|
44
|
+
| `--testnet` | Use testnet |
|
|
45
|
+
| `--dry` | Preview write commands without executing |
|
|
46
|
+
| `--verbose` | Debug output |
|
|
47
|
+
| `--json` | Machine-readable output on info commands |
|
|
48
|
+
|
|
49
|
+
## Asset discovery and IDs
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
openbroker search --query GOLD --json
|
|
53
|
+
openbroker search --query BTC --type perp --json
|
|
54
|
+
openbroker all-markets --type hip3 --json
|
|
55
|
+
openbroker outcomes --query BTC --json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- HIP-3 perps use `dex:COIN`, e.g. `xyz:CL`, not bare `CL`.
|
|
59
|
+
- `assetId` is the canonical identifier for comparisons and persisted agent state; order placement still uses `--coin`.
|
|
60
|
+
- HIP-4 outcome orders use `--outcome <id|#encoding|+encoding>` plus `--outcome-side yes|no` when the reference is a plain ID.
|
|
61
|
+
- On testnet, HIP-3 metadata may need an explicit prefixed coin such as `dex:COIN`.
|
|
62
|
+
|
|
63
|
+
## CLI command map
|
|
64
|
+
|
|
65
|
+
### Info commands
|
|
66
|
+
|
|
67
|
+
Most info commands accept `--json`. Use `--coin`, `--top`, and `--address` where the command supports them rather than repeating bespoke parsing logic.
|
|
68
|
+
|
|
69
|
+
| Command | Main use | Distinct flags |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `account` | Equity, margin, spot balances, positions | `--orders`, `--address` |
|
|
72
|
+
| `positions` | Open perp positions and liquidation distance | `--coin`, `--address` |
|
|
73
|
+
| `funding` | Funding rates | `--coin`, `--top`, `--sort annualized|hourly|oi`, `--all`, `--include-hip3` |
|
|
74
|
+
| `markets` | Perp market data | `--coin`, `--top`, `--sort volume|oi|change`, `--include-hip3` |
|
|
75
|
+
| `all-markets` | Browse every venue type | `--type perp|hip3|spot|outcome|all`, `--top` |
|
|
76
|
+
| `search` | Find markets across providers | `--query`, `--type` |
|
|
77
|
+
| `spot` | Spot markets or balances | `--coin`, `--balances`, `--address`, `--top` |
|
|
78
|
+
| `fills` | Recent fills | `--coin`, `--side buy|sell`, `--top`, `--address` |
|
|
79
|
+
| `orders` | Order history/open orders | `--coin`, `--status`, `--open`, `--top`, `--address` |
|
|
80
|
+
| `order-status` | One order by exchange/client ID | `--oid`, `--address` |
|
|
81
|
+
| `fees` | Fee tier and rates | `--address` |
|
|
82
|
+
| `candles` | OHLCV | `--coin`, `--interval`, `--bars` |
|
|
83
|
+
| `funding-history` | Historical funding | `--coin`, `--hours` |
|
|
84
|
+
| `trades` | Recent tape | `--coin`, `--top` |
|
|
85
|
+
| `rate-limit` | API usage | — |
|
|
86
|
+
| `funding-scan` | Cross-dex scan | `--threshold`, `--main-only`, `--hip3-only`, `--pairs`, `--watch`, `--interval`, `--top` |
|
|
87
|
+
| `outcomes` | HIP-4 discovery/balances | `--query`, `--outcome`, `--side`, `--balances`, `--top` |
|
|
88
|
+
|
|
89
|
+
### Perp trading
|
|
90
|
+
|
|
91
|
+
Shared perp order flags:
|
|
92
|
+
|
|
93
|
+
| Flag | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `--coin <COIN>` | Main perp or HIP-3 `dex:COIN` |
|
|
96
|
+
| `--side buy|sell` | Required except `buy` / `sell` shortcuts |
|
|
97
|
+
| `--size <SIZE>` | Base-asset size |
|
|
98
|
+
| `--leverage <N>` | Main perps use cross; HIP-3 uses isolated |
|
|
99
|
+
| `--reduce` | Reduce-only; use when closing/reducing exposure |
|
|
100
|
+
| `--slippage <bps>` | Market/SL slippage tolerance |
|
|
101
|
+
|
|
102
|
+
| Command | Shape |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `buy`, `sell` | Market shortcuts: `openbroker buy --coin ETH --size 0.1` |
|
|
105
|
+
| `market` | Explicit market order with `--side` |
|
|
106
|
+
| `limit` | Add `--price` and optional `--tif GTC|IOC|ALO` |
|
|
107
|
+
| `trigger` | Add `--trigger`, `--type tp|sl`, optional `--limit` |
|
|
108
|
+
| `tpsl` | Protect an existing position with `--tp` and/or `--sl`; accepts absolute, `%`, or `entry` forms |
|
|
109
|
+
| `cancel` | `--all`, `--coin`, or `--oid` |
|
|
110
|
+
|
|
111
|
+
### Spot and HIP-4 outcome trading
|
|
112
|
+
|
|
113
|
+
| Family | Commands | Shared flags |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| Spot | `spot-buy`, `spot-sell`, `spot-order` | `--coin`, `--side`, `--size`, optional `--price`, `--tif Gtc|Ioc|Alo`, `--slippage` |
|
|
116
|
+
| Outcomes | `outcome-buy`, `outcome-sell`, `outcome-open`, `outcome-close`, `outcome-order` | `--outcome`, `--outcome-side`, `--side`, `--size`, optional `--price`, `--tif`, `--slippage`, `--sz-decimals` |
|
|
117
|
+
|
|
118
|
+
### Advanced execution
|
|
119
|
+
|
|
120
|
+
| Command | Use | Distinct flags |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `twap` | Exchange-managed TWAP | `--duration`, `--randomize`, `--reduce-only` |
|
|
123
|
+
| `twap-cancel` | Stop a TWAP | `--coin`, `--twap-id` |
|
|
124
|
+
| `twap-status` | Inspect TWAPs | `--active` |
|
|
125
|
+
| `scale` | Multi-level ladder | `--levels`, `--range`, `--distribution linear|exponential|flat`, `--tif` |
|
|
126
|
+
| `bracket` | Entry + TP + SL | `--entry market|limit`, `--price`, `--tp`, `--sl` |
|
|
127
|
+
| `chase` | Repriced ALO order | `--offset`, `--timeout`, `--interval`, `--max-chase` |
|
|
128
|
+
|
|
129
|
+
## High-signal workflows
|
|
130
|
+
|
|
131
|
+
Inspect before trading:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
openbroker search --query HYPE --json
|
|
135
|
+
openbroker account --orders --json
|
|
136
|
+
openbroker positions --json
|
|
137
|
+
openbroker markets --coin HYPE --json
|
|
138
|
+
openbroker buy --coin HYPE --size 1 --dry
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Close rather than flip:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
openbroker positions --coin ETH --json
|
|
145
|
+
openbroker sell --coin ETH --size 0.1 --reduce --dry
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For large or passive execution, prefer `limit`, `chase`, `scale`, or `twap` over a blind market order.
|
|
149
|
+
|
|
150
|
+
## Automations
|
|
151
|
+
|
|
152
|
+
Automations are TypeScript scripts run by the CLI:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
openbroker auto run <script> [--id <name>] [--set key=value] [--poll <ms>] [--dry]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Management commands:
|
|
159
|
+
|
|
160
|
+
| Command | Use |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `auto examples` | Inspect bundled examples and schemas |
|
|
163
|
+
| `auto run <script>` | Run a custom script by path or from `~/.openbroker/automations/` |
|
|
164
|
+
| `auto list` | List available automations |
|
|
165
|
+
| `auto status` | Show running automations |
|
|
166
|
+
| `auto stop <id>` | Unregister/stop an automation |
|
|
167
|
+
| `auto report <id>` | Summarize audit data |
|
|
168
|
+
| `auto clean` | Reconcile stale registry entries |
|
|
169
|
+
| `auto prune ...` | Delete old audit runs |
|
|
170
|
+
|
|
171
|
+
Run flags:
|
|
172
|
+
|
|
173
|
+
| Flag | Meaning |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `--set key=value` | Repeatable typed config values |
|
|
176
|
+
| `--id <name>` | Stable automation ID |
|
|
177
|
+
| `--poll <ms>` | Poll interval, minimum 1000 ms |
|
|
178
|
+
| `--dry` | Intercept write methods |
|
|
179
|
+
| `--no-ws` | Disable WebSocket and rely on REST polling |
|
|
180
|
+
| `--allow-sleep` | Do not request OS sleep inhibition |
|
|
181
|
+
|
|
182
|
+
Bundled examples are **references, not production strategies**. Read them for API patterns, then write a purpose-built script with explicit sizing, exit logic, and failure behavior.
|
|
183
|
+
|
|
184
|
+
### Automation API essentials
|
|
185
|
+
|
|
186
|
+
- `api.client` — full Hyperliquid client.
|
|
187
|
+
- `api.on(...)`, `api.every(...)`, `api.onStart(...)`, `api.onStop(...)`, `api.onError(...)`.
|
|
188
|
+
- `api.state` — persisted state; survives restarts.
|
|
189
|
+
- `api.audit.record(...)` / `api.audit.metric(...)` — durable observability.
|
|
190
|
+
- `api.publish(...)` — notify an OpenClaw agent when hooks are configured.
|
|
191
|
+
- `api.dryRun` — whether writes are intercepted.
|
|
192
|
+
|
|
193
|
+
Core events include `tick`, `price_change`, `funding_update`, `position_opened`, `position_closed`, `position_changed`, `pnl_threshold`, `margin_warning`, `order_filled`, `order_update`, and `liquidation`.
|
|
194
|
+
|
|
195
|
+
### Monitoring and dashboard
|
|
196
|
+
|
|
197
|
+
`openbroker-monitoring` is optional but useful for long-running automations, live debugging, and post-run inspection.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm install openbroker-monitoring
|
|
201
|
+
openbroker auto run ./my-automation.ts --id my-auto
|
|
202
|
+
openbroker-monitoring serve --host 127.0.0.1 --port 3001
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- The local dashboard reads `~/.openbroker/automation-audit.sqlite` directly; it works for any standard `openbroker auto run` automation and does **not** need vault config, webhooks, or remote forwarding.
|
|
206
|
+
- Configure it with `OB_MONITOR_HOST`, `OB_MONITOR_PORT`, or `OPENBROKER_AUDIT_DB_PATH`, or the equivalent `serve --host/--port/--db` flags.
|
|
207
|
+
- When installed alongside OpenBroker, the package is convention-loaded as an audit observer. Remote forwarding is separate and only activates when `OB_DASHBOARD_URL` plus `HYPERSTABLE_VAULT_ADDRESS` or `VAULT` are configured; `OB_DASHBOARD_API_KEY` is optional.
|
|
208
|
+
- Start the dashboard when the user wants a live view, troubleshooting help, or ongoing monitoring. It is helpful infrastructure, not a prerequisite for every automation.
|
|
209
|
+
|
|
210
|
+
### Hyperliquid automation design rules
|
|
211
|
+
|
|
212
|
+
These matter more than boilerplate:
|
|
213
|
+
|
|
214
|
+
1. **Model the strategy as a state machine.** Persist flags, streaks, targets, and recovery state with `api.state`; handlers can fire repeatedly and processes can restart.
|
|
215
|
+
2. **Use hysteresis, not one-print decisions.** Confirmation loops, separate enter/exit thresholds, and debounce logic prevent churn from noisy funding or tiny price moves.
|
|
216
|
+
3. **Use the freshest correct signal.** For funding strategies, prefer `getPredictedFundings()` when available; if you fall back to instantaneous funding from metadata, ensure the metadata cache is refreshed so the signal does not freeze after startup.
|
|
217
|
+
4. **Price the flip, not just the signal.** Before closing and later reopening a carry, compare expected hold cost with round-trip trading cost. The HYPE carry automation counts maker fees across both legs plus builder fees before deciding whether a mildly negative funding window is worth exiting.
|
|
218
|
+
5. **Respect settlement timing.** If the current predicted funding is still positive, a close right before hourly settlement can be economically wrong even when the broader signal weakened. Add a settlement-proximity guard when the strategy depends on funding capture.
|
|
219
|
+
6. **Sequence multi-leg hedges deliberately.** For spot-long / perp-short carry, build spot first, then short only up to spot-backed exposure; unwind spot first, then close the short reduce-only. Recover accidental one-sided exposure explicitly instead of pretending it cannot happen.
|
|
220
|
+
7. **Separate strategy logic from execution policy.** Maker-first execution can reduce fees, but it needs bounded retries, post-only rejection handling, order cancellation, partial-fill accounting, minimum trade thresholds, and a defined IOC fallback. Measure progress from refreshed balances/positions, not only from submit responses.
|
|
221
|
+
8. **Size from real NAV and hard caps.** Multi-leg strategies often need spot balances, spot USDC, and perp account value combined; a 50/50 carry target derived from total NAV must then be halved per side and still respect a hard per-side cap.
|
|
222
|
+
9. **Define stop behavior intentionally.** On shutdown, always handle working orders, but do not blindly flatten every strategy. A hedged carry may need “preserve hedge and alert,” while a transient execution bot may need “cancel and flatten.”
|
|
223
|
+
10. **Instrument first-class decisions.** Log and audit funding source, targets, leg notionals, settlement distance, hold/close decisions, fills, retries, and error paths. If using the plugin, publish events that need human attention.
|
|
224
|
+
|
|
225
|
+
Additional practical caveats:
|
|
226
|
+
|
|
227
|
+
- Positive-funding carry and negative-funding carry are not automatically symmetric. If the hedge requires short spot and the client/runtime cannot express that safely, do not invent an unhedged mirror trade.
|
|
228
|
+
- `funding_update` fires for many assets every poll; filter by coin early.
|
|
229
|
+
- Dust matters: if residual size falls below exchange precision or `minTradeUsd`, stop chasing it.
|
|
230
|
+
- `ALO` / post-only orders can be rejected when they would cross; treat that as an execution branch, not a surprise.
|
|
231
|
+
- Naked directional positions usually need explicit TP/SL or equivalent risk logic. Hedged multi-leg strategies need strategy-specific exits instead of cargo-cult TP/SL rules.
|
|
232
|
+
- For new automations, do a dry run, inspect `auto report`, and only then run live unless the user explicitly requested immediate live execution.
|
|
233
|
+
|
|
234
|
+
## Plugin-aware use
|
|
235
|
+
|
|
236
|
+
When the OpenClaw plugin is available:
|
|
237
|
+
|
|
238
|
+
- Prefer `ob_*` tools for common structured reads and simple writes.
|
|
239
|
+
- Use `ob_watcher_status` for background monitoring state.
|
|
240
|
+
- Use `ob_auto_run`, `ob_auto_stop`, and `ob_auto_list` for supported automation actions.
|
|
241
|
+
- Fall back to the CLI for unsupported commands, debugging, richer flags, or if a tool returns empty/unexpected data.
|
|
242
|
+
|
|
243
|
+
Representative mappings:
|
|
244
|
+
|
|
245
|
+
| Plugin tool | CLI equivalent |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `ob_account` | `openbroker account --json` |
|
|
248
|
+
| `ob_positions` | `openbroker positions --json` |
|
|
249
|
+
| `ob_funding` | `openbroker funding --json --include-hip3` |
|
|
250
|
+
| `ob_search` | `openbroker search --query <QUERY> --json` |
|
|
251
|
+
| `ob_buy` / `ob_sell` | `openbroker buy|sell --coin <COIN> --size <SIZE>` |
|
|
252
|
+
| `ob_limit` | `openbroker limit ...` |
|
|
253
|
+
| `ob_tpsl` | `openbroker tpsl ...` |
|
|
254
|
+
| `ob_auto_run` | `openbroker auto run <script> ...` |
|
|
255
|
+
|
|
256
|
+
Skill-only mode is fully usable through the CLI; the plugin adds agent tools, watcher notifications, and OpenClaw webhook integration.
|
|
257
|
+
|
|
258
|
+
## Failure checks
|
|
259
|
+
|
|
260
|
+
- `No market data found` → search again; likely wrong venue prefix.
|
|
261
|
+
- `$0` equity on an API wallet → likely missing `HYPERLIQUID_ACCOUNT_ADDRESS`.
|
|
262
|
+
- Unexpected funding behavior → check whether you are reading predicted vs cached instantaneous data.
|
|
263
|
+
- Strategy churn → inspect confirmation loops, fee-aware hold logic, settlement guards, and min-trade thresholds before changing position size.
|
|
264
|
+
- Tool failure in plugin mode → rerun the equivalent CLI command with `--json` and `--verbose` if needed.
|
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.1",
|
|
4
4
|
"description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"bin/",
|
|
16
16
|
"scripts/",
|
|
17
17
|
"config/example.env",
|
|
18
|
+
"SKILL.md",
|
|
18
19
|
"README.md",
|
|
19
20
|
"CHANGELOG.md"
|
|
20
21
|
],
|
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)
|
|
@@ -33,6 +34,15 @@ Options (for run):
|
|
|
33
34
|
--id <name> Custom automation ID (default: filename)
|
|
34
35
|
--poll <ms> Poll interval in milliseconds (default: 10000)
|
|
35
36
|
--no-ws Disable WebSocket; fall back to REST-only polling
|
|
37
|
+
--allow-sleep Do not request OS idle-sleep inhibition for this run
|
|
38
|
+
|
|
39
|
+
Options (for prune):
|
|
40
|
+
--older-than <d> Only prune runs started before this duration ago (e.g. 7d, 24h)
|
|
41
|
+
--status <list> CSV of statuses to consider (default: stopped,error,stale)
|
|
42
|
+
--keep-last <N> Keep the N most-recent runs per automation_id
|
|
43
|
+
--all Prune everything except runs that are still alive
|
|
44
|
+
--vacuum VACUUM the DB after deletion to reclaim disk space
|
|
45
|
+
--dry Preview what would be deleted without writing
|
|
36
46
|
|
|
37
47
|
Scripts are loaded from:
|
|
38
48
|
1. Absolute or relative path
|
|
@@ -116,6 +126,7 @@ async function runCommand(args: Record<string, string | boolean>, positional: st
|
|
|
116
126
|
const dryRun = args.dry === true;
|
|
117
127
|
const verbose = args.verbose === true;
|
|
118
128
|
const useWebSocket = args['no-ws'] !== true;
|
|
129
|
+
const keepAwake = args['allow-sleep'] === true ? false : undefined;
|
|
119
130
|
const pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : undefined;
|
|
120
131
|
const id = args.id ? String(args.id) : undefined;
|
|
121
132
|
|
|
@@ -141,6 +152,7 @@ async function runCommand(args: Record<string, string | boolean>, positional: st
|
|
|
141
152
|
verbose,
|
|
142
153
|
pollIntervalMs,
|
|
143
154
|
useWebSocket,
|
|
155
|
+
keepAwake,
|
|
144
156
|
initialState: Object.keys(initialState).length > 0 ? initialState : undefined,
|
|
145
157
|
hooksToken: envHooksToken,
|
|
146
158
|
gatewayPort: envGatewayPort && !isNaN(envGatewayPort) ? envGatewayPort : undefined,
|
|
@@ -288,9 +300,83 @@ function stopCommand(positional: string[]) {
|
|
|
288
300
|
console.log(`Unregistered: ${id} (will not restart on next gateway start)`);
|
|
289
301
|
}
|
|
290
302
|
|
|
291
|
-
function cleanCommand() {
|
|
303
|
+
async function cleanCommand() {
|
|
292
304
|
cleanRegistry();
|
|
293
305
|
console.log('Cleaned stale entries from registry');
|
|
306
|
+
|
|
307
|
+
// Also reconcile the audit DB so dead processes whose rows still say
|
|
308
|
+
// 'running' get marked 'stopped'. Without this, the dashboard keeps showing
|
|
309
|
+
// 'stale' badges for automations the operator already cleaned out of the
|
|
310
|
+
// registry.
|
|
311
|
+
try {
|
|
312
|
+
const { prune } = await import('./prune.js');
|
|
313
|
+
const result = prune({ reconcileOnly: true });
|
|
314
|
+
if (result.reconciled > 0) {
|
|
315
|
+
console.log(`Reconciled ${result.reconciled} orphan run row${result.reconciled === 1 ? '' : 's'} in audit DB (status: running → stopped)`);
|
|
316
|
+
} else {
|
|
317
|
+
console.log('Audit DB already consistent');
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.warn(`Could not reconcile audit DB: ${err instanceof Error ? err.message : String(err)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function pruneCommand(args: Record<string, string | boolean>) {
|
|
325
|
+
const { fmtBytes, parseDuration, prune } = await import('./prune.js');
|
|
326
|
+
const olderThanRaw = args['older-than'];
|
|
327
|
+
const olderThanMs = typeof olderThanRaw === 'string' ? parseDuration(olderThanRaw) : undefined;
|
|
328
|
+
const statusesRaw = typeof args.status === 'string' ? args.status : undefined;
|
|
329
|
+
const statuses = statusesRaw
|
|
330
|
+
? new Set(statusesRaw.split(',').map((s) => s.trim()).filter(Boolean))
|
|
331
|
+
: undefined;
|
|
332
|
+
const keepLastRaw = args['keep-last'];
|
|
333
|
+
const keepLast = typeof keepLastRaw === 'string' ? Number(keepLastRaw)
|
|
334
|
+
: typeof keepLastRaw === 'number' ? keepLastRaw
|
|
335
|
+
: undefined;
|
|
336
|
+
if (keepLast !== undefined && (!Number.isFinite(keepLast) || keepLast < 0)) {
|
|
337
|
+
console.error('Error: --keep-last must be a non-negative integer');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const opts = {
|
|
342
|
+
olderThanMs,
|
|
343
|
+
statuses,
|
|
344
|
+
keepLastPerAutomation: keepLast,
|
|
345
|
+
all: args.all === true,
|
|
346
|
+
vacuum: args.vacuum === true,
|
|
347
|
+
dryRun: args.dry === true,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const result = prune(opts);
|
|
351
|
+
|
|
352
|
+
if (result.reconciled > 0) {
|
|
353
|
+
const verb = result.dryRun ? 'Would reconcile' : 'Reconciled';
|
|
354
|
+
console.log(`${verb} ${result.reconciled} orphan run row${result.reconciled === 1 ? '' : 's'} (running → stopped)`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const verb = result.dryRun ? 'Would delete' : 'Deleted';
|
|
358
|
+
if (result.candidateRunIds.length === 0) {
|
|
359
|
+
console.log('No runs matched pruning filters.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
console.log(`${verb} ${result.candidateRunIds.length} automation run${result.candidateRunIds.length === 1 ? '' : 's'}:`);
|
|
363
|
+
for (const id of result.candidateRunIds.slice(0, 25)) {
|
|
364
|
+
console.log(` · ${id}`);
|
|
365
|
+
}
|
|
366
|
+
if (result.candidateRunIds.length > 25) {
|
|
367
|
+
console.log(` … and ${result.candidateRunIds.length - 25} more`);
|
|
368
|
+
}
|
|
369
|
+
if (!result.dryRun) {
|
|
370
|
+
const totalChild = Object.values(result.deletedRows).reduce((a, b) => a + b, 0);
|
|
371
|
+
console.log(`Removed ${totalChild.toLocaleString()} child rows across ${Object.keys(result.deletedRows).length} tables`);
|
|
372
|
+
if (result.freedBytes > 0) {
|
|
373
|
+
console.log(`Reclaimed ~${fmtBytes(result.freedBytes)}${opts.vacuum ? ' (post-VACUUM)' : ''}`);
|
|
374
|
+
} else if (opts.vacuum) {
|
|
375
|
+
console.log('No disk reclaimed (VACUUM completed)');
|
|
376
|
+
} else {
|
|
377
|
+
console.log('Run again with --vacuum to reclaim disk space.');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
294
380
|
}
|
|
295
381
|
|
|
296
382
|
function reportCommand(rawArgs: string[]) {
|
|
@@ -364,7 +450,10 @@ async function main() {
|
|
|
364
450
|
statusCommand();
|
|
365
451
|
break;
|
|
366
452
|
case 'clean':
|
|
367
|
-
cleanCommand();
|
|
453
|
+
await cleanCommand();
|
|
454
|
+
break;
|
|
455
|
+
case 'prune':
|
|
456
|
+
await pruneCommand(args);
|
|
368
457
|
break;
|
|
369
458
|
case 'report':
|
|
370
459
|
reportCommand(restArgs);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
2
|
+
|
|
3
|
+
type KeepAwakeLogger = {
|
|
4
|
+
info(message: string): void;
|
|
5
|
+
warn(message: string): void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export interface KeepAwakeHandle {
|
|
9
|
+
backend: string;
|
|
10
|
+
stop(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createHandle(
|
|
14
|
+
child: ChildProcess,
|
|
15
|
+
backend: string,
|
|
16
|
+
log: KeepAwakeLogger,
|
|
17
|
+
): KeepAwakeHandle {
|
|
18
|
+
let stopping = false;
|
|
19
|
+
|
|
20
|
+
child.once('error', (error) => {
|
|
21
|
+
if (!stopping) {
|
|
22
|
+
log.warn(`keep-awake unavailable via ${backend}: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
child.once('exit', (code, signal) => {
|
|
27
|
+
if (!stopping) {
|
|
28
|
+
const suffix = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
|
|
29
|
+
log.warn(`keep-awake helper ${backend} exited unexpectedly (${suffix}); host may sleep.`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
backend,
|
|
35
|
+
stop() {
|
|
36
|
+
stopping = true;
|
|
37
|
+
if (!child.killed) child.kill();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startDarwin(log: KeepAwakeLogger): KeepAwakeHandle {
|
|
43
|
+
const child = spawn('caffeinate', ['-i', '-w', String(process.pid)], {
|
|
44
|
+
stdio: 'ignore',
|
|
45
|
+
});
|
|
46
|
+
return createHandle(child, 'caffeinate', log);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function startLinux(reason: string, log: KeepAwakeLogger): KeepAwakeHandle {
|
|
50
|
+
const child = spawn(
|
|
51
|
+
'systemd-inhibit',
|
|
52
|
+
[
|
|
53
|
+
'--what=sleep',
|
|
54
|
+
'--who=OpenBroker',
|
|
55
|
+
`--why=${reason}`,
|
|
56
|
+
'--mode=block',
|
|
57
|
+
'sh',
|
|
58
|
+
'-c',
|
|
59
|
+
'while kill -0 "$1" 2>/dev/null; do sleep 30; done',
|
|
60
|
+
'sh',
|
|
61
|
+
String(process.pid),
|
|
62
|
+
],
|
|
63
|
+
{ stdio: 'ignore' },
|
|
64
|
+
);
|
|
65
|
+
return createHandle(child, 'systemd-inhibit', log);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function startWindows(log: KeepAwakeLogger): KeepAwakeHandle {
|
|
69
|
+
const script = [
|
|
70
|
+
'Add-Type -Namespace OpenBroker -Name Native -MemberDefinition',
|
|
71
|
+
'\'"[DllImport(\\\"kernel32.dll\\\")] public static extern uint SetThreadExecutionState(uint esFlags);"\';',
|
|
72
|
+
'[OpenBroker.Native]::SetThreadExecutionState(0x80000001) | Out-Null;',
|
|
73
|
+
`Wait-Process -Id ${process.pid};`,
|
|
74
|
+
'[OpenBroker.Native]::SetThreadExecutionState(0x80000000) | Out-Null;',
|
|
75
|
+
].join(' ');
|
|
76
|
+
|
|
77
|
+
const child = spawn(
|
|
78
|
+
'powershell.exe',
|
|
79
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
80
|
+
{
|
|
81
|
+
stdio: 'ignore',
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
return createHandle(child, 'SetThreadExecutionState', log);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function startKeepAwake(reason: string, log: KeepAwakeLogger): KeepAwakeHandle | null {
|
|
89
|
+
switch (process.platform) {
|
|
90
|
+
case 'darwin':
|
|
91
|
+
return startDarwin(log);
|
|
92
|
+
case 'linux':
|
|
93
|
+
return startLinux(reason, log);
|
|
94
|
+
case 'win32':
|
|
95
|
+
return startWindows(log);
|
|
96
|
+
default:
|
|
97
|
+
log.warn(`keep-awake is not supported on platform ${process.platform}; host may sleep.`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|
package/scripts/auto/runtime.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { AutomationEventBus } from './events.js';
|
|
|
15
15
|
import { loadAutomation } from './loader.js';
|
|
16
16
|
import { registerAutomation, unregisterAutomation, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
|
|
17
17
|
import { createAutomationAudit, toSerializable, type AutomationAuditSink } from './audit.js';
|
|
18
|
+
import { startKeepAwake, type KeepAwakeHandle } from './keep-awake.js';
|
|
18
19
|
import type {
|
|
19
20
|
AutomationAPI,
|
|
20
21
|
AutomationAuditObserver,
|
|
@@ -288,6 +289,12 @@ function createAuditedClient(
|
|
|
288
289
|
async function buildSnapshot(
|
|
289
290
|
client: HyperliquidClient,
|
|
290
291
|
): Promise<AutomationSnapshot> {
|
|
292
|
+
// `metaAndAssetCtxs` contains both mostly-static market metadata and live
|
|
293
|
+
// funding/premium values. The client caches it for market lookups, so clear
|
|
294
|
+
// it before each automation poll snapshot or funding_update events freeze at
|
|
295
|
+
// the first fetched value.
|
|
296
|
+
client.invalidateMetaCache();
|
|
297
|
+
|
|
291
298
|
const [state, mids, metaCtxs] = await Promise.all([
|
|
292
299
|
client.getUserStateAll(),
|
|
293
300
|
client.getAllMids(),
|
|
@@ -321,20 +328,29 @@ async function buildSnapshot(
|
|
|
321
328
|
|
|
322
329
|
// Build funding rates from asset contexts
|
|
323
330
|
const fundingRates = new Map<string, { rate: number; premium: number }>();
|
|
331
|
+
const addFundingRates = (
|
|
332
|
+
universe: Array<{ name?: string }> | undefined,
|
|
333
|
+
assetCtxs: Array<{ funding?: string | number | null; premium?: string | number | null }> | undefined,
|
|
334
|
+
) => {
|
|
335
|
+
if (!universe || !assetCtxs) return;
|
|
336
|
+
for (let i = 0; i < universe.length; i++) {
|
|
337
|
+
const meta = universe[i];
|
|
338
|
+
const ctx = assetCtxs[i];
|
|
339
|
+
if (ctx && meta?.name) {
|
|
340
|
+
fundingRates.set(meta.name, {
|
|
341
|
+
rate: parseFloat(String(ctx.funding || '0')),
|
|
342
|
+
premium: parseFloat(String(ctx.premium || '0')),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
324
348
|
if (metaCtxs && Array.isArray(metaCtxs)) {
|
|
325
349
|
for (const group of metaCtxs) {
|
|
326
|
-
|
|
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
|
-
}
|
|
350
|
+
addFundingRates(group.universe, group.assetCtxs);
|
|
337
351
|
}
|
|
352
|
+
} else if (metaCtxs) {
|
|
353
|
+
addFundingRates(metaCtxs.meta?.universe, metaCtxs.assetCtxs);
|
|
338
354
|
}
|
|
339
355
|
|
|
340
356
|
return {
|
|
@@ -436,6 +452,11 @@ export interface RuntimeOptions {
|
|
|
436
452
|
* @default true
|
|
437
453
|
*/
|
|
438
454
|
useWebSocket?: boolean;
|
|
455
|
+
/**
|
|
456
|
+
* Best-effort host idle-sleep inhibition for the lifetime of the automation.
|
|
457
|
+
* Enabled by default for live runs and disabled by default for dry runs.
|
|
458
|
+
*/
|
|
459
|
+
keepAwake?: boolean;
|
|
439
460
|
}
|
|
440
461
|
|
|
441
462
|
/** Registry of all running automations */
|
|
@@ -501,6 +522,14 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
501
522
|
stateController.attachAudit(audit);
|
|
502
523
|
|
|
503
524
|
const log = createLogger(id, verbose, audit);
|
|
525
|
+
const keepAwakeEnabled = options.keepAwake ?? !dryRun;
|
|
526
|
+
let keepAwake: KeepAwakeHandle | null = null;
|
|
527
|
+
if (keepAwakeEnabled) {
|
|
528
|
+
keepAwake = startKeepAwake(`OpenBroker automation ${id} is running`, log);
|
|
529
|
+
if (keepAwake) {
|
|
530
|
+
log.info(`keep-awake enabled via ${keepAwake.backend}.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
504
533
|
const observers = await loadConventionObservers(log);
|
|
505
534
|
const baseClient = dryRun ? createDryClient(rawClient, log) : rawClient;
|
|
506
535
|
const client = createAuditedClient(baseClient, audit, dryRun, observers);
|
|
@@ -561,6 +590,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
561
590
|
pollCount: 0,
|
|
562
591
|
eventsEmitted: 0,
|
|
563
592
|
});
|
|
593
|
+
keepAwake?.stop();
|
|
564
594
|
throw error;
|
|
565
595
|
}
|
|
566
596
|
|
|
@@ -934,6 +964,8 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
934
964
|
}
|
|
935
965
|
}
|
|
936
966
|
|
|
967
|
+
keepAwake?.stop();
|
|
968
|
+
|
|
937
969
|
eventBus.removeAll();
|
|
938
970
|
registry.delete(id);
|
|
939
971
|
|
package/scripts/core/client.ts
CHANGED
|
@@ -1067,7 +1067,11 @@ export class HyperliquidClient {
|
|
|
1067
1067
|
|
|
1068
1068
|
const response = await fetch(baseUrl + '/info', {
|
|
1069
1069
|
method: 'POST',
|
|
1070
|
-
headers: {
|
|
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<[
|