runspec-node 0.11.1 → 0.13.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/src/serve.ts CHANGED
@@ -139,13 +139,26 @@ function handleToolsCall(
139
139
  const runspecEnv = argsToRunspecEnv(args, toolArgSpecs);
140
140
  const env = { ...process.env, RUNSPEC_AGENT: '1', ...runspecEnv };
141
141
 
142
+ const start = process.hrtime.bigint();
142
143
  const result = spawnSync(cmd, argv, { encoding: 'utf-8', env });
144
+ const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n);
145
+
146
+ // _meta is the MCP-standard extension point; clients that don't understand
147
+ // it ignore the block. Same envelope on success and failure so callers can
148
+ // rely on it being present.
149
+ const meta = {
150
+ runspec: { tool: name, duration_ms: durationMs, exit_code: result.status ?? null },
151
+ };
143
152
 
144
153
  if (result.status === 0) {
145
154
  return {
146
155
  jsonrpc: '2.0',
147
156
  id: reqId,
148
- result: { content: [{ type: 'text', text: result.stdout ?? '' }], isError: false },
157
+ result: {
158
+ content: [{ type: 'text', text: result.stdout ?? '' }],
159
+ isError: false,
160
+ _meta: meta,
161
+ },
149
162
  };
150
163
  }
151
164
 
@@ -156,7 +169,11 @@ function handleToolsCall(
156
169
  return {
157
170
  jsonrpc: '2.0',
158
171
  id: reqId,
159
- result: { content: [{ type: 'text', text: parts.join('\n') }], isError: true },
172
+ result: {
173
+ content: [{ type: 'text', text: parts.join('\n') }],
174
+ isError: true,
175
+ _meta: meta,
176
+ },
160
177
  };
161
178
  }
162
179
 
@@ -192,7 +209,7 @@ function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string
192
209
  if (value === null || value === undefined) value = s['default'];
193
210
  if (value === null || value === undefined) continue;
194
211
 
195
- const envKey = 'RUNSPEC_' + argName.toUpperCase().replace(/-/g, '_').replace(/\./g, '_');
212
+ const envKey = 'RUNSPEC_ARG_' + argName.toUpperCase().replace(/-/g, '_').replace(/\./g, '_');
196
213
  const argType = (s['type'] as string) ?? 'str';
197
214
 
