@almanak/mcp-server 0.1.14 → 0.1.15

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.
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * Thin HTTP client for the test-controller sidecar.
4
+ *
5
+ * The controller is a Python sidecar (see `almanak/test_controller/__main__.py`
6
+ * in the SDK repo) that lives in its own Cloud Run container, holds the
7
+ * privileged RPC env, and exposes ``/start_gateway`` + ``/stop_gateway`` over
8
+ * localhost. The MCP `run_test` tool talks to it from the worker container to
9
+ * spin up a fresh per-test gateway, run the SDK ladder, then tear it down.
10
+ *
11
+ * No external HTTP dep — uses Node's stdlib so this package stays zero-dep.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.TestControllerClient = void 0;
48
+ const http = __importStar(require("http"));
49
+ const https = __importStar(require("https"));
50
+ class TestControllerClient {
51
+ constructor(config) {
52
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
53
+ this.timeoutMs = config.timeoutMs;
54
+ }
55
+ async health() {
56
+ return this.request('GET', '/health');
57
+ }
58
+ async startGateway(workspacePath) {
59
+ return this.request('POST', '/start_gateway', { workspace_path: workspacePath });
60
+ }
61
+ async stopGateway() {
62
+ return this.request('POST', '/stop_gateway');
63
+ }
64
+ request(method, path, body) {
65
+ return new Promise((resolve, reject) => {
66
+ const url = new URL(this.baseUrl + path);
67
+ const headers = { Accept: 'application/json' };
68
+ let bodyStr;
69
+ if (body !== undefined) {
70
+ bodyStr = JSON.stringify(body);
71
+ headers['Content-Type'] = 'application/json';
72
+ headers['Content-Length'] = Buffer.byteLength(bodyStr).toString();
73
+ }
74
+ const transport = url.protocol === 'https:' ? https : http;
75
+ const req = transport.request(url, { method, headers, timeout: this.timeoutMs }, (res) => {
76
+ const chunks = [];
77
+ res.on('data', (c) => chunks.push(c));
78
+ res.on('end', () => {
79
+ var _a, _b;
80
+ const raw = Buffer.concat(chunks).toString('utf-8');
81
+ let parsed;
82
+ try {
83
+ parsed = raw ? JSON.parse(raw) : {};
84
+ }
85
+ catch {
86
+ reject(new Error(`controller ${method} ${path} returned non-JSON (status=${res.statusCode}): ${raw.slice(0, 200)}`));
87
+ return;
88
+ }
89
+ if (res.statusCode && res.statusCode >= 400) {
90
+ const detail = (_b = (_a = parsed === null || parsed === void 0 ? void 0 : parsed.detail) !== null && _a !== void 0 ? _a : parsed === null || parsed === void 0 ? void 0 : parsed.error) !== null && _b !== void 0 ? _b : raw.slice(0, 200);
91
+ reject(new Error(`controller ${method} ${path} -> HTTP ${res.statusCode}: ${detail}`));
92
+ return;
93
+ }
94
+ resolve(parsed);
95
+ });
96
+ });
97
+ req.on('error', (e) => reject(new Error(`controller ${method} ${path} failed: ${e.message}`)));
98
+ req.on('timeout', () => {
99
+ req.destroy();
100
+ reject(new Error(`controller ${method} ${path} timed out after ${this.timeoutMs}ms`));
101
+ });
102
+ if (bodyStr !== undefined)
103
+ req.write(bodyStr);
104
+ req.end();
105
+ });
106
+ }
107
+ }
108
+ exports.TestControllerClient = TestControllerClient;
109
+ //# sourceMappingURL=controller-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controller-client.js","sourceRoot":"","sources":["../../src/testing/controller-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Orchestrates the SDK test ladder against a workspace.
3
+ *
4
+ * The flow:
5
+ * 1. ``imports`` — ``python -c 'from strategy import *'``
6
+ * 2. ``unit`` — ``pytest tests/ -v --tb=short`` (skipped if no tests/ dir)
7
+ * 3. ``smoke`` — ``almanak strat run --network anvil --once --no-gateway ...``
8
+ * 4. ``lifecycle`` — ``almanak strat test --no-gateway --json ...``
9
+ *
10
+ * Steps 1+2 are fail-fast (no point booting a gateway if imports break).
11
+ * Steps 3+4 share a gateway provisioned via the test-controller sidecar;
12
+ * the gateway is torn down in ``finally`` whether the ladder passes or not.
13
+ *
14
+ * Step 4's ``--json`` summary is parsed out of stdout and surfaced on the
15
+ * step result so the agent doesn't have to re-parse mixed output.
16
+ *
17
+ * Privileged env (Alchemy / Etherscan / etc.) is stripped before spawning
18
+ * subprocesses — the SDK CLI talks to the gateway over localhost via
19
+ * ``--no-gateway`` and never needs the upstream RPC URL directly.
20
+ */
21
+ import { TestControllerClient } from './controller-client';
22
+ export interface RunTestArgs {
23
+ workspacePath: string;
24
+ actions?: string[];
25
+ teardown?: boolean;
26
+ /**
27
+ * Optional file path. When set, the full stdout + stderr of every ladder
28
+ * step is appended to this file (with per-step headers). Caller is
29
+ * responsible for the parent dir existing. When unset, only the truncated
30
+ * tails on each step result carry log content.
31
+ */
32
+ logPath?: string;
33
+ }
34
+ export type StepName = 'imports' | 'unit' | 'smoke' | 'lifecycle';
35
+ export interface StepResult {
36
+ name: StepName;
37
+ passed: boolean;
38
+ exit_code: number | null;
39
+ duration_ms: number;
40
+ stdout_tail: string;
41
+ stderr_tail: string;
42
+ /** Only set on the ``lifecycle`` step — the strat-test ``--json`` payload. */
43
+ summary?: Record<string, any> | null;
44
+ /** Set when fail-fast or a prior dependency failure caused this step to be skipped. */
45
+ skipped_reason?: string;
46
+ }
47
+ export interface RunTestResult {
48
+ all_passed: boolean;
49
+ steps: StepResult[];
50
+ }
51
+ export declare function tail(s: string, max?: number): string;
52
+ /**
53
+ * Extract the last top-level JSON object from a mixed stdout stream.
54
+ *
55
+ * ``strat test --json`` emits its summary as the last top-level object after
56
+ * setup chatter; we use a depth-counting scan instead of greedy regex so
57
+ * braces inside strings don't confuse the parse.
58
+ */
59
+ export declare function extractLastJsonObject(text: string): Record<string, any> | null;
60
+ export declare function runTestLadder(args: RunTestArgs, controller: TestControllerClient): Promise<RunTestResult>;
@@ -0,0 +1,472 @@
1
+ "use strict";
2
+ /**
3
+ * Orchestrates the SDK test ladder against a workspace.
4
+ *
5
+ * The flow:
6
+ * 1. ``imports`` — ``python -c 'from strategy import *'``
7
+ * 2. ``unit`` — ``pytest tests/ -v --tb=short`` (skipped if no tests/ dir)
8
+ * 3. ``smoke`` — ``almanak strat run --network anvil --once --no-gateway ...``
9
+ * 4. ``lifecycle`` — ``almanak strat test --no-gateway --json ...``
10
+ *
11
+ * Steps 1+2 are fail-fast (no point booting a gateway if imports break).
12
+ * Steps 3+4 share a gateway provisioned via the test-controller sidecar;
13
+ * the gateway is torn down in ``finally`` whether the ladder passes or not.
14
+ *
15
+ * Step 4's ``--json`` summary is parsed out of stdout and surfaced on the
16
+ * step result so the agent doesn't have to re-parse mixed output.
17
+ *
18
+ * Privileged env (Alchemy / Etherscan / etc.) is stripped before spawning
19
+ * subprocesses — the SDK CLI talks to the gateway over localhost via
20
+ * ``--no-gateway`` and never needs the upstream RPC URL directly.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.tail = tail;
24
+ exports.extractLastJsonObject = extractLastJsonObject;
25
+ exports.runTestLadder = runTestLadder;
26
+ const child_process_1 = require("child_process");
27
+ const fs_1 = require("fs");
28
+ const path_1 = require("path");
29
+ /** Per-step subprocess timeouts (ms). Smoke + lifecycle hit Anvil; give them headroom. */
30
+ const STEP_TIMEOUT_MS = {
31
+ imports: 60000,
32
+ unit: 300000,
33
+ smoke: 300000,
34
+ lifecycle: 600000,
35
+ };
36
+ const STDOUT_TAIL_BYTES = 8192;
37
+ /** Default Anvil account #0 — public test key, safe to embed. */
38
+ const ANVIL_DEFAULT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
39
+ // Exported for tests; production callers reach it via runStep.
40
+ function tail(s, max = STDOUT_TAIL_BYTES) {
41
+ if (!s || s.length <= max)
42
+ return s;
43
+ // String-level slice — Buffer.subarray at an arbitrary byte offset can split
44
+ // a multi-byte UTF-8 character mid-codepoint and produce invalid UTF-8 at
45
+ // the leading boundary. Slicing the string operates on code units instead,
46
+ // which is safe for the diagnostic-only stdout/stderr tail we emit here.
47
+ return `[...${s.length - max} chars truncated...]\n${s.slice(-max)}`;
48
+ }
49
+ /**
50
+ * Extract the last top-level JSON object from a mixed stdout stream.
51
+ *
52
+ * ``strat test --json`` emits its summary as the last top-level object after
53
+ * setup chatter; we use a depth-counting scan instead of greedy regex so
54
+ * braces inside strings don't confuse the parse.
55
+ */
56
+ // Exported for tests; production callers reach it via runStep with parseSummary=true.
57
+ function extractLastJsonObject(text) {
58
+ if (!text)
59
+ return null;
60
+ const starts = [];
61
+ // Match every '{' that begins a line — those are the candidates for a
62
+ // top-level JSON object. Inside-line braces (inside strings or nested
63
+ // structures) are skipped.
64
+ const re = /^\{/gm;
65
+ let m;
66
+ while ((m = re.exec(text)) !== null)
67
+ starts.push(m.index);
68
+ for (let i = starts.length - 1; i >= 0; i--) {
69
+ const slice = text.substring(starts[i]);
70
+ const end = findJsonObjectEnd(slice);
71
+ if (end < 0)
72
+ continue;
73
+ try {
74
+ const parsed = JSON.parse(slice.substring(0, end));
75
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
76
+ return parsed;
77
+ }
78
+ }
79
+ catch {
80
+ // try the next earlier candidate
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ function findJsonObjectEnd(s) {
86
+ let depth = 0;
87
+ let inStr = false;
88
+ let esc = false;
89
+ for (let i = 0; i < s.length; i++) {
90
+ const c = s[i];
91
+ if (esc) {
92
+ esc = false;
93
+ continue;
94
+ }
95
+ if (c === '\\') {
96
+ esc = true;
97
+ continue;
98
+ }
99
+ if (c === '"') {
100
+ inStr = !inStr;
101
+ continue;
102
+ }
103
+ if (inStr)
104
+ continue;
105
+ if (c === '{')
106
+ depth++;
107
+ else if (c === '}') {
108
+ depth--;
109
+ if (depth === 0)
110
+ return i + 1;
111
+ }
112
+ }
113
+ return -1;
114
+ }
115
+ function processEnv() {
116
+ const env = {};
117
+ for (const [k, v] of Object.entries(process.env)) {
118
+ if (v !== undefined)
119
+ env[k] = v;
120
+ }
121
+ return env;
122
+ }
123
+ /**
124
+ * Preflight: confirm that the ``python`` binary on PATH can import ``almanak``.
125
+ *
126
+ * Without this we'd hit a ``ModuleNotFoundError`` mid-ladder on Step 1 and
127
+ * the caller sees a Python traceback instead of an actionable diagnostic.
128
+ * Fails loudly with a single-line error that tells the caller exactly which
129
+ * Python was used + what to do about it.
130
+ *
131
+ * Times out at 5s; in practice this is sub-second.
132
+ */
133
+ async function preflightCheckSdkAvailable(env) {
134
+ return new Promise((resolve, reject) => {
135
+ var _a, _b;
136
+ const proc = (0, child_process_1.spawn)('python', ['-c', 'import almanak; import sys; print(almanak.__file__, sys.executable)'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
137
+ const stderrChunks = [];
138
+ (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', () => { });
139
+ (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (c) => stderrChunks.push(c));
140
+ const timer = setTimeout(() => {
141
+ proc.kill('SIGKILL');
142
+ reject(new Error('preflight: python -c "import almanak" timed out after 5s. Check that ' +
143
+ 'the worker container has the SDK installed and PATH points at the right ' +
144
+ 'Python interpreter.'));
145
+ }, 5000);
146
+ proc.on('error', (e) => {
147
+ clearTimeout(timer);
148
+ reject(new Error(`preflight: could not spawn 'python' (${e.message}). The worker container ` +
149
+ "must have 'python' on PATH."));
150
+ });
151
+ // Use 'close' (not 'exit') so the child's stderr stream has fully flushed
152
+ // before we read it. 'exit' can fire while stderrChunks is still being
153
+ // populated, truncating the diagnostic the user sees on a real failure.
154
+ proc.on('close', (code) => {
155
+ clearTimeout(timer);
156
+ if (code === 0) {
157
+ resolve();
158
+ return;
159
+ }
160
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
161
+ reject(new Error(`preflight: 'python -c "import almanak"' failed (exit=${code}). Check that ` +
162
+ 'PATH points at a Python that has the almanak SDK installed. ' +
163
+ `python stderr: ${stderr.slice(0, 500)}`));
164
+ });
165
+ });
166
+ }
167
+ async function runStep(name, argv, cwd, env, timeoutMs, parseSummary, logPath) {
168
+ const start = Date.now();
169
+ return new Promise((resolve) => {
170
+ var _a, _b;
171
+ const stdoutChunks = [];
172
+ const stderrChunks = [];
173
+ let proc;
174
+ try {
175
+ proc = (0, child_process_1.spawn)(argv[0], argv.slice(1), {
176
+ cwd,
177
+ env,
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ });
180
+ }
181
+ catch (e) {
182
+ resolve({
183
+ name,
184
+ passed: false,
185
+ exit_code: null,
186
+ duration_ms: Date.now() - start,
187
+ stdout_tail: '',
188
+ stderr_tail: `failed to spawn ${argv[0]}: ${e.message}`,
189
+ });
190
+ return;
191
+ }
192
+ let timedOut = false;
193
+ const timer = setTimeout(() => {
194
+ timedOut = true;
195
+ proc.kill('SIGKILL');
196
+ }, timeoutMs);
197
+ // Guard against the (theoretically possible) case where both ``error``
198
+ // and ``exit`` fire on the same subprocess. Promise.resolve is idempotent,
199
+ // but the listener body still runs each time and would log duplicates /
200
+ // do redundant work. One-shot resolve makes the intent explicit.
201
+ let settled = false;
202
+ const settle = (r) => {
203
+ if (settled)
204
+ return;
205
+ settled = true;
206
+ clearTimeout(timer);
207
+ resolve(r);
208
+ };
209
+ (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (c) => stdoutChunks.push(c));
210
+ (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (c) => stderrChunks.push(c));
211
+ proc.on('error', (e) => {
212
+ settle({
213
+ name,
214
+ passed: false,
215
+ exit_code: null,
216
+ duration_ms: Date.now() - start,
217
+ stdout_tail: tail(Buffer.concat(stdoutChunks).toString('utf-8')),
218
+ stderr_tail: `subprocess error: ${e.message}`,
219
+ });
220
+ });
221
+ // Use 'close' (not 'exit'): 'exit' fires when the process exits but stdio
222
+ // streams may not yet be flushed, which can truncate the captured output.
223
+ // 'close' fires after both 'exit' AND all stdio handles have emitted 'end'.
224
+ // Critical for the lifecycle step whose JSON summary is parsed from stdout.
225
+ proc.on('close', (code) => {
226
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
227
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
228
+ const result = {
229
+ name,
230
+ passed: code === 0 && !timedOut,
231
+ exit_code: code,
232
+ duration_ms: Date.now() - start,
233
+ stdout_tail: tail(stdout),
234
+ stderr_tail: timedOut
235
+ ? `[TIMEOUT: exceeded ${timeoutMs}ms]\n${tail(stderr)}`
236
+ : tail(stderr),
237
+ };
238
+ if (parseSummary) {
239
+ result.summary = extractLastJsonObject(stdout);
240
+ }
241
+ if (logPath !== undefined) {
242
+ // Append this step's block to the shared log file. We don't fail the
243
+ // step on a log write error — the response still carries the tail.
244
+ const block = `\n===== step: ${name} =====\n` +
245
+ `argv: ${argv.join(' ')}\n` +
246
+ `cwd: ${cwd}\n` +
247
+ `exit_code: ${code}\n` +
248
+ `duration_ms: ${Date.now() - start}\n` +
249
+ (timedOut ? `TIMEOUT: exceeded ${timeoutMs}ms\n` : '') +
250
+ `--- stdout ---\n${stdout}` +
251
+ (stdout.endsWith('\n') ? '' : '\n') +
252
+ `--- stderr ---\n${stderr}` +
253
+ (stderr.endsWith('\n') ? '' : '\n');
254
+ try {
255
+ (0, fs_1.appendFileSync)(logPath, block);
256
+ }
257
+ catch (e) {
258
+ process.stderr.write(`[run_test] WARNING: failed to append log to ${logPath}: ${e.message}\n`);
259
+ }
260
+ }
261
+ settle(result);
262
+ });
263
+ });
264
+ }
265
+ function skipped(name, reason) {
266
+ return {
267
+ name,
268
+ passed: false,
269
+ exit_code: null,
270
+ duration_ms: 0,
271
+ stdout_tail: '',
272
+ stderr_tail: '',
273
+ skipped_reason: reason,
274
+ };
275
+ }
276
+ /** Skip reasons that don't count as a ladder failure (e.g., no tests dir present). */
277
+ const BENIGN_SKIP_PREFIXES = ['no tests/'];
278
+ function isBenignSkip(reason) {
279
+ if (!reason)
280
+ return false;
281
+ return BENIGN_SKIP_PREFIXES.some((p) => reason.startsWith(p));
282
+ }
283
+ async function runTestLadder(args, controller) {
284
+ var _a;
285
+ const workspace = args.workspacePath;
286
+ if (!(0, fs_1.existsSync)(workspace) || !(0, fs_1.statSync)(workspace).isDirectory()) {
287
+ throw new Error(`workspace_path is not a directory: ${workspace}`);
288
+ }
289
+ if (!(0, fs_1.existsSync)((0, path_1.join)(workspace, 'strategy.py'))) {
290
+ throw new Error(`workspace missing strategy.py: ${workspace}`);
291
+ }
292
+ if (!(0, fs_1.existsSync)((0, path_1.join)(workspace, 'config.json'))) {
293
+ throw new Error(`workspace missing config.json: ${workspace}`);
294
+ }
295
+ const teardown = args.teardown !== false; // default true
296
+ const actions = (_a = args.actions) !== null && _a !== void 0 ? _a : [];
297
+ // Resolve + sandbox the caller-supplied log path to inside the workspace.
298
+ // Without this, any tool caller could overwrite arbitrary container files
299
+ // (e.g. ``/etc/passwd``) by passing an absolute path or a ``..``-traversing
300
+ // relative path. The workspace itself is already the agent's working area,
301
+ // so restricting writes there does not block legitimate use.
302
+ const workspaceResolved = (0, path_1.resolve)(workspace);
303
+ let logPath;
304
+ if (args.logPath !== undefined) {
305
+ const candidate = (0, path_1.isAbsolute)(args.logPath)
306
+ ? (0, path_1.resolve)(args.logPath)
307
+ : (0, path_1.resolve)(workspaceResolved, args.logPath);
308
+ const rel = (0, path_1.relative)(workspaceResolved, candidate);
309
+ if (rel.startsWith('..') || (0, path_1.isAbsolute)(rel)) {
310
+ throw new Error(`log_path must be inside workspace_path (got ${args.logPath})`);
311
+ }
312
+ logPath = candidate;
313
+ }
314
+ // Truncate (or create) the log file up front so retries don't accumulate
315
+ // output from a previous run on the same path. Caller owns parent dir.
316
+ if (logPath !== undefined) {
317
+ try {
318
+ (0, fs_1.writeFileSync)(logPath, `# run_test ladder log — ${new Date().toISOString()}\n# workspace: ${workspace}\n`);
319
+ }
320
+ catch (e) {
321
+ throw new Error(`failed to open log_path ${logPath}: ${e.message}`);
322
+ }
323
+ }
324
+ const steps = [];
325
+ const env = processEnv();
326
+ // Preflight before any step runs — a misconfigured worker (Python missing
327
+ // or SDK not installed in the active env) would otherwise produce a Step 1
328
+ // ModuleNotFoundError traceback that's hard to read. Surface the actionable
329
+ // diagnostic upfront instead.
330
+ await preflightCheckSdkAvailable(env);
331
+ // ---- Step 1: imports ----
332
+ const step1 = await runStep('imports', ['python', '-c', 'from strategy import *'], workspace, env, STEP_TIMEOUT_MS.imports, false, logPath);
333
+ steps.push(step1);
334
+ if (!step1.passed) {
335
+ steps.push(skipped('unit', 'imports failed'));
336
+ steps.push(skipped('smoke', 'imports failed'));
337
+ steps.push(skipped('lifecycle', 'imports failed'));
338
+ return { all_passed: false, steps };
339
+ }
340
+ // ---- Step 2: pytest (skipped if no tests/ dir) ----
341
+ const testsDir = (0, path_1.join)(workspace, 'tests');
342
+ let step2;
343
+ if ((0, fs_1.existsSync)(testsDir) && (0, fs_1.statSync)(testsDir).isDirectory()) {
344
+ step2 = await runStep('unit', ['pytest', 'tests/', '-v', '--tb=short'], workspace, env, STEP_TIMEOUT_MS.unit, false, logPath);
345
+ }
346
+ else {
347
+ step2 = skipped('unit', 'no tests/ directory in workspace');
348
+ }
349
+ steps.push(step2);
350
+ if (!step2.passed && !isBenignSkip(step2.skipped_reason)) {
351
+ steps.push(skipped('smoke', 'unit tests failed'));
352
+ steps.push(skipped('lifecycle', 'unit tests failed'));
353
+ return { all_passed: false, steps };
354
+ }
355
+ // ---- Step 3: smoke (own gateway lifecycle) ----
356
+ // Each gateway-bound step gets its OWN start/stop cycle so each lands on a
357
+ // freshly-forked Anvil. Sharing one gateway across smoke + lifecycle leaves
358
+ // smoke's on-chain mutations (a buy that opens a position, an approval, …)
359
+ // visible to lifecycle, which then sees pre-existing positions when it
360
+ // tries to drive `force_action=open` from a clean slate.
361
+ //
362
+ // --fresh on `strat run` clears any prior almanak_state.db in the workspace
363
+ // (state.db is workspace-scoped, not anvil-scoped, so a stop/start of the
364
+ // gateway doesn't touch it). `strat test` handles its own state clearing
365
+ // internally (cli.py: fresh=True), so we only pass --fresh on the smoke step.
366
+ //
367
+ // If the test-controller isn't reachable (ECONNREFUSED — typical local-dev
368
+ // shape where no sidecar is running), runGatewayStep falls back to the
369
+ // SDK's ManagedGateway path: argv omits --no-gateway/--gateway-host/--port,
370
+ // env omits ALMANAK_GATEWAY_*, and the SDK boots/tears down its own Anvil
371
+ // per step. Identical UX in cloud vs local — `run_test` "just works."
372
+ const step3 = await runGatewayStep(controller, workspace, 'smoke', (port) => {
373
+ const argv = [
374
+ 'almanak', 'strat', 'run',
375
+ '--network', 'anvil', '--once', '--fresh',
376
+ '-d', workspace,
377
+ ];
378
+ if (port !== null) {
379
+ argv.push('--no-gateway', '--gateway-host', 'localhost', '--gateway-port', String(port));
380
+ }
381
+ return argv;
382
+ }, env, STEP_TIMEOUT_MS.smoke, false, logPath);
383
+ steps.push(step3);
384
+ // Do NOT fail-fast on Step 3 — its failure can be informative for the
385
+ // lifecycle diagnosis below, and the lifecycle often surfaces the same
386
+ // root cause with more detail in its JSON summary.
387
+ // ---- Step 4: lifecycle (own gateway lifecycle, fresh Anvil fork) ----
388
+ const step4 = await runGatewayStep(controller, workspace, 'lifecycle', (port) => {
389
+ const argv = [
390
+ 'almanak', 'strat', 'test',
391
+ '--json',
392
+ '-d', workspace,
393
+ ];
394
+ if (port !== null) {
395
+ argv.push('--no-gateway', '--gateway-host', 'localhost', '--gateway-port', String(port));
396
+ }
397
+ if (actions.length > 0)
398
+ argv.push('--actions', actions.join(','));
399
+ if (teardown)
400
+ argv.push('--teardown');
401
+ return argv;
402
+ }, env, STEP_TIMEOUT_MS.lifecycle, true, logPath);
403
+ steps.push(step4);
404
+ // A step "passes" for ladder purposes if it actually ran successfully OR
405
+ // was a benign skip (e.g., no tests/ directory). Any other failure flips
406
+ // all_passed to false.
407
+ const all_passed = steps.every((s) => s.passed || isBenignSkip(s.skipped_reason));
408
+ return { all_passed, steps };
409
+ }
410
+ /** True if the controller-client error wraps a transport-level "nothing
411
+ * listening" signal — typical local-dev shape where the sidecar isn't
412
+ * running. Other failures (controller running but returning 5xx, timing
413
+ * out mid-request, hitting an unknown route) bubble up as real errors. */
414
+ function isControllerUnreachable(e) {
415
+ var _a;
416
+ const msg = (_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : '';
417
+ return msg.includes('ECONNREFUSED');
418
+ }
419
+ /**
420
+ * Run one step with a gateway. Two modes:
421
+ * 1. Controller-managed: start a gateway via the sidecar, run with --no-gateway
422
+ * pointing at it, stop the gateway after — always, even on exception.
423
+ * 2. SDK-managed (fallback when controller isn't listening): skip start/stop,
424
+ * run without --no-gateway so the SDK boots its own Anvil + ManagedGateway.
425
+ *
426
+ * The fallback only fires on ECONNREFUSED. A controller that's up but unhealthy
427
+ * (timeout, 5xx) bubbles up as a real failure step — silent fallback there
428
+ * would hide a misbehaving sidecar.
429
+ */
430
+ async function runGatewayStep(controller, workspace, name, buildArgv, env, timeoutMs, parseSummary, logPath) {
431
+ let port = null;
432
+ try {
433
+ const startResp = await controller.startGateway(workspace);
434
+ port = startResp.port;
435
+ }
436
+ catch (e) {
437
+ if (!isControllerUnreachable(e)) {
438
+ return {
439
+ name,
440
+ passed: false,
441
+ exit_code: null,
442
+ duration_ms: 0,
443
+ stdout_tail: '',
444
+ stderr_tail: `gateway startup failed: ${e.message}`,
445
+ };
446
+ }
447
+ // Local-dev fallback: controller not listening — let the SDK manage its own gateway.
448
+ process.stderr.write(`[run_test] test-controller not reachable; using SDK-managed gateway for ${name}\n`);
449
+ }
450
+ const gwEnv = {
451
+ ...env,
452
+ ALMANAK_PRIVATE_KEY: ANVIL_DEFAULT_PRIVATE_KEY,
453
+ };
454
+ if (port !== null) {
455
+ gwEnv.ALMANAK_GATEWAY_HOST = 'localhost';
456
+ gwEnv.ALMANAK_GATEWAY_PORT = String(port);
457
+ }
458
+ try {
459
+ return await runStep(name, buildArgv(port), workspace, gwEnv, timeoutMs, parseSummary, logPath);
460
+ }
461
+ finally {
462
+ if (port !== null) {
463
+ try {
464
+ await controller.stopGateway();
465
+ }
466
+ catch (e) {
467
+ process.stderr.write(`[run_test] WARNING: /stop_gateway after ${name} failed: ${e.message}\n`);
468
+ }
469
+ }
470
+ }
471
+ }
472
+ //# sourceMappingURL=runner.js.map