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,873 @@
|
|
|
1
|
+
// `dexterm autotrade service …` (0.2.0 card B, AUTOTRADE-SPEC §7.4 / AT-10): the always-on service.
|
|
2
|
+
//
|
|
3
|
+
// install [--dry-run] [--load] [--platform P] show (and write) the OS job that runs `service ensure` every minute
|
|
4
|
+
// uninstall [--flatten --yes] [--force] stop, unload, remove exactly what install wrote
|
|
5
|
+
// ensure one scheduler tick: start the service when it is not running
|
|
6
|
+
// run the service itself (foreground; what ensure starts, detached)
|
|
7
|
+
// start | stop [--flatten --yes] [--wait SEC] start it now / stop it (a clean stop stays stopped)
|
|
8
|
+
// status [--json] [--clear] heartbeat, guard, crash-loop state, strategies; --clear lifts a give-up
|
|
9
|
+
// logs [-n N] the service log
|
|
10
|
+
// `--home PATH` on any of them = DEXTERM_HOME for that call (the installers pass it for a non-default home).
|
|
11
|
+
//
|
|
12
|
+
// The service hosts `run --all` (every strategy of autotrade.json, the existing fleet: its own lock, rendezvous,
|
|
13
|
+
// control file, panic file, per-strategy controls and Q screen) and adds, around it: the service lock and heartbeat,
|
|
14
|
+
// the guard aggregator (service-guard.ts: pause new entries on disk / load / busy process / stale data; notices for
|
|
15
|
+
// allowance, day caps, recorder health, halted strategies), crash recovery (service-recover.ts: positions a crashed
|
|
16
|
+
// run left open are adopted and keep their exits; coins it took are never bought again), notifications through the
|
|
17
|
+
// paired Telegram bot (and the bot's /stop /pause controls), resource limits (nice 10, a 512 MB heap cap, bounded logs).
|
|
18
|
+
// Every stop halts new entries at the next step and keeps the exits running until nothing is held (flatten sells).
|
|
19
|
+
// A stop from anywhere (this command, the Q screen, the bot, `dexterm autotrade stop`, the panic file, a `touch` of
|
|
20
|
+
// the STOP file) leaves the service stopped until `service start`; a crash restarts it on the next tick.
|
|
21
|
+
//
|
|
22
|
+
// Mode-agnostic: the service never chooses paper or real; the strategies and their own gates do.
|
|
23
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
24
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { homedir, loadavg, setPriority, tmpdir } from 'node:os';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dextermHome } from '../../../custom/src/index.js';
|
|
29
|
+
import { KillSwitch, defaultDbPath } from '../../../data/src/index.js';
|
|
30
|
+
import { BoundedLog, INSTALL_MANIFEST_SCHEMA, PaperPort, RESUME_TAKEN_RULE, SERVICE_HEARTBEAT_MS, SERVICE_SCHEMA, ServiceGuard, TelegramConnector, TelegramService, activeRunner, acquireServiceLock, applyEnsure, clearAttention, clearStopMarker, encode, ensureDecision, fileSha, guardConfigFrom, guardNoticeText, installManifestPath, installPlan, lastRunCrashed, liveServiceHolder, loadConnectors, loadRunnerConfig, readFleetStatus, readServiceHeartbeat, readServiceState, readStarts, readStopMarker, recoverFleet, releaseServiceLock, requestControl, runnerRendezvousPath, secretStore, serviceLabel, serviceLogDir, serviceLogPath, serviceStopPath, strategyTaggedHooks, tailServiceLog, writeServiceHeartbeat, writeServiceState, writeStopMarker, } from '../../../autotrade/src/index.js';
|
|
31
|
+
import { parseArgs } from "./strategies.js";
|
|
32
|
+
import { fleetRunCmd } from "./fleet.js";
|
|
33
|
+
export const SERVICE_USAGE = 'dexterm autotrade service install|uninstall|ensure|run|start|stop|status|logs [--home PATH]';
|
|
34
|
+
const SUBS = ['install', 'uninstall', 'ensure', 'run', 'start', 'stop', 'status', 'logs'];
|
|
35
|
+
/** The heap cap of the started service (a leak ends in a crash and a restart, never in a swapped machine). */
|
|
36
|
+
export const SERVICE_HEAP_MB = 512;
|
|
37
|
+
const homeOf = (io) => dextermHome(io.env);
|
|
38
|
+
const defaultHomeOf = (io) => dextermHome({ ...io.env, DEXTERM_HOME: '' });
|
|
39
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
40
|
+
export async function serviceCmd(argv, io0, deps = {}) {
|
|
41
|
+
const [sub, ...rest0] = argv;
|
|
42
|
+
// --home PATH: DEXTERM_HOME for this call only.
|
|
43
|
+
const rest = [];
|
|
44
|
+
let io = io0;
|
|
45
|
+
for (let i = 0; i < rest0.length; i++) {
|
|
46
|
+
if (rest0[i] === '--home') {
|
|
47
|
+
const h = rest0[++i];
|
|
48
|
+
if (!h) {
|
|
49
|
+
io0.stderr.write('dexterm autotrade service: --home needs a PATH\n');
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
io = { ...io0, env: { ...io0.env, DEXTERM_HOME: resolve(h) } };
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
rest.push(rest0[i]);
|
|
56
|
+
}
|
|
57
|
+
if (!sub || !SUBS.includes(sub)) {
|
|
58
|
+
io.stderr.write(`usage: ${SERVICE_USAGE}\n${HELP}`);
|
|
59
|
+
return sub === undefined || sub === 'help' || sub === '--help' ? 0 : 2;
|
|
60
|
+
}
|
|
61
|
+
switch (sub) {
|
|
62
|
+
case 'ensure': return ensureCmd(rest, io, deps);
|
|
63
|
+
case 'run': return runServiceCmd(rest, io, deps);
|
|
64
|
+
case 'start': return startCmd(rest, io, deps);
|
|
65
|
+
case 'stop': return stopServiceCmd(rest, io, deps);
|
|
66
|
+
case 'status': return statusCmd(rest, io, deps);
|
|
67
|
+
case 'logs': return logsCmd(rest, io);
|
|
68
|
+
case 'install': return installCmd(rest, io, deps);
|
|
69
|
+
case 'uninstall': return uninstallCmd(rest, io, deps);
|
|
70
|
+
}
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
const HELP = `
|
|
74
|
+
install show the OS job (macOS LaunchAgent, Linux systemd user timer, Windows scheduled task) that runs
|
|
75
|
+
\`service ensure\` every minute and at login; --dry-run writes nothing, --load also loads it
|
|
76
|
+
uninstall stop the service, unload the job and remove exactly the files install wrote; refuses while positions
|
|
77
|
+
are open unless --flatten --yes
|
|
78
|
+
ensure one scheduler tick: nothing when the service is running or was stopped; otherwise start it
|
|
79
|
+
run the service in the foreground (\`ensure\` starts this detached)
|
|
80
|
+
start start it now (lifts a stop)
|
|
81
|
+
stop no new entries now; open positions keep their exits until closed (--flatten --yes sells them);
|
|
82
|
+
it stays stopped until \`service start\`
|
|
83
|
+
status heartbeat, guard states, crash-loop state and the strategies; --clear lifts a crash-loop give-up
|
|
84
|
+
logs the end of the service log (-n N lines)
|
|
85
|
+
`;
|
|
86
|
+
// ───────────────────────────── notifications ─────────────────────────────
|
|
87
|
+
/** Send one plain-text line to every chat paired with the notify tier of the user's own bot; silent when none. */
|
|
88
|
+
export async function telegramNotify(home, text) {
|
|
89
|
+
const r = loadConnectors(home);
|
|
90
|
+
if (!r.ok)
|
|
91
|
+
return;
|
|
92
|
+
const t = r.config.telegram;
|
|
93
|
+
if (!t.enabled || !t.tiers.includes('notify'))
|
|
94
|
+
return;
|
|
95
|
+
const chats = [...new Set(t.allow.filter((a) => a.tiers.includes('notify')).map((a) => a.chatId))];
|
|
96
|
+
if (!chats.length)
|
|
97
|
+
return;
|
|
98
|
+
const token = t.secretStore ? secretStore(t.secretStore, home).get() : null;
|
|
99
|
+
if (!token)
|
|
100
|
+
return;
|
|
101
|
+
const c = new TelegramConnector({ token: () => token });
|
|
102
|
+
for (const chatId of chats) {
|
|
103
|
+
try {
|
|
104
|
+
await Promise.race([c.send({ chatId }, { kind: 'text', badge: null, lines: [text] }), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 10_000).unref())]);
|
|
105
|
+
}
|
|
106
|
+
catch { /* the log has it */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function notifier(home, deps, log) {
|
|
110
|
+
const send = deps.notify ?? telegramNotify;
|
|
111
|
+
return async (text) => {
|
|
112
|
+
log?.line(`notify: ${text}`);
|
|
113
|
+
try {
|
|
114
|
+
await send(home, text);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
log?.line(`notify failed: ${errMsg(e)}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ───────────────────────────── ensure ─────────────────────────────
|
|
122
|
+
/** The source bin this file ships next to (…/packages/cli/bin/dexterm.mjs). */
|
|
123
|
+
export const sourceBin = () => fileURLToPath(new URL('../../bin/dexterm.mjs', import.meta.url));
|
|
124
|
+
/** Start `service run` detached, its stdout/stderr (a crash's stack) in logs/service.out; returns the pid. */
|
|
125
|
+
export function spawnServiceRun(home, io) {
|
|
126
|
+
const dir = serviceLogDir(home);
|
|
127
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
128
|
+
const outPath = join(dir, 'service.out');
|
|
129
|
+
try {
|
|
130
|
+
if (statSync(outPath).size > 1024 * 1024)
|
|
131
|
+
renameSync(outPath, `${outPath}.1`);
|
|
132
|
+
}
|
|
133
|
+
catch { /* none yet */ }
|
|
134
|
+
const fd = openSync(outPath, 'a', 0o600);
|
|
135
|
+
const env = { ...io.env, DEXTERM_HOME: home, NODE_OPTIONS: `${io.env.NODE_OPTIONS ?? ''} --max-old-space-size=${SERVICE_HEAP_MB}`.trim() };
|
|
136
|
+
const child = spawn(process.execPath, [sourceBin(), 'autotrade', 'service', 'run', '--home', home], { detached: true, stdio: ['ignore', fd, fd], env, cwd: resolve(dirname(sourceBin()), '..', '..', '..'), windowsHide: true });
|
|
137
|
+
child.unref();
|
|
138
|
+
closeSync(fd);
|
|
139
|
+
return child.pid ?? null;
|
|
140
|
+
}
|
|
141
|
+
async function ensureCmd(argv, io, deps) {
|
|
142
|
+
const { opts } = parseArgs(argv, ['json', 'quiet']);
|
|
143
|
+
const home = homeOf(io);
|
|
144
|
+
const now = (deps.now ?? Date.now)();
|
|
145
|
+
const panic = (deps.killSwitch ?? new KillSwitch()).engaged();
|
|
146
|
+
const d = ensureDecision({ now, holder: liveServiceHolder(home), heartbeat: readServiceHeartbeat(home), stop: readStopMarker(home), starts: readStarts(home), panic: panic ? panic.reason : null });
|
|
147
|
+
const log = new BoundedLog(serviceLogPath(home));
|
|
148
|
+
const notify = notifier(home, deps, log);
|
|
149
|
+
// The scheduler appends this command's output to logs/tick.log every minute: keep it bounded too.
|
|
150
|
+
const tick = join(serviceLogDir(home), 'tick.log');
|
|
151
|
+
try {
|
|
152
|
+
if (statSync(tick).size > 1024 * 1024)
|
|
153
|
+
renameSync(tick, `${tick}.1`);
|
|
154
|
+
}
|
|
155
|
+
catch { /* none yet */ }
|
|
156
|
+
const say = (s) => { if (opts.json)
|
|
157
|
+
return; if (d.action !== 'noop' || !opts.quiet)
|
|
158
|
+
io.stdout.write(`${new Date(now).toISOString()} ${s}\n`); };
|
|
159
|
+
let pid = null;
|
|
160
|
+
if (d.action === 'kill-hung') {
|
|
161
|
+
log.line(`ensure: ${d.why}; ending it (the next tick restarts it)`);
|
|
162
|
+
try {
|
|
163
|
+
(deps.kill ?? process.kill.bind(process))(d.pid, 'SIGKILL');
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
log.line(`ensure: kill failed: ${errMsg(e)}`);
|
|
167
|
+
}
|
|
168
|
+
await notify(`autotrade service · DOWN · it stopped answering (pid ${d.pid}); restarting it`);
|
|
169
|
+
}
|
|
170
|
+
else if (d.action === 'give-up') {
|
|
171
|
+
applyEnsure(home, d, now);
|
|
172
|
+
if (d.notify) {
|
|
173
|
+
log.line(`ensure: ${d.why}`);
|
|
174
|
+
await notify(`autotrade service · CRASH LOOP · ${d.why}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (d.action === 'start') {
|
|
178
|
+
applyEnsure(home, d, now);
|
|
179
|
+
log.line(`ensure: ${d.why}`);
|
|
180
|
+
pid = (deps.spawnRun ?? spawnServiceRun)(home, io);
|
|
181
|
+
log.line(`ensure: started the service (pid ${pid ?? '?'})`);
|
|
182
|
+
if (d.crashed && d.notifyDown)
|
|
183
|
+
await notify('autotrade service · DOWN · it ended without a clean stop; restarting it (open paper positions are carried over)');
|
|
184
|
+
}
|
|
185
|
+
if (opts.json)
|
|
186
|
+
io.stdout.write(`${JSON.stringify({ ...d, pid })}\n`);
|
|
187
|
+
else
|
|
188
|
+
say(`${d.action}: ${d.why}${pid ? ` (pid ${pid})` : ''}`);
|
|
189
|
+
return d.action === 'give-up' ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
async function startCmd(argv, io, deps) {
|
|
192
|
+
const home = homeOf(io);
|
|
193
|
+
const had = readStopMarker(home);
|
|
194
|
+
clearStopMarker(home);
|
|
195
|
+
if (had)
|
|
196
|
+
io.stdout.write(`lifted the stop (${had.reason})\n`);
|
|
197
|
+
const st = readStarts(home);
|
|
198
|
+
if (st.attention) {
|
|
199
|
+
io.stderr.write(`the service gave up after a crash loop (${st.attention.why}). Look at \`dexterm autotrade service logs\`, then \`dexterm autotrade service status --clear\`.\n`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
return ensureCmd(argv, io, deps);
|
|
203
|
+
}
|
|
204
|
+
// ───────────────────────────── stop ─────────────────────────────
|
|
205
|
+
async function stopServiceCmd(argv, io, deps) {
|
|
206
|
+
const { opts } = parseArgs(argv, ['flatten', 'yes', 'json']);
|
|
207
|
+
const home = homeOf(io);
|
|
208
|
+
if (opts.flatten && !opts.yes) {
|
|
209
|
+
io.stderr.write('dexterm autotrade service stop --flatten sells every open paper position at its next tick: add --yes to confirm\n');
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
const r = await stopService(io, opts.flatten ? 'flatten' : 'stop', 'cli', waitOf(opts.wait));
|
|
213
|
+
if (opts.json) {
|
|
214
|
+
io.stdout.write(`${JSON.stringify(r)}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
io.stdout.write(`${r.lines.join('\n')}\n`);
|
|
218
|
+
void deps;
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
const waitOf = (v) => { const n = typeof v === 'string' ? Number(v) : NaN; return Number.isFinite(n) && n >= 0 ? Math.min(n, 120) : 5; };
|
|
222
|
+
/** The STOP marker (a clean stop stays stopped) + a soft stop / flatten of the running fleet. */
|
|
223
|
+
export async function stopService(io, action, by, waitSec) {
|
|
224
|
+
const home = homeOf(io);
|
|
225
|
+
writeStopMarker(home, by, by === 'uninstall' ? 'uninstall' : `\`dexterm autotrade service ${action === 'flatten' ? 'stop --flatten' : 'stop'}\``);
|
|
226
|
+
const lines = ['service stop recorded: it stays stopped until `dexterm autotrade service start`'];
|
|
227
|
+
const r = activeRunner(home);
|
|
228
|
+
if (!r) {
|
|
229
|
+
lines.push('no strategies are running; nothing else to do');
|
|
230
|
+
return { marker: true, runId: null, positions: 0, lines };
|
|
231
|
+
}
|
|
232
|
+
const { sendControl } = await import("./stop.js");
|
|
233
|
+
const res = await sendControl(io, action, waitSec, r);
|
|
234
|
+
if ('error' in res) {
|
|
235
|
+
lines.push(`the strategies did not take the request: ${res.error}`);
|
|
236
|
+
return { marker: true, runId: r.runId, positions: r.positions, lines };
|
|
237
|
+
}
|
|
238
|
+
lines.push(res.acknowledged
|
|
239
|
+
? `✓ ${action === 'flatten' ? 'flattening' : 'halted'}: no new entries${res.positions ? `; ${res.positions} open position${res.positions === 1 ? '' : 's'} ${action === 'flatten' ? 'sell at the next tick' : 'keep their exits until closed (`--flatten --yes` sells now)'}` : ''}; the service ends once nothing is held`
|
|
240
|
+
: 'requested; the strategies read it on their next step (`dexterm autotrade service status`)');
|
|
241
|
+
return { marker: true, runId: r.runId, positions: res.positions, lines };
|
|
242
|
+
}
|
|
243
|
+
// ───────────────────────────── run ─────────────────────────────
|
|
244
|
+
const FLEET_PASS = ['--replay', '--ms-per-tick', '--sol-usd', '--model', '--max-minutes', '--config'];
|
|
245
|
+
export const SERVICE_CONFIG_SCHEMA = 'dexterm-autotrade-service-config/1';
|
|
246
|
+
export const serviceConfigPath = (home) => join(home, 'autotrade', 'service.json');
|
|
247
|
+
/**
|
|
248
|
+
* $DEXTERM_HOME/autotrade/service.json: `{ "schema": "dexterm-autotrade-service-config/1", "runArgs": [...] }`, the
|
|
249
|
+
* `run --all` flags a scheduler-started service uses (only --stub, --replay, --ms-per-tick, --sol-usd, --model,
|
|
250
|
+
* --config, --max-minutes; anything else is refused and logged). Never --live: the strategies' own modes decide.
|
|
251
|
+
*/
|
|
252
|
+
export function readServiceConfig(home, say = () => { }) {
|
|
253
|
+
const p = serviceConfigPath(home);
|
|
254
|
+
if (!existsSync(p))
|
|
255
|
+
return [];
|
|
256
|
+
let d;
|
|
257
|
+
try {
|
|
258
|
+
d = JSON.parse(readFileSync(p, 'utf8'));
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
say(`service.json is not JSON (${errMsg(e)}); ignored`);
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
if (d.schema !== SERVICE_CONFIG_SCHEMA || !Array.isArray(d.runArgs)) {
|
|
265
|
+
say('service.json: unknown shape; ignored');
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const out = [];
|
|
269
|
+
const a = d.runArgs.filter((x) => typeof x === 'string');
|
|
270
|
+
for (let i = 0; i < a.length; i++) {
|
|
271
|
+
if (a[i] === '--stub')
|
|
272
|
+
out.push('--stub');
|
|
273
|
+
else if (FLEET_PASS.includes(a[i]) && typeof a[i + 1] === 'string')
|
|
274
|
+
out.push(a[i], a[++i]);
|
|
275
|
+
else {
|
|
276
|
+
say(`service.json: ${JSON.stringify(a[i])} is not allowed in runArgs; ignored`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
/** Recorder health: installed (its OS job exists) and how old the DB's last write is (file times; never opens it). */
|
|
282
|
+
function recorderProbe(io, userHome, now) {
|
|
283
|
+
const label = 'xyz.dexterm.collector';
|
|
284
|
+
const installed = process.platform === 'darwin' ? existsSync(join(userHome, 'Library', 'LaunchAgents', `${label}.plist`))
|
|
285
|
+
: process.platform === 'linux' ? existsSync(join(userHome, '.config', 'systemd', 'user', 'dexterm-collector.service')) : false;
|
|
286
|
+
const db = io.env.DEXTERM_DB_PATH || defaultDbPath(io.env);
|
|
287
|
+
let newest = 0;
|
|
288
|
+
for (const p of [db, `${db}-wal`]) {
|
|
289
|
+
try {
|
|
290
|
+
newest = Math.max(newest, statSync(p).mtimeMs);
|
|
291
|
+
}
|
|
292
|
+
catch { /* absent */ }
|
|
293
|
+
}
|
|
294
|
+
return { installed, newestAgeMs: newest ? now - newest : null };
|
|
295
|
+
}
|
|
296
|
+
function busyProbe(patterns) {
|
|
297
|
+
if (!patterns.length || process.platform === 'win32')
|
|
298
|
+
return [];
|
|
299
|
+
const hit = [];
|
|
300
|
+
for (const p of patterns) {
|
|
301
|
+
const r = spawnSync('pgrep', ['-f', p], { encoding: 'utf8', timeout: 3000 });
|
|
302
|
+
const pids = (r.stdout ?? '').split('\n').map(Number).filter((n) => n && n !== process.pid && n !== r.pid);
|
|
303
|
+
if (pids.length)
|
|
304
|
+
hit.push(p);
|
|
305
|
+
}
|
|
306
|
+
return hit;
|
|
307
|
+
}
|
|
308
|
+
function freeGbOf(home) {
|
|
309
|
+
try {
|
|
310
|
+
const s = statfsSync(home);
|
|
311
|
+
return (s.bavail * s.bsize) / 1e9;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function runServiceCmd(argv, io, deps) {
|
|
318
|
+
const { opts } = parseArgs(argv, ['stub', 'quiet']);
|
|
319
|
+
const home = homeOf(io);
|
|
320
|
+
const clock = deps.now ?? Date.now;
|
|
321
|
+
mkdirSync(join(home, 'autotrade'), { recursive: true, mode: 0o700 });
|
|
322
|
+
const stop0 = readStopMarker(home);
|
|
323
|
+
if (stop0) {
|
|
324
|
+
io.stderr.write(`dexterm autotrade service run: the service is stopped (${stop0.reason}); \`dexterm autotrade service start\` lifts it\n`);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
const lock = acquireServiceLock(home);
|
|
328
|
+
if (!lock.ok) {
|
|
329
|
+
io.stderr.write(`dexterm autotrade service run: the service is already running (pid ${lock.holder.pid}); it never runs twice\n`);
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
setPriority(10);
|
|
334
|
+
}
|
|
335
|
+
catch { /* best effort (launchd / systemd already set Nice 10) */ }
|
|
336
|
+
const log = new BoundedLog(serviceLogPath(home));
|
|
337
|
+
const notify = notifier(home, deps, log);
|
|
338
|
+
// In a terminal the lines show as they are logged; detached (stdout = service.out) the log alone has them.
|
|
339
|
+
const echo = !opts.quiet && !!process.stdout.isTTY && !deps.fleet;
|
|
340
|
+
const say = (s) => { log.line(s); if (echo)
|
|
341
|
+
io.stdout.write(`${s}\n`); };
|
|
342
|
+
const startedAt = clock();
|
|
343
|
+
if (lock.reclaimed)
|
|
344
|
+
say(`reclaimed a stale service lock (pid ${lock.reclaimed.pid} is gone)`);
|
|
345
|
+
// Crash recovery: the fleet the last run hosted, when it did not end cleanly.
|
|
346
|
+
const prevHb = readServiceHeartbeat(home);
|
|
347
|
+
const prev = readServiceState(home);
|
|
348
|
+
const adopt = new Map();
|
|
349
|
+
const taken = new Map();
|
|
350
|
+
let resumed = null;
|
|
351
|
+
let fromFleet = null;
|
|
352
|
+
if (prev?.fleetId && (lastRunCrashed(prevHb) || !prevHb)) {
|
|
353
|
+
const rec = recoverFleet(home, prev.fleetId);
|
|
354
|
+
if (rec.crashed) {
|
|
355
|
+
fromFleet = prev.fleetId;
|
|
356
|
+
let n = 0, coins = 0;
|
|
357
|
+
for (const m of rec.members) {
|
|
358
|
+
if (m.positions.length)
|
|
359
|
+
adopt.set(m.member, m.positions);
|
|
360
|
+
taken.set(m.member, new Set(m.taken));
|
|
361
|
+
n += m.positions.length;
|
|
362
|
+
coins += m.taken.length;
|
|
363
|
+
for (const p of m.problems)
|
|
364
|
+
say(`recovery [${m.member}]: ${p}`);
|
|
365
|
+
}
|
|
366
|
+
for (const p of rec.problems)
|
|
367
|
+
say(`recovery: ${p}`);
|
|
368
|
+
resumed = { positions: n, takenCoins: coins };
|
|
369
|
+
say(`recovery: fleet ${prev.fleetId} ended without a clean stop; ${n} open paper position${n === 1 ? '' : 's'} carried over, ${coins} coin${coins === 1 ? '' : 's'} it took will not be bought again`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const rc = loadRunnerConfig(home);
|
|
373
|
+
const guard = new ServiceGuard(guardConfigFrom(rc.ok ? rc.config : null));
|
|
374
|
+
const ks = deps.killSwitch ?? new KillSwitch();
|
|
375
|
+
const userHome = deps.userHome ?? io.env.HOME ?? io.env.USERPROFILE ?? homedir();
|
|
376
|
+
let state = 'starting';
|
|
377
|
+
let fleetId = null;
|
|
378
|
+
let lastFleet = null;
|
|
379
|
+
let lastAdvance = clock();
|
|
380
|
+
let lastT = 0;
|
|
381
|
+
let guardStopSent = false;
|
|
382
|
+
let lastGuard = null;
|
|
383
|
+
let recorderCache = { at: 0, v: null };
|
|
384
|
+
let busyCache = { at: 0, v: [] };
|
|
385
|
+
const beat = () => {
|
|
386
|
+
const now = clock();
|
|
387
|
+
const hb = { schema: SERVICE_SCHEMA, pid: process.pid, startedAt, heartbeatAt: now, state, fleetId, guard: lastGuard?.states ?? [], resumed, dataAgeMs: lastT ? now - lastAdvance : null };
|
|
388
|
+
try {
|
|
389
|
+
writeServiceHeartbeat(home, hb);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
log.line(`heartbeat failed: ${errMsg(e)}`);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const evaluate = () => {
|
|
396
|
+
const now = clock();
|
|
397
|
+
const cfg = guard.cfg;
|
|
398
|
+
if (deps.probe?.recorder !== undefined)
|
|
399
|
+
recorderCache = { at: now, v: deps.probe.recorder };
|
|
400
|
+
else if (now - recorderCache.at >= 60_000)
|
|
401
|
+
recorderCache = { at: now, v: recorderProbe(io, userHome, now) };
|
|
402
|
+
if (deps.probe?.busy !== undefined)
|
|
403
|
+
busyCache = { at: now, v: deps.probe.busy };
|
|
404
|
+
else if (now - busyCache.at >= 30_000)
|
|
405
|
+
busyCache = { at: now, v: busyProbe(cfg.busyProcs) };
|
|
406
|
+
const stop = readStopMarker(home);
|
|
407
|
+
const panic = ks.engaged();
|
|
408
|
+
let runnerAge = null;
|
|
409
|
+
try {
|
|
410
|
+
const r = JSON.parse(readFileSync(runnerRendezvousPath(home), 'utf8'));
|
|
411
|
+
if (fleetId && r.runId === fleetId && typeof r.heartbeatAt === 'number')
|
|
412
|
+
runnerAge = now - r.heartbeatAt;
|
|
413
|
+
}
|
|
414
|
+
catch { /* not yet */ }
|
|
415
|
+
const halted = {};
|
|
416
|
+
for (const m of lastFleet?.members ?? [])
|
|
417
|
+
if (m.state === 'halted' && m.haltReason && !['stop', 'flatten', 'kill-switch', 'runner-stopped'].includes(m.haltReason))
|
|
418
|
+
halted[m.id] = m.haltReason;
|
|
419
|
+
const v = guard.evaluate({
|
|
420
|
+
now, stopFile: stop ? stop.reason : null, panic: panic ? panic.reason : null,
|
|
421
|
+
freeGb: deps.probe?.freeGb !== undefined ? deps.probe.freeGb : freeGbOf(home),
|
|
422
|
+
load1m: deps.probe?.load1m !== undefined ? deps.probe.load1m : (process.platform === 'win32' ? null : loadavg()[0]),
|
|
423
|
+
busy: busyCache.v, dataAgeMs: lastT ? now - lastAdvance : null, upMs: fleetId ? now - startedAt : 0, runnerAgeMs: runnerAge,
|
|
424
|
+
allowance: lastFleet?.global.allowance ?? null, dayCap: lastFleet?.global.blocked ?? null, recorder: recorderCache.v, halted,
|
|
425
|
+
});
|
|
426
|
+
lastGuard = v;
|
|
427
|
+
for (const t of v.transitions) {
|
|
428
|
+
say(`guard ${t.on ? 'ON ' : 'off'} ${t.id}: ${t.detail}`);
|
|
429
|
+
fleetLedger?.add(now, 'service_guard', { id: t.id, on: t.on, effect: t.effect, detail: t.detail.slice(0, 200) });
|
|
430
|
+
if (t.on || t.effect !== 'notice')
|
|
431
|
+
void notify(guardNoticeText(t));
|
|
432
|
+
}
|
|
433
|
+
if (v.stop && fleetId && !guardStopSent) {
|
|
434
|
+
guardStopSent = true;
|
|
435
|
+
const w = requestControl(home, fleetId, 'stop', 'cli');
|
|
436
|
+
say(`guard ${v.stop}: stopping every strategy (no new entries; exits keep running until nothing is held)${w.ok ? '' : ` (${w.why})`}`);
|
|
437
|
+
}
|
|
438
|
+
beat();
|
|
439
|
+
};
|
|
440
|
+
let fleetLedger = null;
|
|
441
|
+
const tickMs = deps.beatMs ?? SERVICE_HEARTBEAT_MS;
|
|
442
|
+
const timer = setInterval(evaluate, tickMs);
|
|
443
|
+
// The STOP file is checked every second (a `touch` stops the strategies within a step).
|
|
444
|
+
const stopWatch = setInterval(() => { if (!guardStopSent && fleetId && existsSync(serviceStopPath(home)))
|
|
445
|
+
evaluate(); }, Math.min(1000, tickMs));
|
|
446
|
+
beat();
|
|
447
|
+
// The user's own bot: notifications for every strategy's events and /status /pause /stop through the control file.
|
|
448
|
+
let tg = null;
|
|
449
|
+
try {
|
|
450
|
+
tg = await startTelegram(home, () => fleetId, say);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
say(`telegram: not started (${errMsg(e)})`);
|
|
454
|
+
}
|
|
455
|
+
const takenVet = (m, inner) => {
|
|
456
|
+
const set = taken.get(m.config.id);
|
|
457
|
+
if (!set?.size)
|
|
458
|
+
return inner;
|
|
459
|
+
return async (k) => (set.has(k.mint) ? { ok: false, rule: RESUME_TAKEN_RULE, detail: 'a run that crashed already took this coin' } : inner ? inner(k) : { ok: true });
|
|
460
|
+
};
|
|
461
|
+
// Flags: this command line first, then service.json `runArgs` (what a scheduler-started run uses).
|
|
462
|
+
const cfgArgs = readServiceConfig(home, say);
|
|
463
|
+
const cfg = parseArgs(cfgArgs, ['stub']).opts;
|
|
464
|
+
const fleetArgv = ['--all', '--paper', ...(opts.stub || cfg.stub ? ['--stub'] : [])];
|
|
465
|
+
for (const f of FLEET_PASS) {
|
|
466
|
+
const v = opts[f.slice(2)] ?? cfg[f.slice(2)];
|
|
467
|
+
if (typeof v === 'string')
|
|
468
|
+
fleetArgv.push(f, v);
|
|
469
|
+
}
|
|
470
|
+
const over = {
|
|
471
|
+
...deps.fleetOver,
|
|
472
|
+
extraSafety: () => ({ pauseEntries: lastGuard?.pauseEntries ?? null }),
|
|
473
|
+
vetWrap: takenVet,
|
|
474
|
+
adopt: (id) => adopt.get(id),
|
|
475
|
+
onStarted: (id, fl) => {
|
|
476
|
+
fleetId = id;
|
|
477
|
+
fleetLedger = fl;
|
|
478
|
+
state = 'running';
|
|
479
|
+
writeServiceState(home, { fleetId: id, startedAt, pid: process.pid });
|
|
480
|
+
fl.add(clock(), 'service_start', { pid: process.pid, fromFleet, resumed });
|
|
481
|
+
if (taken.size)
|
|
482
|
+
fl.add(clock(), 'service_resume', { fromFleet, members: Object.fromEntries([...taken].map(([k, v]) => [k, { taken: [...v], adopted: adopt.get(k)?.length ?? 0 }])) });
|
|
483
|
+
say(`fleet ${id} started`);
|
|
484
|
+
beat();
|
|
485
|
+
deps.fleetOver?.onStarted?.(id, fl);
|
|
486
|
+
},
|
|
487
|
+
hooks: strategyTaggedHooks({ onEvent: (e) => tg?.onLedger(e) }, {
|
|
488
|
+
onMemberState: (m, r, st) => {
|
|
489
|
+
if (st.t > lastT) {
|
|
490
|
+
lastT = st.t;
|
|
491
|
+
lastAdvance = clock();
|
|
492
|
+
}
|
|
493
|
+
deps.fleetOver?.hooks?.onMemberState?.(m, r, st);
|
|
494
|
+
},
|
|
495
|
+
onMemberEvent: (m, r, e) => deps.fleetOver?.hooks?.onMemberEvent?.(m, r, e),
|
|
496
|
+
onFleetState: (st) => {
|
|
497
|
+
lastFleet = st;
|
|
498
|
+
if (tg) {
|
|
499
|
+
try {
|
|
500
|
+
const rs = JSON.parse(readFileSync(runnerRendezvousPath(home), 'utf8'));
|
|
501
|
+
tg.onState(rs);
|
|
502
|
+
}
|
|
503
|
+
catch { /* not yet */ }
|
|
504
|
+
}
|
|
505
|
+
deps.fleetOver?.hooks?.onFleetState?.(st);
|
|
506
|
+
},
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
const fleetIo = { ...io, stdout: log.writer(), stderr: log.writer('! ') };
|
|
510
|
+
say(`service started (pid ${process.pid}, home ${home === defaultHomeOf(io) ? 'default' : 'custom'})`);
|
|
511
|
+
let code = 1;
|
|
512
|
+
let crashed = false;
|
|
513
|
+
try {
|
|
514
|
+
code = await (deps.fleet ?? fleetRunCmd)(fleetArgv, fleetIo, over);
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
crashed = true;
|
|
518
|
+
say(`the strategies failed: ${errMsg(e)}`);
|
|
519
|
+
}
|
|
520
|
+
clearInterval(timer);
|
|
521
|
+
clearInterval(stopWatch);
|
|
522
|
+
const last = lastFleet;
|
|
523
|
+
const halt = last?.haltReason ?? null;
|
|
524
|
+
let endReason;
|
|
525
|
+
if (crashed || (fleetId && code !== 0 && !halt)) {
|
|
526
|
+
// An error after the strategies started is a crash: the next tick restarts and recovers.
|
|
527
|
+
state = 'starting';
|
|
528
|
+
endReason = 'failed';
|
|
529
|
+
crashed = true;
|
|
530
|
+
}
|
|
531
|
+
else if (!fleetId) {
|
|
532
|
+
endReason = 'could not start the strategies (see the service log)';
|
|
533
|
+
writeStopMarker(home, 'fleet', endReason);
|
|
534
|
+
await notify(`autotrade service · CANNOT RUN · ${endReason}: fix it, then \`dexterm autotrade service start\``);
|
|
535
|
+
state = 'stopped';
|
|
536
|
+
}
|
|
537
|
+
else if (halt === 'runner-stopped') {
|
|
538
|
+
// A signal (logout, shutdown, `kill`) or --max-minutes: a clean end that the next login starts again.
|
|
539
|
+
endReason = 'signal or time limit';
|
|
540
|
+
state = 'stopped';
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
const stopM = readStopMarker(home);
|
|
544
|
+
endReason = stopM ? stopM.reason : halt ? `the strategies stopped (${halt})` : 'every strategy ended';
|
|
545
|
+
if (!stopM)
|
|
546
|
+
writeStopMarker(home, 'fleet', endReason);
|
|
547
|
+
state = 'stopped';
|
|
548
|
+
await notify(`autotrade service · STOPPED · ${endReason}; it stays stopped until \`dexterm autotrade service start\``);
|
|
549
|
+
}
|
|
550
|
+
if (state === 'stopped')
|
|
551
|
+
writeServiceState(home, { fleetId: null, startedAt, pid: process.pid });
|
|
552
|
+
try {
|
|
553
|
+
writeServiceHeartbeat(home, { schema: SERVICE_SCHEMA, pid: process.pid, startedAt, heartbeatAt: clock(), state: crashed ? 'starting' : 'stopped', fleetId: crashed ? fleetId : null, endReason, guard: lastGuard?.states ?? [], resumed });
|
|
554
|
+
}
|
|
555
|
+
catch { /* best effort */ }
|
|
556
|
+
say(`service ${crashed ? 'FAILED (the next tick restarts it)' : 'stopped'}: ${endReason}`);
|
|
557
|
+
try {
|
|
558
|
+
await tg?.stop();
|
|
559
|
+
}
|
|
560
|
+
catch { /* closing */ }
|
|
561
|
+
releaseServiceLock(home);
|
|
562
|
+
return crashed ? 1 : 0;
|
|
563
|
+
}
|
|
564
|
+
/** The paired bot inside the service: notify + /status /pause /stop for the fleet (approvals have no port here). */
|
|
565
|
+
async function startTelegram(home, runId, say) {
|
|
566
|
+
const r = loadConnectors(home);
|
|
567
|
+
if (!r.ok) {
|
|
568
|
+
say(`telegram: ${r.error}`);
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
const t = r.config.telegram;
|
|
572
|
+
if (!t.enabled || !t.allow.length)
|
|
573
|
+
return null;
|
|
574
|
+
const token = t.secretStore ? secretStore(t.secretStore, home).get() : null;
|
|
575
|
+
if (!token) {
|
|
576
|
+
say('telegram: the bot token is missing from the secret store (dexterm telegram setup)');
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
const connector = new TelegramConnector({ token: () => token, onLog: (l) => say(`telegram: ${l}`) });
|
|
580
|
+
const refuse = { name: 'service-no-approvals', buy: () => { throw new Error('approvals are not taken by the service'); }, sell: () => { throw new Error('approvals are not taken by the service'); } };
|
|
581
|
+
const port = new PaperPort(refuse, { entryBlock: () => 'service-no-approvals' });
|
|
582
|
+
const state = () => { try {
|
|
583
|
+
return JSON.parse(readFileSync(runnerRendezvousPath(home), 'utf8'));
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return null;
|
|
587
|
+
} };
|
|
588
|
+
const svc = new TelegramService({ home, connector, port, knownToken: () => token, runId, state, strategy: () => null, alert: (l) => say(`! ${l}`) });
|
|
589
|
+
await svc.start();
|
|
590
|
+
say('telegram: your bot is on (notifications, /status, /pause, /stop)');
|
|
591
|
+
return svc;
|
|
592
|
+
}
|
|
593
|
+
// ───────────────────────────── status / logs ─────────────────────────────
|
|
594
|
+
async function statusCmd(argv, io, deps) {
|
|
595
|
+
const { opts } = parseArgs(argv, ['json', 'clear']);
|
|
596
|
+
const home = homeOf(io);
|
|
597
|
+
const now = (deps.now ?? Date.now)();
|
|
598
|
+
if (opts.clear) {
|
|
599
|
+
clearAttention(home);
|
|
600
|
+
io.stdout.write('crash-loop history cleared; the next tick (or `dexterm autotrade service start`) starts the service\n');
|
|
601
|
+
}
|
|
602
|
+
const hb = readServiceHeartbeat(home);
|
|
603
|
+
const holder = liveServiceHolder(home);
|
|
604
|
+
const stop = readStopMarker(home);
|
|
605
|
+
const starts = readStarts(home);
|
|
606
|
+
const manifest = readManifest(home);
|
|
607
|
+
const fleet = readFleetStatus(home, now);
|
|
608
|
+
const running = !!holder && !!hb && hb.pid === holder.pid && hb.state !== 'stopped';
|
|
609
|
+
const age = hb ? Math.round((now - hb.heartbeatAt) / 1000) : null;
|
|
610
|
+
const crashes = starts.crashStarts.filter((t) => now - t < 3_600_000).length;
|
|
611
|
+
const doc = {
|
|
612
|
+
running, pid: holder?.pid ?? null, state: hb?.state ?? null, heartbeatAgeSec: age, fleetId: hb?.fleetId ?? null,
|
|
613
|
+
stopped: stop ? { reason: stop.reason, by: stop.by, at: stop.at } : null, attention: starts.attention, crashesLastHour: crashes,
|
|
614
|
+
guard: running ? hb.guard : [], resumed: hb?.resumed ?? null, dataAgeSec: hb?.dataAgeMs != null ? Math.round(hb.dataAgeMs / 1000) : null,
|
|
615
|
+
installed: manifest ? { platform: manifest.platform, name: manifest.name, loaded: manifest.loaded, files: manifest.files.map((f) => f.path) } : null,
|
|
616
|
+
strategies: fleet.ok && running ? fleet.status.members.map((m) => ({ id: m.id, state: m.state, open: m.open.length, haltReason: m.haltReason })) : [],
|
|
617
|
+
log: serviceLogPath(home),
|
|
618
|
+
};
|
|
619
|
+
if (opts.json) {
|
|
620
|
+
io.stdout.write(`${JSON.stringify(doc)}\n`);
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
const L = [];
|
|
624
|
+
L.push(`service ${running ? `RUNNING · pid ${holder.pid} · heartbeat ${age} s ago${hb.fleetId ? ` · fleet ${hb.fleetId}` : ''}` : stop ? `STOPPED (${stop.reason}) · \`dexterm autotrade service start\`` : starts.attention ? 'DOWN: crash loop (see below)' : hb && hb.state !== 'stopped' && !holder ? 'DOWN: the last run crashed; the next tick restarts it' : 'not running'}`);
|
|
625
|
+
L.push(`installed ${manifest ? `${manifest.name} (${manifest.platform}${manifest.loaded ? ', loaded' : ', written, not loaded'})` : 'no (`dexterm autotrade service install`)'}`);
|
|
626
|
+
if (starts.attention)
|
|
627
|
+
L.push(`ATTENTION ${starts.attention.why}\n look at \`dexterm autotrade service logs\`, then \`dexterm autotrade service status --clear\``);
|
|
628
|
+
else if (crashes)
|
|
629
|
+
L.push(`crashes ${crashes} in the last hour (gives up after 5)`);
|
|
630
|
+
if (running && hb.resumed)
|
|
631
|
+
L.push(`resumed ${hb.resumed.positions} open position(s) carried over from a crash; ${hb.resumed.takenCoins} coin(s) never bought again`);
|
|
632
|
+
if (running && hb.dataAgeMs != null)
|
|
633
|
+
L.push(`market data newest tick ${Math.round(hb.dataAgeMs / 1000)} s ago`);
|
|
634
|
+
if (running) {
|
|
635
|
+
L.push(`guard ${hb.guard.length ? '' : 'all clear'}`);
|
|
636
|
+
for (const g of hb.guard)
|
|
637
|
+
L.push(` ${g.effect === 'stop' ? 'STOP ' : g.effect === 'pause-entries' ? 'PAUSE' : 'note '} ${g.id}: ${g.detail}`);
|
|
638
|
+
for (const s of doc.strategies)
|
|
639
|
+
L.push(` strategy ${s.id}: ${s.state}${s.haltReason ? ` (${s.haltReason})` : ''} · ${s.open} open`);
|
|
640
|
+
}
|
|
641
|
+
L.push(`log ${serviceLogPath(home)}`);
|
|
642
|
+
io.stdout.write(`${L.join('\n')}\n`);
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
function logsCmd(argv, io) {
|
|
646
|
+
const n = (() => { const i = argv.indexOf('-n'); const v = i >= 0 ? Number(argv[i + 1]) : 50; return Number.isFinite(v) && v > 0 ? Math.min(v, 2000) : 50; })();
|
|
647
|
+
const home = homeOf(io);
|
|
648
|
+
const lines = tailServiceLog(home, n);
|
|
649
|
+
io.stdout.write(lines.length ? `${lines.join('\n')}\n` : `no service log yet (${serviceLogPath(home)})\n`);
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
// ───────────────────────────── install / uninstall ─────────────────────────────
|
|
653
|
+
function readManifest(home) {
|
|
654
|
+
try {
|
|
655
|
+
const d = JSON.parse(readFileSync(installManifestPath(home), 'utf8'));
|
|
656
|
+
return d.schema === INSTALL_MANIFEST_SCHEMA ? d : null;
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
export function installSpec(io, platform, userHome) {
|
|
663
|
+
const home = homeOf(io);
|
|
664
|
+
const dflt = defaultHomeOf(io);
|
|
665
|
+
const bin = sourceBin();
|
|
666
|
+
return {
|
|
667
|
+
platform, label: serviceLabel(home, dflt), nodePath: process.execPath, binPath: bin, workingDir: resolve(dirname(bin), '..', '..', '..'), userHome,
|
|
668
|
+
dexHome: home === dflt ? null : home, tickLog: join(serviceLogDir(home), 'tick.log'), path: platform === 'win32' ? null : (io.env.PATH ?? null), uid: process.getuid?.() ?? 0,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/** Parse checks of a plan's files: XML well-formedness (plist, task), systemd sections, and `plutil -lint` on macOS. */
|
|
672
|
+
export function verifyPlan(plan, exec) {
|
|
673
|
+
const lines = [];
|
|
674
|
+
let ok = true;
|
|
675
|
+
for (const f of plan.files) {
|
|
676
|
+
const isXml = f.path.endsWith('.plist') || f.path.endsWith('.xml');
|
|
677
|
+
const why = isXml ? xmlProblem(f.content) : iniProblem(f.content);
|
|
678
|
+
if (why) {
|
|
679
|
+
ok = false;
|
|
680
|
+
lines.push(`✗ ${f.path}: ${why}`);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (f.path.endsWith('.plist') && /<key>KeepAlive<\/key>/.test(f.content)) {
|
|
684
|
+
ok = false;
|
|
685
|
+
lines.push(`✗ ${f.path}: KeepAlive is not allowed`);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (f.path.endsWith('.plist') && process.platform === 'darwin') {
|
|
689
|
+
const tmp = join(tmpdir(), `dexterm-service-lint-${process.pid}.plist`);
|
|
690
|
+
writeFileSync(tmp, f.content);
|
|
691
|
+
const r = (exec ?? defaultExec)(['plutil', '-lint', tmp]);
|
|
692
|
+
rmSync(tmp, { force: true });
|
|
693
|
+
if (r.status !== 0) {
|
|
694
|
+
ok = false;
|
|
695
|
+
lines.push(`✗ ${f.path}: plutil -lint: ${r.out.trim()}`);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
lines.push(`✓ ${f.path}: parses (plutil -lint OK), StartInterval 60, no KeepAlive`);
|
|
699
|
+
}
|
|
700
|
+
else
|
|
701
|
+
lines.push(`✓ ${f.path}: parses`);
|
|
702
|
+
}
|
|
703
|
+
return { ok, lines };
|
|
704
|
+
}
|
|
705
|
+
/** A small well-formedness check: every tag closes in order (comments, declarations and self-closing tags skipped). */
|
|
706
|
+
export function xmlProblem(s) {
|
|
707
|
+
const stack = [];
|
|
708
|
+
const re = /<(\/?)([A-Za-z_][\w.:-]*)([^>]*?)(\/?)>|<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>/g;
|
|
709
|
+
let m;
|
|
710
|
+
while ((m = re.exec(s))) {
|
|
711
|
+
if (!m[2])
|
|
712
|
+
continue;
|
|
713
|
+
if (m[4] === '/')
|
|
714
|
+
continue;
|
|
715
|
+
if (m[1]) {
|
|
716
|
+
const top = stack.pop();
|
|
717
|
+
if (top !== m[2])
|
|
718
|
+
return `</${m[2]}> closes <${top ?? 'nothing'}>`;
|
|
719
|
+
}
|
|
720
|
+
else
|
|
721
|
+
stack.push(m[2]);
|
|
722
|
+
}
|
|
723
|
+
if (stack.length)
|
|
724
|
+
return `<${stack[stack.length - 1]}> is never closed`;
|
|
725
|
+
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;)/.test(s.replace(/<!--[\s\S]*?-->/g, '')))
|
|
726
|
+
return 'a bare & (not escaped)';
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
/** systemd unit files: [Section] headers and Key=Value lines only. */
|
|
730
|
+
export function iniProblem(s) {
|
|
731
|
+
let section = false;
|
|
732
|
+
for (const [i, raw] of s.split('\n').entries()) {
|
|
733
|
+
const l = raw.trim();
|
|
734
|
+
if (!l || l.startsWith('#') || l.startsWith(';'))
|
|
735
|
+
continue;
|
|
736
|
+
if (/^\[[A-Za-z]+\]$/.test(l)) {
|
|
737
|
+
section = true;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (!section)
|
|
741
|
+
return `line ${i + 1}: a key before any [Section]`;
|
|
742
|
+
if (!/^[A-Za-z][A-Za-z0-9]*=/.test(l))
|
|
743
|
+
return `line ${i + 1}: not Key=Value`;
|
|
744
|
+
}
|
|
745
|
+
return section ? null : 'no [Section]';
|
|
746
|
+
}
|
|
747
|
+
const defaultExec = (cmd) => {
|
|
748
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8', timeout: 20_000 });
|
|
749
|
+
return { status: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? r.error.message : ''}` };
|
|
750
|
+
};
|
|
751
|
+
async function installCmd(argv, io, deps) {
|
|
752
|
+
const { opts } = parseArgs(argv, ['dry-run', 'load', 'json']);
|
|
753
|
+
const home = homeOf(io);
|
|
754
|
+
const platform = (typeof opts.platform === 'string' ? opts.platform : deps.platform ?? process.platform);
|
|
755
|
+
if (!['darwin', 'linux', 'win32'].includes(platform)) {
|
|
756
|
+
io.stderr.write(`dexterm autotrade service install: no installer for ${platform} (darwin, linux, win32)\n`);
|
|
757
|
+
return 2;
|
|
758
|
+
}
|
|
759
|
+
const userHome = deps.userHome ?? io.env.HOME ?? io.env.USERPROFILE ?? homedir();
|
|
760
|
+
const spec = installSpec(io, platform, userHome);
|
|
761
|
+
const plan = installPlan(spec, home);
|
|
762
|
+
const dry = !!opts['dry-run'];
|
|
763
|
+
const out = (s) => io.stdout.write(`${s}\n`);
|
|
764
|
+
out(`dexterm autotrade service install${dry ? ' --dry-run (nothing is written)' : ''}: ${platform}, ${plan.name}`);
|
|
765
|
+
out(`It runs \`dexterm autotrade service ensure${spec.dexHome ? ` --home ${spec.dexHome}` : ''}\` every minute and at login. Exactly these files:`);
|
|
766
|
+
for (const f of plan.files) {
|
|
767
|
+
out(`\n── ${f.path}${f.encoding === 'utf16le' ? ' (UTF-16 LE, as Task Scheduler wants)' : ''}`);
|
|
768
|
+
io.stdout.write(f.content);
|
|
769
|
+
}
|
|
770
|
+
const v = verifyPlan(plan, platform === process.platform ? deps.exec : () => ({ status: 0, out: '' }));
|
|
771
|
+
out('\nchecks:');
|
|
772
|
+
for (const l of v.lines)
|
|
773
|
+
out(` ${l}`);
|
|
774
|
+
if (!v.ok) {
|
|
775
|
+
io.stderr.write('dexterm autotrade service install: a generated file did not pass its check; nothing written\n');
|
|
776
|
+
return 1;
|
|
777
|
+
}
|
|
778
|
+
out(`\nto load it: ${plan.activate.map((c) => c.join(' ')).join(' && ')}`);
|
|
779
|
+
for (const n of plan.notes)
|
|
780
|
+
out(`note: ${n}`);
|
|
781
|
+
if (dry)
|
|
782
|
+
return 0;
|
|
783
|
+
if (platform !== process.platform && !deps.platform) {
|
|
784
|
+
io.stderr.write(`dexterm autotrade service install: this machine is ${process.platform}; --platform ${platform} only works with --dry-run\n`);
|
|
785
|
+
return 2;
|
|
786
|
+
}
|
|
787
|
+
const prev = readManifest(home);
|
|
788
|
+
for (const f of plan.files) {
|
|
789
|
+
mkdirSync(dirname(f.path), { recursive: true });
|
|
790
|
+
writeFileSync(f.path, encode(f.content, f.encoding));
|
|
791
|
+
}
|
|
792
|
+
mkdirSync(serviceLogDir(home), { recursive: true, mode: 0o700 });
|
|
793
|
+
const manifest = { schema: INSTALL_MANIFEST_SCHEMA, platform, label: plan.label, name: plan.name, installedAt: (deps.now ?? Date.now)(), files: plan.files.map((f) => ({ path: f.path, sha256: fileSha(f.content, f.encoding) })), deactivate: plan.deactivate, loaded: false };
|
|
794
|
+
// Files a previous install wrote that this one did not (a renamed label) are removed, so uninstall stays exact.
|
|
795
|
+
for (const f of prev?.files ?? [])
|
|
796
|
+
if (!plan.files.some((x) => x.path === f.path))
|
|
797
|
+
rmSync(f.path, { force: true });
|
|
798
|
+
mkdirSync(dirname(installManifestPath(home)), { recursive: true, mode: 0o700 });
|
|
799
|
+
writeFileSync(installManifestPath(home), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
800
|
+
out(`\nwrote ${plan.files.map((f) => f.path).join(', ')} (recorded in ${installManifestPath(home)})`);
|
|
801
|
+
if (!opts.load) {
|
|
802
|
+
out('NOT loaded: run the command above, or `dexterm autotrade service install --load`, once you have read it.');
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
const ex = deps.exec ?? defaultExec;
|
|
806
|
+
for (const [i, c] of plan.activate.entries()) {
|
|
807
|
+
const r = ex(c);
|
|
808
|
+
// The first macOS step (bootout) fails when nothing is loaded yet: that is fine.
|
|
809
|
+
if (r.status !== 0 && !(platform === 'darwin' && i === 0)) {
|
|
810
|
+
io.stderr.write(`dexterm autotrade service install: ${c.join(' ')} exited ${r.status}: ${r.out.trim().slice(0, 300)}\n`);
|
|
811
|
+
return 1;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
manifest.loaded = true;
|
|
815
|
+
writeFileSync(installManifestPath(home), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
816
|
+
out('✓ loaded: the service starts within a minute (`dexterm autotrade service status`)');
|
|
817
|
+
return 0;
|
|
818
|
+
}
|
|
819
|
+
async function uninstallCmd(argv, io, deps) {
|
|
820
|
+
const { opts } = parseArgs(argv, ['flatten', 'yes', 'force', 'dry-run', 'json']);
|
|
821
|
+
const home = homeOf(io);
|
|
822
|
+
const m = readManifest(home);
|
|
823
|
+
const out = (s) => io.stdout.write(`${s}\n`);
|
|
824
|
+
if (!m) {
|
|
825
|
+
out(`nothing installed for this home (no ${installManifestPath(home)})`);
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
828
|
+
const r = activeRunner(home);
|
|
829
|
+
if (r && r.positions > 0 && !opts.flatten) {
|
|
830
|
+
io.stderr.write(`dexterm autotrade service uninstall: ${r.positions} position${r.positions === 1 ? ' is' : 's are'} open. Stop first and let the exits close them (\`dexterm autotrade service stop\`), or sell them now with --flatten --yes\n`);
|
|
831
|
+
return 1;
|
|
832
|
+
}
|
|
833
|
+
if (opts.flatten && !opts.yes) {
|
|
834
|
+
io.stderr.write('dexterm autotrade service uninstall --flatten sells every open paper position: add --yes to confirm\n');
|
|
835
|
+
return 2;
|
|
836
|
+
}
|
|
837
|
+
if (opts['dry-run']) {
|
|
838
|
+
out(`would stop the service, run ${m.deactivate.map((c) => c.join(' ')).join(' && ')}, and remove:`);
|
|
839
|
+
for (const f of m.files)
|
|
840
|
+
out(` ${f.path}`);
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
const s = await stopService(io, opts.flatten ? 'flatten' : 'stop', 'uninstall', waitOf(opts.wait));
|
|
844
|
+
for (const l of s.lines)
|
|
845
|
+
out(l);
|
|
846
|
+
const ex = deps.exec ?? defaultExec;
|
|
847
|
+
if (m.loaded || m.platform === 'darwin')
|
|
848
|
+
for (const c of m.deactivate) {
|
|
849
|
+
const x = ex(c);
|
|
850
|
+
out(`${c.join(' ')}: ${x.status === 0 ? 'ok' : `exit ${x.status} (${x.out.trim().slice(0, 120) || 'not loaded'})`}`);
|
|
851
|
+
}
|
|
852
|
+
let kept = 0;
|
|
853
|
+
for (const f of m.files) {
|
|
854
|
+
if (!existsSync(f.path)) {
|
|
855
|
+
out(`already gone: ${f.path}`);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const cur = readFileSync(f.path);
|
|
859
|
+
const sha = (await import('node:crypto')).createHash('sha256').update(cur).digest('hex');
|
|
860
|
+
if (sha !== f.sha256 && !opts.force) {
|
|
861
|
+
kept++;
|
|
862
|
+
io.stderr.write(`kept ${f.path}: it changed since install (remove it yourself, or --force)\n`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
rmSync(f.path, { force: true });
|
|
866
|
+
out(`removed ${f.path}`);
|
|
867
|
+
}
|
|
868
|
+
if (kept)
|
|
869
|
+
return 1;
|
|
870
|
+
rmSync(installManifestPath(home), { force: true });
|
|
871
|
+
out('✓ uninstalled: the OS job is gone; the service stays stopped');
|
|
872
|
+
return 0;
|
|
873
|
+
}
|