198
215
  if (argType === 'flag' || argType === 'bool') {
@@ -123,7 +123,7 @@ describe('complex.toml', () => {
123
123
  const raw = loadRaw(COMPLEX);
124
124
  const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
125
125
  const run = inferred.commands['run'];
126
- expect(run.args['api-key'].env).toBe('PIPELINE_API_KEY');
126
+ expect(run.args['api-key'].env).toEqual(['PIPELINE_API_KEY']);
127
127
  expect(run.args['api-key'].autonomy).toBe('manual');
128
128
  });
129
129
 
@@ -145,7 +145,7 @@ test('normalises [config.logging] with defaults', () => {
145
145
  description = "hi"
146
146
  `);
147
147
  const raw = loadRaw(file);
148
- expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7 });
148
+ expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7, summary: true });
149
149
  });
150
150
 
151
151
  test('normalises [config.logging] all fields', () => {
@@ -153,14 +153,28 @@ test('normalises [config.logging] all fields', () => {
153
153
  const file = path.join(dir, 'runspec.toml');
154
154
  fs.writeFileSync(file, `
155
155
  [config.logging]
156
- rotate = "10 MB"
157
- keep = 3
156
+ rotate = "10 MB"
157
+ keep = 3
158
+ summary = false
158
159
 
159
160
  [greet]
160
161
  description = "hi"
161
162
  `);
162
163
  const raw = loadRaw(file);
163
- expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3 });
164
+ expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3, summary: false });
165
+ });
166
+
167
+ test('[config.logging] summary defaults to true when omitted', () => {
168
+ const dir = tmpDir();
169
+ const file = path.join(dir, 'runspec.toml');
170
+ fs.writeFileSync(file, `
171
+ [config.logging]
172
+ rotate = "midnight"
173
+
174
+ [greet]
175
+ description = "hi"
176
+ `);
177
+ expect(loadRaw(file).config.logging?.summary).toBe(true);
164
178
  });
165
179
 
166
180
  test('logging is undefined when section absent', () => {
@@ -10,13 +10,20 @@ import {
10
10
  } from '../src/logging_setup';
11
11
 
12
12
  function tmpDir(): string {
13
- return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-log-test-'));
13
+ // Each test dir is its own project root so resolveLogDir lands inside it.
14
+ // Without a package.json the walk-up would escape to a real project root
15
+ // and write logs to a shared location — drop a marker file to anchor it.
16
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-log-test-'));
17
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"test","version":"0.0.0"}');
18
+ return d;
14
19
  }
15
20
 
16
21
  function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
17
22
  const { debug, ...logOverrides } = overrides as { debug?: boolean } & Record<string, unknown>;
18
23
  return {
19
- logCfg: { rotate: 'midnight', keep: 7, ...logOverrides },
24
+ // summary defaults off in tests so we don't litter atexit emissions across
25
+ // unrelated assertions; the run-summary suite enables it explicitly.
26
+ logCfg: { rotate: 'midnight', keep: 7, summary: false, ...logOverrides },
20
27
  runnableName: 'myscript',
21
28
  configPath: path.join(dir, 'runspec.toml'),
22
29
  debug,
@@ -365,7 +372,7 @@ test('_periodForDate weekly: adjacent weeks differ', () => {
365
372
 
366
373
  // ── log dir fallback ──────────────────────────────────────────────────────────
367
374
 
368
- test('falls back to ~/logs when package dir is not writable', () => {
375
+ test('falls back to ~/logs when the project root is not writable', () => {
369
376
  const dir = tmpDir();
370
377
  const logsDir = path.join(dir, 'logs');
371
378
  // Create logs dir as unwritable
@@ -0,0 +1,67 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { parse } from '../src/parser';
5
+
6
+ function makeTmpConfig(toml: string): string {
7
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
8
+ const configPath = path.join(dir, 'runspec.toml');
9
+ fs.writeFileSync(configPath, toml);
10
+ return configPath;
11
+ }
12
+
13
+ const QUALITY_TOML = `
14
+ [compress]
15
+ [compress.args]
16
+ quality = {default = 85, range = [1, 100]}
17
+ `;
18
+
19
+ const QUALITY_ALIAS_TOML = `
20
+ [compress]
21
+ [compress.args]
22
+ quality = {default = 85, range = [1, 100], env = "CI_QUALITY"}
23
+ `;
24
+
25
+ // ── env resolution tier ───────────────────────────────────────────────────────
26
+
27
+ describe('env resolution', () => {
28
+ afterEach(() => {
29
+ delete process.env['RUNSPEC_ARG_QUALITY'];
30
+ delete process.env['CI_QUALITY'];
31
+ });
32
+
33
+ test('RUNSPEC_ARG_* provides auto default when no CLI arg', () => {
34
+ const configPath = makeTmpConfig(QUALITY_TOML);
35
+ process.env['RUNSPEC_ARG_QUALITY'] = '95';
36
+ const args = parse({ scriptName: 'compress', argv: [], configPath });
37
+ expect(args['quality']).toBe(95);
38
+ });
39
+
40
+ test('CLI arg wins over RUNSPEC_ARG_*', () => {
41
+ const configPath = makeTmpConfig(QUALITY_TOML);
42
+ process.env['RUNSPEC_ARG_QUALITY'] = '95';
43
+ const args = parse({ scriptName: 'compress', argv: ['--quality', '80'], configPath });
44
+ expect(args['quality']).toBe(80);
45
+ });
46
+
47
+ test('developer alias fallback when RUNSPEC_ARG_* not set', () => {
48
+ const configPath = makeTmpConfig(QUALITY_ALIAS_TOML);
49
+ process.env['CI_QUALITY'] = '70';
50
+ const args = parse({ scriptName: 'compress', argv: [], configPath });
51
+ expect(args['quality']).toBe(70);
52
+ });
53
+
54
+ test('RUNSPEC_ARG_* wins over developer alias', () => {
55
+ const configPath = makeTmpConfig(QUALITY_ALIAS_TOML);
56
+ process.env['RUNSPEC_ARG_QUALITY'] = '95';
57
+ process.env['CI_QUALITY'] = '70';
58
+ const args = parse({ scriptName: 'compress', argv: [], configPath });
59
+ expect(args['quality']).toBe(95);
60
+ });
61
+
62
+ test('no env vars set falls back to spec default', () => {
63
+ const configPath = makeTmpConfig(QUALITY_TOML);
64
+ const args = parse({ scriptName: 'compress', argv: [], configPath });
65
+ expect(args['quality']).toBe(85);
66
+ });
67
+ });
@@ -0,0 +1,204 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import {
5
+ configureLogging,
6
+ getLogger,
7
+ emitRunSummary,
8
+ _resetForTest,
9
+ RUN_SUMMARY_LOGGER,
10
+ } from '../src/logging_setup';
11
+
12
+ function tmpDir(): string {
13
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-summary-test-'));
14
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"test","version":"0.0.0"}');
15
+ return d;
16
+ }
17
+
18
+ function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
19
+ const { debug, noSummary, autonomy, agent, commandPath, ...logOverrides } = overrides as {
20
+ debug?: boolean; noSummary?: boolean; autonomy?: string; agent?: boolean; commandPath?: string[];
21
+ } & Record<string, unknown>;
22
+ return {
23
+ logCfg: { rotate: 'midnight', keep: 7, summary: true, ...logOverrides },
24
+ runnableName: 'myscript',
25
+ configPath: path.join(dir, 'runspec.toml'),
26
+ debug,
27
+ noSummary,
28
+ autonomy,
29
+ agent,
30
+ commandPath,
31
+ };
32
+ }
33
+
34
+ function captureStderr() {
35
+ const lines: string[] = [];
36
+ const spy = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
37
+ lines.push(String(chunk));
38
+ return true;
39
+ });
40
+ return { lines, restore() { spy.mockRestore(); } };
41
+ }
42
+
43
+ beforeEach(() => {
44
+ _resetForTest();
45
+ delete process.env['RUNSPEC_ARG_NO_SUMMARY'];
46
+ });
47
+
48
+ afterAll(() => {
49
+ // Final cleanup so jest's own process-exit doesn't trigger a summary
50
+ // emit against a stale state pointing at a tmp dir.
51
+ _resetForTest();
52
+ });
53
+
54
+ // ── counter ──────────────────────────────────────────────────────────────────
55
+
56
+ test('counter increments per level', () => {
57
+ const dir = tmpDir();
58
+ configureLogging(makeCfg(dir));
59
+ const log = getLogger('test.counter');
60
+ log.info('one');
61
+ log.info('two');
62
+ log.warning('careful');
63
+ log.error('broke');
64
+ const cap = captureStderr();
65
+ emitRunSummary();
66
+ cap.restore();
67
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
68
+ const summary = content.trim().split('\n').map(l => JSON.parse(l)).find(o => o.logger === RUN_SUMMARY_LOGGER);
69
+ expect(summary.extra.events).toEqual({ DEBUG: 0, INFO: 2, WARNING: 1, ERROR: 1, CRITICAL: 0 });
70
+ });
71
+
72
+ test('counter ignores the summary logger itself', () => {
73
+ const dir = tmpDir();
74
+ configureLogging(makeCfg(dir));
75
+ // Calling the summary logger directly must not inflate counts.
76
+ getLogger(RUN_SUMMARY_LOGGER).info('should not count');
77
+ const cap = captureStderr();
78
+ emitRunSummary();
79
+ cap.restore();
80
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
81
+ const lines = content.trim().split('\n').map(l => JSON.parse(l));
82
+ const summary = lines.find(o => o.logger === RUN_SUMMARY_LOGGER && o.extra?.event === 'run_summary');
83
+ expect(summary.extra.events).toEqual({ DEBUG: 0, INFO: 0, WARNING: 0, ERROR: 0, CRITICAL: 0 });
84
+ });
85
+
86
+ // ── emit ─────────────────────────────────────────────────────────────────────
87
+
88
+ test('summary writes one record to the audit file', () => {
89
+ const dir = tmpDir();
90
+ configureLogging(makeCfg(dir, { autonomy: 'confirm', agent: false }));
91
+ getLogger('test.emit').info('did work');
92
+ getLogger('test.emit').warning('a warning');
93
+ const cap = captureStderr();
94
+ emitRunSummary();
95
+ cap.restore();
96
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
97
+ const summaries = content.trim().split('\n').map(l => JSON.parse(l)).filter(o => o.logger === RUN_SUMMARY_LOGGER);
98
+ expect(summaries).toHaveLength(1);
99
+ const s = summaries[0];
100
+ expect(s.message).toBe('run completed');
101
+ expect(s.extra.event).toBe('run_summary');
102
+ expect(s.extra.runnable).toBe('myscript');
103
+ expect(s.extra.events.INFO).toBe(1);
104
+ expect(s.extra.events.WARNING).toBe(1);
105
+ expect(s.extra.exit_code).toBe(0);
106
+ expect(s.extra.exception).toBeNull();
107
+ expect(typeof s.extra.duration_ms).toBe('number');
108
+ });
109
+
110
+ test('summary writes a one-line summary to stderr', () => {
111
+ const dir = tmpDir();
112
+ configureLogging(makeCfg(dir));
113
+ getLogger('test.stderr').info('ran');
114
+ const cap = captureStderr();
115
+ emitRunSummary();
116
+ cap.restore();
117
+ const joined = cap.lines.join('');
118
+ expect(joined).toContain('runspec: myscript completed');
119
+ expect(joined).toContain('events');
120
+ });
121
+
122
+ test('summary is not echoed to stdout or stderr via console handlers', () => {
123
+ const dir = tmpDir();
124
+ const stdoutLines: string[] = [];
125
+ const stderrLines: string[] = [];
126
+ const o = jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutLines.push(String(chunk)); return true; });
127
+ const e = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderrLines.push(String(chunk)); return true; });
128
+ configureLogging(makeCfg(dir));
129
+ getLogger('test.console').info('hi');
130
+ emitRunSummary();
131
+ o.mockRestore();
132
+ e.mockRestore();
133
+ // The JSON form of the summary record must not appear on either stream.
134
+ expect(stdoutLines.join('')).not.toContain('"logger":"runspec.runsummary"');
135
+ expect(stderrLines.join('').match(/"logger":"runspec.runsummary"/)).toBeNull();
136
+ });
137
+
138
+ test('summary emit is idempotent', () => {
139
+ const dir = tmpDir();
140
+ configureLogging(makeCfg(dir));
141
+ const cap = captureStderr();
142
+ emitRunSummary();
143
+ emitRunSummary();
144
+ cap.restore();
145
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
146
+ const summaries = content.trim().split('\n').map(l => JSON.parse(l)).filter(o => o.logger === RUN_SUMMARY_LOGGER);
147
+ expect(summaries).toHaveLength(1);
148
+ });
149
+
150
+ // ── disable switches ─────────────────────────────────────────────────────────
151
+
152
+ test('noSummary option disables summary', () => {
153
+ const dir = tmpDir();
154
+ configureLogging(makeCfg(dir, { noSummary: true }));
155
+ const cap = captureStderr();
156
+ emitRunSummary(); // no-op because state is null
157
+ cap.restore();
158
+ expect(cap.lines.join('')).not.toContain('runspec: ');
159
+ expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(false);
160
+ });
161
+
162
+ test('summary=false in config disables summary', () => {
163
+ const dir = tmpDir();
164
+ configureLogging(makeCfg(dir, { summary: false }));
165
+ const cap = captureStderr();
166
+ emitRunSummary();
167
+ cap.restore();
168
+ expect(cap.lines.join('')).not.toContain('runspec: ');
169
+ });
170
+
171
+ test('RUNSPEC_ARG_NO_SUMMARY=1 disables summary', () => {
172
+ process.env['RUNSPEC_ARG_NO_SUMMARY'] = '1';
173
+ const dir = tmpDir();
174
+ configureLogging(makeCfg(dir));
175
+ const cap = captureStderr();
176
+ emitRunSummary();
177
+ cap.restore();
178
+ expect(cap.lines.join('')).not.toContain('runspec: ');
179
+ });
180
+
181
+ // ── stderr line shape ────────────────────────────────────────────────────────
182
+
183
+ test('success line uses singular "warning" for 1 and plural "errors" for 0', () => {
184
+ const dir = tmpDir();
185
+ configureLogging(makeCfg(dir));
186
+ getLogger('t').warning('one');
187
+ const cap = captureStderr();
188
+ emitRunSummary();
189
+ cap.restore();
190
+ const joined = cap.lines.join('');
191
+ expect(joined).toContain('1 warning,');
192
+ expect(joined).toContain('0 errors)');
193
+ });
194
+
195
+ test('success line uses plural for 2 warnings', () => {
196
+ const dir = tmpDir();
197
+ configureLogging(makeCfg(dir));
198
+ getLogger('t').warning('a');
199
+ getLogger('t').warning('b');
200
+ const cap = captureStderr();
201
+ emitRunSummary();
202
+ cap.restore();
203
+ expect(cap.lines.join('')).toContain('2 warnings,');
204
+ });
package/tsconfig.json CHANGED
@@ -11,7 +11,8 @@
11
11
  "sourceMap": true,
12
12
  "esModuleInterop": true,
13
13
  "forceConsistentCasingInFileNames": true,
14
- "skipLibCheck": true
14
+ "skipLibCheck": true,
15
+ "types": ["node", "jest"]
15
16
  },
16
17
  "include": ["src"],
17
18
  "exclude": ["node_modules", "dist", "tests"]