dexterm-cli 0.1.7 → 0.2.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/dist/packages/agents/src/spectator.js +3 -1
- package/dist/packages/agents/src/tiers.js +9 -0
- package/dist/packages/agents/src/tools.js +10 -5
- package/dist/packages/autotrade/assets/limits.schema.json +569 -0
- package/dist/packages/autotrade/examples/executor/README.md +70 -0
- package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
- package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
- package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
- package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
- package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
- package/dist/packages/autotrade/src/backtest.js +2 -1
- package/dist/packages/autotrade/src/chat/config.js +4 -2
- package/dist/packages/autotrade/src/chat/control.js +1 -0
- package/dist/packages/autotrade/src/chat/notify.js +16 -6
- package/dist/packages/autotrade/src/chat/secrets.js +25 -18
- package/dist/packages/autotrade/src/chat/service.js +3 -0
- package/dist/packages/autotrade/src/engine.js +16 -2
- package/dist/packages/autotrade/src/executor-health.js +217 -0
- package/dist/packages/autotrade/src/fleet.js +3 -0
- package/dist/packages/autotrade/src/forward.js +355 -0
- package/dist/packages/autotrade/src/index.js +19 -0
- package/dist/packages/autotrade/src/journey.js +477 -0
- package/dist/packages/autotrade/src/limits.js +682 -0
- package/dist/packages/autotrade/src/outbox.js +394 -0
- package/dist/packages/autotrade/src/paper-limits.js +87 -0
- package/dist/packages/autotrade/src/real-mode.js +688 -0
- package/dist/packages/autotrade/src/receipts.js +330 -0
- package/dist/packages/autotrade/src/report.js +168 -0
- package/dist/packages/autotrade/src/runner.js +62 -11
- package/dist/packages/autotrade/src/service-guard.js +125 -0
- package/dist/packages/autotrade/src/service-install.js +213 -0
- package/dist/packages/autotrade/src/service-recover.js +232 -0
- package/dist/packages/autotrade/src/service.js +289 -0
- package/dist/packages/autotrade/src/signal-channels.js +312 -0
- package/dist/packages/autotrade/src/signal.js +196 -0
- package/dist/packages/autotrade/src/strategy.js +3 -1
- package/dist/packages/autotrade/src/trade-ledger.js +2 -2
- package/dist/packages/cli/src/analyst/backend.js +3 -2
- package/dist/packages/cli/src/analyst/codex.js +3 -2
- package/dist/packages/cli/src/app-help-gen.js +14 -2
- package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
- package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
- package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +8 -4
- package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
- package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
- package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
- package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
- package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
- package/dist/packages/cli/src/autotrade-cmd/run.js +257 -13
- package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
- package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
- package/dist/packages/cli/src/autotrade-cmd.js +50 -4
- package/dist/packages/cli/src/ledger-cmd.js +4 -2
- package/dist/packages/cli/src/pnl-card.js +4 -0
- package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
- package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
- package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
- package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
- package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
- package/dist/packages/cli/src/telegram-cmd.js +4 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/data/src/trade/ledger/schema.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
- package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +57 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
- package/dist/packages/mcp/src/autotrade-tools.js +168 -5
- package/dist/packages/mcp/src/engine/restrictions.js +3 -3
- package/dist/packages/mcp/src/index.js +1 -1
- package/dist/packages/mcp/src/ledger-tools.js +155 -7
- package/dist/packages/mcp/src/server.js +21 -10
- package/dist/packages/tui/src/keymap.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// dexterm autotrade — the always-on service's guard aggregator (0.2.0 card B, AUTOTRADE-SPEC §7.3 / §7.4).
|
|
2
|
+
//
|
|
3
|
+
// Every 10 s (and on demand) the service folds what it can see into named guard states. Each state has ONE effect:
|
|
4
|
+
// stop the service stops the fleet (a soft stop: no new entries, sessions closed, exits keep running)
|
|
5
|
+
// pause-entries no new entries while it holds; lifts by itself (hysteresis where a value can flap)
|
|
6
|
+
// notice shown in `service status` and sent once as a notification; nothing pauses
|
|
7
|
+
// Open positions are never paused by any state: their exits keep running (the monitor is never paused, §7.4).
|
|
8
|
+
//
|
|
9
|
+
// state id effect from
|
|
10
|
+
// stop-file stop the service STOP marker (`service stop`, a plain `touch`)
|
|
11
|
+
// panic stop the panic file (`dexterm wallet panic`); the fleet halts itself too
|
|
12
|
+
// disk-low pause-entries free space under runner.json disk.minFreeGb (default 5 GB); +1 GB to lift
|
|
13
|
+
// load-high pause-entries 1-min load above load.pauseAboveLoad1m for load.sustainSec; lifts below resumeBelowLoad1m
|
|
14
|
+
// busy-process pause-entries a runner.json load.busyProcs pattern matches a running process (not ours)
|
|
15
|
+
// data-stale pause-entries no strategy has seen a market tick for dataStaleSec (default 300 s)
|
|
16
|
+
// runner-stale notice the fleet's heartbeat (runner-status.json) is older than 30 s
|
|
17
|
+
// allowance-high notice the model usage windows are above runner.json allowanceGuard (the fleet pauses sessions itself)
|
|
18
|
+
// day-cap notice the fleet's global day caps are used up (sessions resume when the day rolls)
|
|
19
|
+
// recorder-down notice the market recorder is installed but has written nothing for recorderStaleSec
|
|
20
|
+
// (default 15 min); not installed → notice with `dexterm record install`
|
|
21
|
+
// strategy-halted notice a strategy halted itself (daily loss / drawdown caps); one per strategy
|
|
22
|
+
//
|
|
23
|
+
// The guard is pure over its inputs (the service collects them); transitions come back so the caller can log one
|
|
24
|
+
// ledger line and send one notification per change, never one per tick.
|
|
25
|
+
export const DEFAULT_GUARD_CONFIG = {
|
|
26
|
+
minFreeGb: 5, pauseAboveLoad1m: 25, resumeBelowLoad1m: 20, sustainSec: 120, busyProcs: [],
|
|
27
|
+
dataStaleSec: 300, recorderStaleSec: 900, fiveHourMax: 0.6, sevenDayMax: 0.8,
|
|
28
|
+
};
|
|
29
|
+
/** runner.json → the guard's knobs (the stricter of the two never matters here: these only pause). */
|
|
30
|
+
export function guardConfigFrom(rc, over = {}) {
|
|
31
|
+
return {
|
|
32
|
+
...DEFAULT_GUARD_CONFIG,
|
|
33
|
+
...(rc?.load ? { pauseAboveLoad1m: rc.load.pauseAboveLoad1m, resumeBelowLoad1m: rc.load.resumeBelowLoad1m, sustainSec: rc.load.sustainSec, busyProcs: [...rc.load.busyProcs] } : {}),
|
|
34
|
+
...(rc?.disk ? { minFreeGb: rc.disk.minFreeGb } : {}),
|
|
35
|
+
...(rc?.allowanceGuard ? { fiveHourMax: rc.allowanceGuard.fiveHourMax, sevenDayMax: rc.allowanceGuard.sevenDayMax } : {}),
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const gb = (n) => `${n.toFixed(1)} GB`;
|
|
40
|
+
export class ServiceGuard {
|
|
41
|
+
cfg;
|
|
42
|
+
active = new Map();
|
|
43
|
+
loadAboveSince = null;
|
|
44
|
+
loadPaused = false;
|
|
45
|
+
diskLow = false;
|
|
46
|
+
constructor(cfg = DEFAULT_GUARD_CONFIG) { this.cfg = cfg; }
|
|
47
|
+
evaluate(i) {
|
|
48
|
+
const c = this.cfg;
|
|
49
|
+
const want = new Map();
|
|
50
|
+
const on = (id, effect, detail) => { want.set(id, { effect, detail }); };
|
|
51
|
+
if (i.stopFile)
|
|
52
|
+
on('stop-file', 'stop', i.stopFile);
|
|
53
|
+
if (i.panic)
|
|
54
|
+
on('panic', 'stop', `the panic switch is engaged (${i.panic}); \`dexterm wallet resume\` lifts it`);
|
|
55
|
+
if (i.freeGb !== null) {
|
|
56
|
+
if (!this.diskLow && i.freeGb < c.minFreeGb)
|
|
57
|
+
this.diskLow = true;
|
|
58
|
+
else if (this.diskLow && i.freeGb >= c.minFreeGb + 1)
|
|
59
|
+
this.diskLow = false;
|
|
60
|
+
if (this.diskLow)
|
|
61
|
+
on('disk-low', 'pause-entries', `${gb(i.freeGb)} free, under the ${gb(c.minFreeGb)} floor (runner.json disk.minFreeGb); lifts at ${gb(c.minFreeGb + 1)}`);
|
|
62
|
+
}
|
|
63
|
+
if (i.load1m !== null) {
|
|
64
|
+
if (i.load1m > c.pauseAboveLoad1m) {
|
|
65
|
+
this.loadAboveSince ??= i.now;
|
|
66
|
+
if (i.now - this.loadAboveSince >= c.sustainSec * 1000)
|
|
67
|
+
this.loadPaused = true;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.loadAboveSince = null;
|
|
71
|
+
if (i.load1m < c.resumeBelowLoad1m)
|
|
72
|
+
this.loadPaused = false;
|
|
73
|
+
}
|
|
74
|
+
if (this.loadPaused)
|
|
75
|
+
on('load-high', 'pause-entries', `1-min load ${i.load1m.toFixed(1)} (pauses above ${c.pauseAboveLoad1m} for ${c.sustainSec} s, resumes below ${c.resumeBelowLoad1m})`);
|
|
76
|
+
}
|
|
77
|
+
if (i.busy.length)
|
|
78
|
+
on('busy-process', 'pause-entries', `a busy process is running (${i.busy.slice(0, 3).join(', ')})`);
|
|
79
|
+
if (i.upMs >= c.dataStaleSec * 1000 && (i.dataAgeMs === null || i.dataAgeMs > c.dataStaleSec * 1000)) {
|
|
80
|
+
on('data-stale', 'pause-entries', i.dataAgeMs === null ? `no market tick since the start (${Math.round(i.upMs / 1000)} s)` : `the newest market tick is ${Math.round(i.dataAgeMs / 1000)} s old (limit ${c.dataStaleSec} s)`);
|
|
81
|
+
}
|
|
82
|
+
if (i.runnerAgeMs !== null && i.runnerAgeMs > 30_000)
|
|
83
|
+
on('runner-stale', 'notice', `the strategies' heartbeat is ${Math.round(i.runnerAgeMs / 1000)} s old`);
|
|
84
|
+
const a = i.allowance;
|
|
85
|
+
if (a && ((a.fiveHour ?? 0) > c.fiveHourMax || (a.sevenDay ?? 0) > c.sevenDayMax)) {
|
|
86
|
+
on('allowance-high', 'notice', `model usage 5 h ${pctOf(a.fiveHour)} / 7 d ${pctOf(a.sevenDay)} (limits ${pctOf(c.fiveHourMax)} / ${pctOf(c.sevenDayMax)}): no new sessions until it falls`);
|
|
87
|
+
}
|
|
88
|
+
if (i.dayCap)
|
|
89
|
+
on('day-cap', 'notice', `${i.dayCap}: no new sessions until the day rolls`);
|
|
90
|
+
if (i.recorder) {
|
|
91
|
+
if (!i.recorder.installed)
|
|
92
|
+
on('recorder-down', 'notice', 'the market recorder is not installed: backtests and tuning see no new history (`dexterm record install`)');
|
|
93
|
+
else if (i.recorder.newestAgeMs === null || i.recorder.newestAgeMs > c.recorderStaleSec * 1000)
|
|
94
|
+
on('recorder-down', 'notice', `the market recorder has written nothing for ${i.recorder.newestAgeMs === null ? 'ever' : `${Math.round(i.recorder.newestAgeMs / 60_000)} min`} (\`dexterm record status\`)`);
|
|
95
|
+
}
|
|
96
|
+
for (const [id, why] of Object.entries(i.halted))
|
|
97
|
+
on(`strategy-halted:${id}`, 'notice', `strategy ${id} halted itself (${why}); its open positions keep their exits`);
|
|
98
|
+
const transitions = [];
|
|
99
|
+
for (const [id, w] of want) {
|
|
100
|
+
const cur = this.active.get(id);
|
|
101
|
+
if (!cur) {
|
|
102
|
+
this.active.set(id, { id, effect: w.effect, detail: w.detail, since: i.now });
|
|
103
|
+
transitions.push({ id, on: true, effect: w.effect, detail: w.detail });
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
cur.detail = w.detail;
|
|
107
|
+
}
|
|
108
|
+
for (const [id, cur] of [...this.active]) {
|
|
109
|
+
if (want.has(id))
|
|
110
|
+
continue;
|
|
111
|
+
this.active.delete(id);
|
|
112
|
+
transitions.push({ id, on: false, effect: cur.effect, detail: cur.detail });
|
|
113
|
+
}
|
|
114
|
+
const states = [...this.active.values()].map((s) => ({ ...s }));
|
|
115
|
+
const stop = states.find((s) => s.effect === 'stop')?.id ?? null;
|
|
116
|
+
const pause = states.find((s) => s.effect === 'pause-entries')?.id ?? null;
|
|
117
|
+
return { states, stop, pauseEntries: pause ? `guard:${pause}` : null, transitions };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const pctOf = (v) => (v === null ? '?' : `${Math.round(v * 100)}%`);
|
|
121
|
+
/** A one-line notification text for a guard transition (plain text, no paths, no secrets). */
|
|
122
|
+
export function guardNoticeText(t) {
|
|
123
|
+
const what = t.effect === 'stop' ? 'STOP' : t.effect === 'pause-entries' ? 'ENTRIES PAUSED' : 'NOTICE';
|
|
124
|
+
return t.on ? `autotrade service · ${what} · ${t.id}: ${t.detail}` : `autotrade service · cleared · ${t.id}${t.effect === 'pause-entries' ? ': entries resume' : ''}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// dexterm autotrade — the always-on service's OS installers (0.2.0 card B, AUTOTRADE-SPEC §7.4).
|
|
2
|
+
//
|
|
3
|
+
// Every platform runs the same thing: `dexterm autotrade service ensure` once a minute and at login. `ensure` exits at
|
|
4
|
+
// once while the service's heartbeat is fresh and otherwise starts it detached, so a crash costs at most one tick and
|
|
5
|
+
// a clean stop stays stopped. The generators are pure (golden-file tested); the CLI shows the user every file and
|
|
6
|
+
// command before anything is written, and records what it wrote (install manifest) so uninstall removes exactly that.
|
|
7
|
+
//
|
|
8
|
+
// macOS ~/Library/LaunchAgents/<label>.plist: StartInterval 60, RunAtLoad, ProcessType Background, Nice 10,
|
|
9
|
+
// LowPriorityIO, AbandonProcessGroup (the started service outlives the tick). NO KeepAlive and never
|
|
10
|
+
// `launchctl submit` (a KeepAlive job relaunches forever). Loaded with `launchctl bootout` + `bootstrap`:
|
|
11
|
+
// `kickstart -k` does NOT re-read the plist's arguments.
|
|
12
|
+
// Linux ~/.config/systemd/user/<unit>.service (oneshot ensure, Nice 10, idle IO, KillMode=process so the started
|
|
13
|
+
// service survives the tick) + <unit>.timer (30 s after login, then every 60 s). Without
|
|
14
|
+
// `loginctl enable-linger` a user unit runs only while the user is logged in.
|
|
15
|
+
// Windows a scheduled task (XML for `schtasks /Create /XML`): at logon + every minute, ignore-new on overlap,
|
|
16
|
+
// below-normal priority, least privilege, 5-minute limit for the tick itself.
|
|
17
|
+
//
|
|
18
|
+
// One label per DEXTERM_HOME: the default home gets `xyz.dexterm.autotrade`; any other home gets a suffix from a hash
|
|
19
|
+
// of its path, so a test home never replaces (or removes) the real one. A non-default home is passed as `--home`.
|
|
20
|
+
// Only PATH is copied into the job's environment (the service needs to find `claude`); nothing else, no secrets.
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
export const SERVICE_LABEL = 'xyz.dexterm.autotrade';
|
|
24
|
+
export const INSTALL_MANIFEST_SCHEMA = 'dexterm-autotrade-service-install/1';
|
|
25
|
+
/** The label for a DEXTERM_HOME: the default one, or one suffixed by a hash of the path. */
|
|
26
|
+
export function serviceLabel(dexHome, defaultHome) {
|
|
27
|
+
if (dexHome === defaultHome)
|
|
28
|
+
return SERVICE_LABEL;
|
|
29
|
+
return `${SERVICE_LABEL}.h${createHash('sha256').update(dexHome).digest('hex').slice(0, 8)}`;
|
|
30
|
+
}
|
|
31
|
+
/** The tick's command line (`--quiet`: a tick that changes nothing prints nothing, so the tick log stays small). */
|
|
32
|
+
export const ensureArgs = (s) => [s.nodePath, s.binPath, 'autotrade', 'service', 'ensure', '--quiet', ...(s.dexHome ? ['--home', s.dexHome] : [])];
|
|
33
|
+
// ───────────────────────────── macOS ─────────────────────────────
|
|
34
|
+
const xml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
35
|
+
export const launchAgentPath = (s) => join(s.userHome, 'Library', 'LaunchAgents', `${s.label}.plist`);
|
|
36
|
+
export function renderLaunchAgent(s) {
|
|
37
|
+
const args = ensureArgs(s).map((a) => ` <string>${xml(a)}</string>`).join('\n');
|
|
38
|
+
const env = s.path ? ` <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${xml(s.path)}</string>\n </dict>\n` : '';
|
|
39
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key>
|
|
44
|
+
<string>${xml(s.label)}</string>
|
|
45
|
+
<key>ProgramArguments</key>
|
|
46
|
+
<array>
|
|
47
|
+
${args}
|
|
48
|
+
</array>
|
|
49
|
+
<key>WorkingDirectory</key>
|
|
50
|
+
<string>${xml(s.workingDir)}</string>
|
|
51
|
+
${env} <key>StartInterval</key>
|
|
52
|
+
<integer>60</integer>
|
|
53
|
+
<key>RunAtLoad</key>
|
|
54
|
+
<true/>
|
|
55
|
+
<key>ProcessType</key>
|
|
56
|
+
<string>Background</string>
|
|
57
|
+
<key>Nice</key>
|
|
58
|
+
<integer>10</integer>
|
|
59
|
+
<key>LowPriorityIO</key>
|
|
60
|
+
<true/>
|
|
61
|
+
<key>AbandonProcessGroup</key>
|
|
62
|
+
<true/>
|
|
63
|
+
<key>StandardOutPath</key>
|
|
64
|
+
<string>${xml(s.tickLog)}</string>
|
|
65
|
+
<key>StandardErrorPath</key>
|
|
66
|
+
<string>${xml(s.tickLog)}</string>
|
|
67
|
+
</dict>
|
|
68
|
+
</plist>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
// ───────────────────────────── Linux (systemd user) ─────────────────────────────
|
|
72
|
+
export const systemdUnit = (s) => s.label.replace(/^xyz\./, '').replace(/\./g, '-');
|
|
73
|
+
export const systemdDir = (s) => join(s.userHome, '.config', 'systemd', 'user');
|
|
74
|
+
/** systemd quoting: double quotes, backslash and quote escaped, `%` doubled (specifier expansion). */
|
|
75
|
+
const sd = (a) => `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
|
|
76
|
+
export function renderSystemdService(s) {
|
|
77
|
+
return `[Unit]
|
|
78
|
+
Description=dexterm autotrade service: start it when it is not running (one tick)
|
|
79
|
+
|
|
80
|
+
[Service]
|
|
81
|
+
Type=oneshot
|
|
82
|
+
ExecStart=${ensureArgs(s).map(sd).join(' ')}
|
|
83
|
+
WorkingDirectory=${s.workingDir.replace(/%/g, '%%')}
|
|
84
|
+
${s.path ? `Environment=${sd(`PATH=${s.path}`)}\n` : ''}Nice=10
|
|
85
|
+
IOSchedulingClass=idle
|
|
86
|
+
KillMode=process
|
|
87
|
+
StandardOutput=append:${s.tickLog.replace(/%/g, '%%')}
|
|
88
|
+
StandardError=append:${s.tickLog.replace(/%/g, '%%')}
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
export function renderSystemdTimer(s) {
|
|
92
|
+
return `[Unit]
|
|
93
|
+
Description=dexterm autotrade service tick (every minute)
|
|
94
|
+
|
|
95
|
+
[Timer]
|
|
96
|
+
OnStartupSec=30
|
|
97
|
+
OnUnitActiveSec=60
|
|
98
|
+
AccuracySec=10
|
|
99
|
+
Unit=${systemdUnit(s)}.service
|
|
100
|
+
|
|
101
|
+
[Install]
|
|
102
|
+
WantedBy=timers.target
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
// ───────────────────────────── Windows (scheduled task) ─────────────────────────────
|
|
106
|
+
export const windowsTaskName = (s) => `\\dexterm\\${s.label.replace(/^xyz\.dexterm\./, '')}`;
|
|
107
|
+
export const windowsTaskFile = (dexHome, s) => join(dexHome, 'autotrade', 'service', `${s.label}.task.xml`);
|
|
108
|
+
/** Windows command-line quoting for one argument (CommandLineToArgvW rules). */
|
|
109
|
+
export function winArg(a) {
|
|
110
|
+
if (a && !/[\s"]/.test(a))
|
|
111
|
+
return a;
|
|
112
|
+
let out = '"';
|
|
113
|
+
let bs = 0;
|
|
114
|
+
for (const ch of a) {
|
|
115
|
+
if (ch === '\\') {
|
|
116
|
+
bs++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (ch === '"') {
|
|
120
|
+
out += '\\'.repeat(bs * 2 + 1) + '"';
|
|
121
|
+
bs = 0;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
out += '\\'.repeat(bs) + ch;
|
|
125
|
+
bs = 0;
|
|
126
|
+
}
|
|
127
|
+
return `${out}${'\\'.repeat(bs * 2)}"`;
|
|
128
|
+
}
|
|
129
|
+
export function renderWindowsTask(s) {
|
|
130
|
+
const [cmd, ...rest] = ensureArgs(s);
|
|
131
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
132
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
133
|
+
<RegistrationInfo>
|
|
134
|
+
<Description>dexterm autotrade service: start it when it is not running (one tick a minute)</Description>
|
|
135
|
+
</RegistrationInfo>
|
|
136
|
+
<Triggers>
|
|
137
|
+
<LogonTrigger>
|
|
138
|
+
<Enabled>true</Enabled>
|
|
139
|
+
</LogonTrigger>
|
|
140
|
+
<TimeTrigger>
|
|
141
|
+
<Repetition>
|
|
142
|
+
<Interval>PT1M</Interval>
|
|
143
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
144
|
+
</Repetition>
|
|
145
|
+
<StartBoundary>2026-01-01T00:00:00</StartBoundary>
|
|
146
|
+
<Enabled>true</Enabled>
|
|
147
|
+
</TimeTrigger>
|
|
148
|
+
</Triggers>
|
|
149
|
+
<Principals>
|
|
150
|
+
<Principal id="Author">
|
|
151
|
+
<LogonType>InteractiveToken</LogonType>
|
|
152
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
153
|
+
</Principal>
|
|
154
|
+
</Principals>
|
|
155
|
+
<Settings>
|
|
156
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
157
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
158
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
159
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
160
|
+
<ExecutionTimeLimit>PT5M</ExecutionTimeLimit>
|
|
161
|
+
<Priority>7</Priority>
|
|
162
|
+
<Enabled>true</Enabled>
|
|
163
|
+
</Settings>
|
|
164
|
+
<Actions Context="Author">
|
|
165
|
+
<Exec>
|
|
166
|
+
<Command>${xml(cmd)}</Command>
|
|
167
|
+
<Arguments>${xml(rest.map(winArg).join(' '))}</Arguments>
|
|
168
|
+
<WorkingDirectory>${xml(s.workingDir)}</WorkingDirectory>
|
|
169
|
+
</Exec>
|
|
170
|
+
</Actions>
|
|
171
|
+
</Task>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
// ───────────────────────────── the plan ─────────────────────────────
|
|
175
|
+
export function installPlan(s, dexHome) {
|
|
176
|
+
if (s.platform === 'darwin') {
|
|
177
|
+
const path = launchAgentPath(s);
|
|
178
|
+
const domain = `gui/${s.uid}`;
|
|
179
|
+
return {
|
|
180
|
+
platform: s.platform, label: s.label, name: s.label,
|
|
181
|
+
files: [{ path, content: renderLaunchAgent(s) }],
|
|
182
|
+
activate: [['launchctl', 'bootout', `${domain}/${s.label}`], ['launchctl', 'bootstrap', domain, path]],
|
|
183
|
+
deactivate: [['launchctl', 'bootout', `${domain}/${s.label}`]],
|
|
184
|
+
notes: ['A LaunchAgent starts at login: the service runs while you are logged in on this Mac.', 'To change it later: edit nothing by hand; run `dexterm autotrade service install --load` again (bootout + bootstrap; `kickstart -k` would keep the old arguments).'],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (s.platform === 'linux') {
|
|
188
|
+
const unit = systemdUnit(s);
|
|
189
|
+
const dir = systemdDir(s);
|
|
190
|
+
return {
|
|
191
|
+
platform: s.platform, label: s.label, name: `${unit}.timer`,
|
|
192
|
+
files: [{ path: join(dir, `${unit}.service`), content: renderSystemdService(s) }, { path: join(dir, `${unit}.timer`), content: renderSystemdTimer(s) }],
|
|
193
|
+
activate: [['systemctl', '--user', 'daemon-reload'], ['systemctl', '--user', 'enable', '--now', `${unit}.timer`]],
|
|
194
|
+
deactivate: [['systemctl', '--user', 'disable', '--now', `${unit}.timer`]],
|
|
195
|
+
notes: ['A systemd user timer runs while you are logged in; `loginctl enable-linger` (your choice) keeps it running after logout and from boot.'],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const file = windowsTaskFile(dexHome, s);
|
|
199
|
+
const name = windowsTaskName(s);
|
|
200
|
+
return {
|
|
201
|
+
platform: s.platform, label: s.label, name,
|
|
202
|
+
files: [{ path: file, content: renderWindowsTask(s), encoding: 'utf16le' }],
|
|
203
|
+
activate: [['schtasks', '/Create', '/TN', name, '/XML', file, '/F']],
|
|
204
|
+
deactivate: [['schtasks', '/Delete', '/TN', name, '/F']],
|
|
205
|
+
notes: ['The task runs at logon and every minute while you are logged in.'],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export const fileSha = (content, encoding = 'utf8') => createHash('sha256').update(encode(content, encoding)).digest('hex');
|
|
209
|
+
/** The bytes written for a file (UTF-16 LE with a BOM for the Windows task XML). */
|
|
210
|
+
export function encode(content, encoding = 'utf8') {
|
|
211
|
+
return encoding === 'utf16le' ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')]) : Buffer.from(content, 'utf8');
|
|
212
|
+
}
|
|
213
|
+
export const installManifestPath = (dexHome) => join(dexHome, 'autotrade', 'service', 'install.json');
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// dexterm autotrade — safe resume after a crash or a reboot (0.2.0 card B, AUTOTRADE-SPEC §7.4 "Restart safety").
|
|
2
|
+
//
|
|
3
|
+
// The service records the fleet it hosts in service/state.json. When the next start finds that fleet without a
|
|
4
|
+
// `fleet_end` (a crash, a kill -9, a power cut), recoverFleet() reads the fleet ledger for each strategy's runs and,
|
|
5
|
+
// for every member run without a `run_end`:
|
|
6
|
+
// - checks its hash chain (a torn last line from a crash mid-write is dropped first and reported);
|
|
7
|
+
// - rebuilds the paper positions still open from the run's own events: `position_adopted` snapshots (an earlier
|
|
8
|
+
// resume) plus every `fill` of that position, minus the positions a `trade_closed` closed;
|
|
9
|
+
// - collects every coin the run took (any buy, any adopted position, an earlier resume's taken list).
|
|
10
|
+
// The new run of the same strategy ADOPTS those positions (their exits keep running: stops, take-profits, trails,
|
|
11
|
+
// max hold from the original open time) and REFUSES a new entry on every taken coin (vet rule
|
|
12
|
+
// `service-resume-taken`), so a restart never buys a coin twice.
|
|
13
|
+
//
|
|
14
|
+
// Conservative choices, all in the direction of selling sooner and buying never:
|
|
15
|
+
// - an order still pending at the crash had no fill event, so it never happened: it is never re-sent;
|
|
16
|
+
// - an adopted position keeps no unfilled ladder tranche (no add after a restart) and its peak is the highest
|
|
17
|
+
// fill price (a trail measures from at most that);
|
|
18
|
+
// - a run whose chain is broken (an edited ledger) adopts nothing; its coins still count as taken.
|
|
19
|
+
// Mode-agnostic: a paper position is adopted into the paper book; a real-mode position (card A) is the user's
|
|
20
|
+
// executor's, and the same taken list keeps the next run from firing a second entry signal for it.
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { runFiles, verifyChain } from "./ledger.js";
|
|
23
|
+
export const RESUME_TAKEN_RULE = 'service-resume-taken';
|
|
24
|
+
/** Read a ledger file tolerating a torn final line (a crash mid-append); any other bad line is an error. */
|
|
25
|
+
export function readLedgerTolerant(path) {
|
|
26
|
+
const lines = readFileSync(path, 'utf8').split('\n');
|
|
27
|
+
const events = [];
|
|
28
|
+
let torn = false;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const line = lines[i];
|
|
31
|
+
if (!line.trim())
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
events.push(JSON.parse(line));
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
const rest = lines.slice(i + 1).some((l) => l.trim());
|
|
38
|
+
if (rest)
|
|
39
|
+
throw new Error(`line ${i + 1} is not JSON: ${e.message}`);
|
|
40
|
+
torn = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { events, torn };
|
|
44
|
+
}
|
|
45
|
+
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
|
46
|
+
/** A placeholder tick at the last known price, until the live feed sends a real one (focus marks the coin held). */
|
|
47
|
+
export function placeholderTick(mint, symbol, t, price) {
|
|
48
|
+
return {
|
|
49
|
+
mint, t, kind: 'live', symbol, launchpad: 'unknown', createdAt: null, price, mcap: null, mcapResolved: null, mcapFlagged: false,
|
|
50
|
+
liq: null, vol5m: null, vol1h: null, vol24: null, buys5m: null, sells5m: null, buys24: null, sells24: null,
|
|
51
|
+
chg5m: null, chg1h: null, chg6h: null, chg24h: null, holders: null, top10: null, dev: null, mintAuth: null, freezeAuth: null, org: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Open positions and taken coins from ONE run's events (pure; the run is known to have no run_end). */
|
|
55
|
+
export function positionsFromEvents(events) {
|
|
56
|
+
const symbols = new Map();
|
|
57
|
+
const base = new Map();
|
|
58
|
+
const fills = new Map();
|
|
59
|
+
const closed = new Set();
|
|
60
|
+
const taken = new Set();
|
|
61
|
+
for (const e of events) {
|
|
62
|
+
const mint = typeof e.mint === 'string' ? e.mint : null;
|
|
63
|
+
if (e.kind === 'candidate' && mint && typeof e.symbol === 'string')
|
|
64
|
+
symbols.set(mint, e.symbol);
|
|
65
|
+
else if (e.kind === 'position_adopted' && e.position && typeof e.position === 'object') {
|
|
66
|
+
const p = e.position;
|
|
67
|
+
if (typeof p.id === 'string' && typeof p.mint === 'string') {
|
|
68
|
+
base.set(p.id, structuredClone(p));
|
|
69
|
+
taken.add(p.mint);
|
|
70
|
+
symbols.set(p.mint, p.symbol);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (e.kind === 'fill' && typeof e.positionId === 'string' && mint) {
|
|
74
|
+
const f = { t: num(e.t), side: e.side === 'sell' ? 'sell' : 'buy', usd: num(e.usd), tokens: num(e.tokens), price: num(e.price), avgPrice: num(e.avgPrice, num(e.price)), impactPct: num(e.impactPct), partial: !!e.partial, reason: String(e.reason ?? '') };
|
|
75
|
+
const cur = fills.get(e.positionId) ?? { mint, profile: null, fills: [] };
|
|
76
|
+
if (typeof e.profile === 'string')
|
|
77
|
+
cur.profile ??= e.profile;
|
|
78
|
+
cur.fills.push(f);
|
|
79
|
+
fills.set(e.positionId, cur);
|
|
80
|
+
if (f.side === 'buy')
|
|
81
|
+
taken.add(mint);
|
|
82
|
+
}
|
|
83
|
+
else if (e.kind === 'trade_closed' && e.trade && typeof e.trade === 'object') {
|
|
84
|
+
const t = e.trade;
|
|
85
|
+
if (typeof t.id === 'string')
|
|
86
|
+
closed.add(t.id);
|
|
87
|
+
if (typeof t.mint === 'string' && typeof t.symbol === 'string')
|
|
88
|
+
symbols.set(t.mint, t.symbol);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
const ids = new Set([...base.keys(), ...fills.keys()]);
|
|
93
|
+
for (const id of ids) {
|
|
94
|
+
if (closed.has(id))
|
|
95
|
+
continue;
|
|
96
|
+
const b = base.get(id);
|
|
97
|
+
const f = fills.get(id);
|
|
98
|
+
const mint = b?.mint ?? f.mint;
|
|
99
|
+
const symbol = symbols.get(mint) ?? b?.symbol ?? `${mint.slice(0, 4)}…`;
|
|
100
|
+
const p = b ?? {
|
|
101
|
+
id, mint, symbol, profileId: f?.profile ?? 'default', openedAt: f.fills[0].t, sessionId: null,
|
|
102
|
+
tokens: 0, origTokens: 0, costUsd: 0, realizedUsd: 0, entryPrice: 0, peakPrice: 0, tpFilled: 0, trailArmed: false,
|
|
103
|
+
ladder: [], fills: [], status: 'open', stuck: false, closedAt: null, exitReason: null, liqWindow: [], lowTapeSince: null,
|
|
104
|
+
lastTick: placeholderTick(mint, symbol, f.fills[0].t, f.fills[0].price), wakeSent: {},
|
|
105
|
+
};
|
|
106
|
+
for (const x of f?.fills ?? []) {
|
|
107
|
+
if (x.side === 'buy') {
|
|
108
|
+
const tokens = p.tokens + x.tokens;
|
|
109
|
+
p.entryPrice = tokens > 0 ? (p.entryPrice * p.tokens + x.price * x.tokens) / tokens : x.price;
|
|
110
|
+
p.tokens = tokens;
|
|
111
|
+
p.origTokens += x.tokens;
|
|
112
|
+
p.costUsd += x.usd;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
p.tokens = Math.max(0, p.tokens - x.tokens);
|
|
116
|
+
p.realizedUsd += x.usd;
|
|
117
|
+
const tp = /^tp(\d+)$/.exec(x.reason);
|
|
118
|
+
if (tp)
|
|
119
|
+
p.tpFilled = Math.max(p.tpFilled, Number(tp[1]));
|
|
120
|
+
if (x.reason === 'cost-recovery')
|
|
121
|
+
p.costRecovered = true;
|
|
122
|
+
}
|
|
123
|
+
p.peakPrice = Math.max(p.peakPrice, x.price);
|
|
124
|
+
p.fills.push(x);
|
|
125
|
+
}
|
|
126
|
+
if (p.origTokens > 0 && p.tokens < p.origTokens * 1e-6)
|
|
127
|
+
continue; // sold out without a trade_closed line (torn)
|
|
128
|
+
if (!(p.tokens > 0))
|
|
129
|
+
continue;
|
|
130
|
+
const last = p.fills[p.fills.length - 1];
|
|
131
|
+
p.ladder = [];
|
|
132
|
+
p.sessionId = null;
|
|
133
|
+
p.status = 'open';
|
|
134
|
+
p.troughPrice = p.troughPrice ?? Math.min(...p.fills.map((x) => x.price));
|
|
135
|
+
if (last)
|
|
136
|
+
p.lastTick = placeholderTick(mint, p.symbol || symbol, last.t, last.price);
|
|
137
|
+
p.symbol = p.symbol || symbol;
|
|
138
|
+
out.push(p);
|
|
139
|
+
}
|
|
140
|
+
return { positions: out, taken: [...taken] };
|
|
141
|
+
}
|
|
142
|
+
/** What the crashed fleet left behind, per strategy (empty when it ended cleanly). */
|
|
143
|
+
export function recoverFleet(home, fleetId) {
|
|
144
|
+
const res = { fleetId, crashed: false, members: [], problems: [] };
|
|
145
|
+
let fpath;
|
|
146
|
+
try {
|
|
147
|
+
fpath = runFiles(home, fleetId).ledger;
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
res.problems.push(e.message);
|
|
151
|
+
return res;
|
|
152
|
+
}
|
|
153
|
+
if (!existsSync(fpath)) {
|
|
154
|
+
res.problems.push(`no fleet ledger for ${fleetId}`);
|
|
155
|
+
return res;
|
|
156
|
+
}
|
|
157
|
+
let fleet;
|
|
158
|
+
try {
|
|
159
|
+
fleet = readLedgerTolerant(fpath).events;
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
res.problems.push(`fleet ledger: ${e.message}`);
|
|
163
|
+
return res;
|
|
164
|
+
}
|
|
165
|
+
if (fleet.some((e) => e.kind === 'fleet_end'))
|
|
166
|
+
return res;
|
|
167
|
+
res.crashed = true;
|
|
168
|
+
const runsOf = new Map();
|
|
169
|
+
for (const e of fleet)
|
|
170
|
+
if (e.kind === 'member_start' && typeof e.member === 'string' && typeof e.runId === 'string')
|
|
171
|
+
runsOf.set(e.member, [...(runsOf.get(e.member) ?? []), e.runId]);
|
|
172
|
+
// An earlier resume's taken coins (the service writes them into the fleet ledger), so a second crash keeps them.
|
|
173
|
+
const takenBefore = new Map();
|
|
174
|
+
for (const e of fleet) {
|
|
175
|
+
if (e.kind !== 'service_resume' || !e.members || typeof e.members !== 'object')
|
|
176
|
+
continue;
|
|
177
|
+
for (const [id, v] of Object.entries(e.members)) {
|
|
178
|
+
if (Array.isArray(v?.taken))
|
|
179
|
+
takenBefore.set(id, [...(takenBefore.get(id) ?? []), ...v.taken.filter((x) => typeof x === 'string')]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const id of takenBefore.keys())
|
|
183
|
+
if (!runsOf.has(id))
|
|
184
|
+
runsOf.set(id, []);
|
|
185
|
+
for (const [member, runIds] of runsOf) {
|
|
186
|
+
const m = { member, runIds: [], positions: [], taken: [], problems: [] };
|
|
187
|
+
const taken = new Set(takenBefore.get(member) ?? []);
|
|
188
|
+
for (const runId of runIds) {
|
|
189
|
+
let path;
|
|
190
|
+
try {
|
|
191
|
+
path = runFiles(home, runId).ledger;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
m.problems.push(e.message);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!existsSync(path))
|
|
198
|
+
continue;
|
|
199
|
+
let ev;
|
|
200
|
+
try {
|
|
201
|
+
ev = readLedgerTolerant(path);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
m.problems.push(`${runId}: ${e.message}; nothing adopted from it`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ev.events.some((e) => e.kind === 'run_end'))
|
|
208
|
+
continue;
|
|
209
|
+
m.runIds.push(runId);
|
|
210
|
+
if (ev.torn)
|
|
211
|
+
m.problems.push(`${runId}: a torn last line (the crash) was dropped`);
|
|
212
|
+
const r = positionsFromEvents(ev.events);
|
|
213
|
+
for (const t of r.taken)
|
|
214
|
+
taken.add(t);
|
|
215
|
+
const chain = verifyChain(ev.events);
|
|
216
|
+
if (!chain.ok) {
|
|
217
|
+
m.problems.push(`${runId}: the hash chain breaks at event ${chain.at} (${chain.error}); nothing adopted from it, its coins stay taken`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// A book numbers its positions p1, p2, …: an adopted one keeps its origin in its id so it never collides with
|
|
221
|
+
// the new run's own (an id that already names a run came from an earlier resume).
|
|
222
|
+
for (const p of r.positions)
|
|
223
|
+
if (!p.id.includes('/'))
|
|
224
|
+
p.id = `${runId}/${p.id}`;
|
|
225
|
+
m.positions.push(...r.positions);
|
|
226
|
+
}
|
|
227
|
+
m.taken = [...taken];
|
|
228
|
+
if (m.runIds.length || m.problems.length || m.taken.length)
|
|
229
|
+
res.members.push(m);
|
|
230
|
+
}
|
|
231
|
+
return res;
|
|
232
|
+
}
|