runspec-node 0.19.0 → 0.22.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.
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,EAAE,CAAC;IAC3B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,UAAU;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,wBAAwB,EAAE,MAAM,EAAE,CAAC;IAC5C,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,gBAAgB,EAAE,UAAU,CAAC;IACtC,QAAQ,CAAC,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,oBAAoB,EAAE,MAAM,EAAE,CAAC;IACxC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC"}
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runspec-node",
3
- "version": "0.19.0",
3
+ "version": "0.22.0",
4
4
  "description": "Node/TypeScript language pack for runspec",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,13 @@
13
13
  "typecheck": "tsc --noEmit",
14
14
  "clean": "rm -rf dist"
15
15
  },
16
- "keywords": ["runspec", "cli", "arguments", "agents", "mcp"],
16
+ "keywords": [
17
+ "runspec",
18
+ "cli",
19
+ "arguments",
20
+ "agents",
21
+ "mcp"
22
+ ],
17
23
  "author": "Jason Finestone",
18
24
  "license": "MIT",
19
25
  "repository": {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { parse, loadSpec } from './parser';
2
2
  export { registerType, listTypes } from './types';
3
+ export { tsTypeOf } from './inference';
3
4
  export { RunSpecError, MissingRequiredArg, InvalidChoice, OutOfRange, UnknownArg, GroupViolation, AutonomyViolation } from './errors';
4
5
  export { findConfig } from './finder';
5
6
  export { loadRaw } from './loader';
package/src/inference.ts CHANGED
@@ -4,6 +4,31 @@ import { RunSpecError } from './errors';
4
4
  export const AUTONOMY_LEVELS = ['autonomous', 'confirm', 'supervised', 'manual'] as const;
5
5
  export const AUTONOMY_RANK = Object.fromEntries(AUTONOMY_LEVELS.map((l, i) => [l, i]));
6
6
 
7
+ // Item-type name → its TypeScript type. Mirrors the JSON-Schema type map used
8
+ // by emit; custom (register_type) names fall back to `unknown`.
9
+ const TS_TYPE_NAMES: Record<string, string> = {
10
+ str: 'string',
11
+ int: 'number',
12
+ float: 'number',
13
+ bool: 'boolean',
14
+ flag: 'boolean',
15
+ path: 'string',
16
+ choice: 'string',
17
+ rest: 'string[]',
18
+ };
19
+
20
+ /** The TypeScript type of an arg's parsed value: the item type, suffixed `[]`
21
+ * when the arg is `multiple` (`rest` is always `string[]`). `arg.type` itself
22
+ * stays the item type — it drives per-item coercion. Pure helper, computed on
23
+ * demand from a (typically inferred) ArgSpec; custom registered types fall
24
+ * back to `"unknown"`. e.g. `tsTypeOf({type:"str", multiple:true})` → `"string[]"`. */
25
+ export function tsTypeOf(arg: ArgSpec): string {
26
+ const type = arg.type ?? 'str';
27
+ if (type === 'rest') return 'string[]';
28
+ const base = TS_TYPE_NAMES[type] ?? 'unknown';
29
+ return arg.multiple ? `${base}[]` : base;
30
+ }
31
+
7
32
  export function inferArg(raw: ArgSpec): ArgSpec {
8
33
  const result = { ...raw };
9
34
  const def = result.default;
package/src/loader.ts CHANGED
@@ -40,10 +40,15 @@ function normaliseLogging(raw: Record<string, unknown> | undefined): LoggingConf
40
40
  // log-event counts by level. Suppress per-invocation with `--no-summary`
41
41
  // or `RUNSPEC_NO_SUMMARY=1`.
42
42
  if (raw === undefined) return undefined;
43
+ // `store` selects the file layout: 'single' = one rotating {runnable}.log;
44
+ // 'per-run' = one file per invocation. Unknown values fall back to 'single'
45
+ // — the schema check flags typos; runtime stays lenient.
46
+ const store = String(raw['store'] ?? 'single');
43
47
  return {
44
48
  rotate: String(raw['rotate'] ?? 'midnight'),
45
49
  keep: Number(raw['keep'] ?? 7),
46
50
  summary: raw['summary'] !== undefined ? Boolean(raw['summary']) : true,
51
+ store: store === 'per-run' ? 'per-run' : 'single',
47
52
  };
48
53
  }
49
54
 
@@ -7,6 +7,7 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
+ import { randomUUID } from 'crypto';
10
11
  import type { LoggingConfig } from './models';
11
12
 
12
13
  // ── internal state ────────────────────────────────────────────────────────────
@@ -15,7 +16,22 @@ let _configured = false;
15
16
  const _loggers = new Map<string, Logger>();
16
17
  const _handlers: Handler[] = [];
17
18
 
19
+ // UUID4 assigned at configureLogging() time, injected into every file record as
20
+ // extra.run_id (SPEC §run_id). Separates interleaved runs in a shared log, and
21
+ // matches the run_id token in a per-run filename.
22
+ let _runId: string | null = null;
23
+
18
24
  const RUN_SUMMARY_LOGGER = 'runspec.runsummary';
25
+ // Uncaught exceptions are emitted on this dedicated logger so the file handler
26
+ // records them while the console handlers drop them by name — console display is
27
+ // handled explicitly in _handleUncaught (debug-gated).
28
+ const EXCEPTION_LOGGER = 'runspec.exception';
29
+
30
+ // Directory of the installed runspec-node package — used to filter internal
31
+ // library frames out of the *displayed* compact trace (full trace still hits the file).
32
+ const RUNSPEC_PKG_DIR = __dirname;
33
+
34
+ let _debug = false; // mirrors the --debug flag; read by _handleUncaught for console rendering
19
35
 
20
36
  interface CapturedException {
21
37
  type: string;
@@ -23,6 +39,20 @@ interface CapturedException {
23
39
  traceback: string;
24
40
  }
25
41
 
42
+ interface ExcFrame {
43
+ func: string;
44
+ file: string;
45
+ line: number | null;
46
+ code: string | null;
47
+ }
48
+
49
+ interface ExcStructured {
50
+ type: string;
51
+ message: string;
52
+ module: string | null;
53
+ frames: ExcFrame[];
54
+ }
55
+
26
56
  interface SummaryState {
27
57
  counter: RunSummaryCounter;
28
58
  start: bigint;
@@ -83,6 +113,7 @@ interface LogRecord {
83
113
  message: string;
84
114
  error?: Error;
85
115
  extra?: Record<string, unknown>;
116
+ excStructured?: Record<string, unknown>;
86
117
  }
87
118
 
88
119
  interface Handler {
@@ -145,7 +176,12 @@ function formatJson(record: LogRecord): string {
145
176
  message: record.message,
146
177
  };
147
178
  if (record.error) obj['exc'] = record.error.stack ?? record.error.message;
148
- if (record.extra) obj['extra'] = record.extra;
179
+ if (record.excStructured) obj['exc_structured'] = record.excStructured;
180
+ // run_id rides in extra on every file record (console output omits it — that
181
+ // handler uses formatConsole). Mirrors Python's _RunIdFilter.
182
+ if (record.extra || _runId !== null) {
183
+ obj['extra'] = { ...(record.extra ?? {}), ...(_runId !== null ? { run_id: _runId } : {}) };
184
+ }
149
185
  return JSON.stringify(obj);
150
186
  }
151
187
 
@@ -183,7 +219,7 @@ class StdoutHandler implements Handler {
183
219
 
184
220
  emit(record: LogRecord): void {
185
221
  if (record.levelNum >= 30) return; // WARNING+ belongs on stderr
186
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
222
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
187
223
  try {
188
224
  process.stdout.write(formatConsole(record, this.showTracebacks) + '\n');
189
225
  } catch {
@@ -204,7 +240,7 @@ class StderrHandler implements Handler {
204
240
 
205
241
  emit(record: LogRecord): void {
206
242
  if (record.levelNum < 30) return;
207
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
243
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
208
244
  try {
209
245
  process.stderr.write(formatConsole(record, this.showTracebacks) + '\n');
210
246
  } catch {
@@ -226,8 +262,8 @@ class RunSummaryCounter implements Handler {
226
262
  };
227
263
 
228
264
  emit(record: LogRecord): void {
229
- // Don't count the summary record itself.
230
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
265
+ // Don't count runspec's own bookkeeping records (summary + uncaught-exception).
266
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
231
267
  const label = LEVEL_LABEL[record.levelNum];
232
268
  if (label && label in this.counts) {
233
269
  this.counts[label]++;
@@ -317,6 +353,32 @@ class TimedRotatingFileHandler implements Handler {
317
353
  }
318
354
  }
319
355
 
356
+ /**
357
+ * Plain append-only handler for `store = "per-run"` — one file per invocation,
358
+ * so there is nothing to rotate (and no rotation race across writers). The file
359
+ * is created lazily on first append, so a run that logs nothing leaves no empty
360
+ * file behind.
361
+ */
362
+ class PlainFileHandler implements Handler {
363
+ constructor(
364
+ private readonly logPath: string,
365
+ readonly level: number,
366
+ ) {}
367
+
368
+ emit(record: LogRecord): void {
369
+ try {
370
+ fs.appendFileSync(this.logPath, formatJson(record) + '\n', 'utf-8');
371
+ } catch {
372
+ // never disrupt
373
+ }
374
+ }
375
+ }
376
+
377
+ /** Compact UTC stamp for per-run filenames: `YYYYMMDDThhmmssZ`. */
378
+ function _perRunStamp(d: Date): string {
379
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
380
+ }
381
+
320
382
  // ── size/rotate parser ────────────────────────────────────────────────────────
321
383
 
322
384
  const SIZE_RE = /^(\d+(?:\.\d+)?)\s*(KB|MB|GB)$/i;
@@ -462,6 +524,99 @@ export function emitRunSummary(): void {
462
524
  }
463
525
  }
464
526
 
527
+ // ── uncaught exceptions ────────────────────────────────────────────────────────
528
+
529
+ const STACK_FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
530
+
531
+ /**
532
+ * Parse a V8 stack string into frames, innermost LAST (mirroring Python's
533
+ * traceback.extract_tb order). `code` is unavailable from a stack string.
534
+ */
535
+ function parseStackFrames(stack: string): ExcFrame[] {
536
+ const frames: ExcFrame[] = [];
537
+ for (const lineStr of stack.split('\n')) {
538
+ const m = STACK_FRAME_RE.exec(lineStr);
539
+ if (!m) continue;
540
+ frames.push({ func: m[1] ?? '<anonymous>', file: m[2], line: parseInt(m[3], 10), code: null });
541
+ }
542
+ frames.reverse();
543
+ return frames;
544
+ }
545
+
546
+ /** Clean, machine-readable form of an error for the JSON audit / UI tables. */
547
+ function buildExcStructured(err: Error): ExcStructured {
548
+ const frames = parseStackFrames(err.stack ?? '');
549
+ const last = frames.length ? frames[frames.length - 1] : null;
550
+ const module = last ? path.basename(last.file).replace(/\.[^.]+$/, '') : null;
551
+ return { type: err.name || 'Error', message: err.message || String(err), module, frames };
552
+ }
553
+
554
+ /**
555
+ * Neat, aligned, stdlib-only trace for --debug console output. Internal
556
+ * runspec frames are dropped from the *display* (full trace still hits the
557
+ * file). Falls back to the full list if filtering empties it.
558
+ */
559
+ function formatCompactTrace(err: Error, frames: ExcFrame[]): string {
560
+ const shown = frames.filter((f) => !f.file.includes(RUNSPEC_PKG_DIR));
561
+ const use = shown.length ? shown : frames;
562
+ const locs = use.map((f) => `${path.basename(f.file)}:${f.line}`);
563
+ const width = Math.max(0, ...locs.map((l) => l.length));
564
+ const lines = [`${err.name || 'Error'}: ${err.message}`, ''];
565
+ use.forEach((f, i) => {
566
+ lines.push(` ${locs[i].padEnd(width)} ${f.func}`.trimEnd());
567
+ });
568
+ return lines.join('\n');
569
+ }
570
+
571
+ /** Emit the structured exception record straight to the handlers (file keeps it). */
572
+ function emitExceptionRecord(err: Error, structured: ExcStructured): void {
573
+ if (_handlers.length === 0) return;
574
+ const record: LogRecord = {
575
+ ts: new Date(),
576
+ levelNum: 50,
577
+ loggerName: EXCEPTION_LOGGER,
578
+ message: 'uncaught exception',
579
+ error: err,
580
+ excStructured: structured as unknown as Record<string, unknown>,
581
+ };
582
+ for (const h of _handlers) {
583
+ try {
584
+ if (record.levelNum >= h.level) h.emit(record);
585
+ } catch {
586
+ // never disrupt
587
+ }
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Uniform handling for an uncaught error: always write a structured record to
593
+ * the audit file, feed the run summary (when on), and show a neat compact
594
+ * trace on the console only with --debug (otherwise a single concise line).
595
+ * Exported for tests so they can exercise it without triggering process.exit.
596
+ */
597
+ export function _handleUncaught(err: Error): void {
598
+ const structured = buildExcStructured(err);
599
+
600
+ // Always to the audit file (independent of --debug and of the summary toggle).
601
+ emitExceptionRecord(err, structured);
602
+
603
+ if (_summaryState) {
604
+ _summaryState.exception = { type: structured.type, message: structured.message, traceback: err.stack ?? '' };
605
+ _summaryState.exitCode = 1;
606
+ }
607
+
608
+ // Console: full compact trace only with --debug, else a one-liner.
609
+ try {
610
+ if (_debug) {
611
+ process.stderr.write(formatCompactTrace(err, structured.frames) + '\n');
612
+ } else {
613
+ process.stderr.write(`ERROR: ${structured.type}: ${structured.message} (run with --debug for traceback)\n`);
614
+ }
615
+ } catch {
616
+ process.stderr.write((err.stack ?? String(err)) + '\n'); // safety fallback
617
+ }
618
+ }
619
+
465
620
  function installExitHooks(): void {
466
621
  if (_exitHooksInstalled) return;
467
622
  _exitHooksInstalled = true;
@@ -469,43 +624,25 @@ function installExitHooks(): void {
469
624
  process.on('exit', (code) => {
470
625
  if (_summaryState && !_summaryState.emitted) {
471
626
  // process.exitCode wins over the explicit exception capture only if
472
- // it's non-zero — uncaughtException already set state.exitCode=1.
627
+ // it's non-zero — _handleUncaught already set state.exitCode=1.
473
628
  if (code !== 0 && _summaryState.exitCode === 0) _summaryState.exitCode = code;
474
629
  emitRunSummary();
475
630
  }
476
631
  });
477
632
 
478
633
  // Skip the crash-handlers under jest — they call process.exit(1), which
479
- // would tear down the test runner if any test ever produced an unhandled
480
- // rejection. The 'exit' hook above is harmless and still runs.
634
+ // would tear down the test runner. _handleUncaught is unit-tested directly.
635
+ // The 'exit' hook above is harmless and still runs.
481
636
  if (process.env['JEST_WORKER_ID'] !== undefined) return;
482
637
 
483
638
  process.on('uncaughtException', (err: Error) => {
484
- if (_summaryState) {
485
- _summaryState.exception = {
486
- type: err.name || 'Error',
487
- message: err.message || String(err),
488
- traceback: err.stack ?? '',
489
- };
490
- _summaryState.exitCode = 1;
491
- }
492
- // Preserve default Node behaviour: print and exit non-zero. The 'exit'
493
- // hook above will fire and run emitRunSummary().
494
- process.stderr.write((err.stack ?? String(err)) + '\n');
639
+ _handleUncaught(err);
495
640
  process.exit(1);
496
641
  });
497
642
 
498
643
  process.on('unhandledRejection', (reason: unknown) => {
499
- if (_summaryState) {
500
- const err = reason instanceof Error ? reason : new Error(String(reason));
501
- _summaryState.exception = {
502
- type: err.name || 'Error',
503
- message: err.message || String(reason),
504
- traceback: err.stack ?? '',
505
- };
506
- _summaryState.exitCode = 1;
507
- }
508
- process.stderr.write(`Unhandled rejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}\n`);
644
+ const err = reason instanceof Error ? reason : new Error(String(reason));
645
+ _handleUncaught(err);
509
646
  process.exit(1);
510
647
  });
511
648
  }
@@ -554,14 +691,26 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
554
691
  if (!opts.logCfg || _configured) return;
555
692
 
556
693
  const debug = opts.debug ?? false;
694
+ _debug = debug;
557
695
  const floor = debug ? LEVEL_NUM['debug'] : LEVEL_NUM['info'];
558
696
 
697
+ // Unique ID for this invocation — injected into every file record (formatJson)
698
+ // and reused as the per-run filename token so file run_id == record run_id.
699
+ _runId = randomUUID();
700
+
559
701
  _handlers.push(new StdoutHandler(floor, debug));
560
702
  _handlers.push(new StderrHandler(debug));
561
703
 
562
704
  const logDir = resolveLogDir(opts.configPath);
563
- const logPath = path.join(logDir, `${opts.runnableName}.log`);
564
- _handlers.push(makeFileHandler(logPath, opts.logCfg.rotate, opts.logCfg.keep, floor));
705
+ if (opts.logCfg.store === 'per-run') {
706
+ // One file per invocation — multi-writer safe (no shared file, so no
707
+ // rotation race). rotate/keep are inert; retention is `runspec logs`.
708
+ const logPath = path.join(logDir, `${opts.runnableName}.${_perRunStamp(new Date())}.${_runId}.log`);
709
+ _handlers.push(new PlainFileHandler(logPath, floor));
710
+ } else {
711
+ const logPath = path.join(logDir, `${opts.runnableName}.log`);
712
+ _handlers.push(makeFileHandler(logPath, opts.logCfg.rotate, opts.logCfg.keep, floor));
713
+ }
565
714
 
566
715
  // Always attach the counter — cost is one dict increment per log call.
567
716
  // Only the exit hook + state population are conditional on summary mode.
@@ -589,9 +738,13 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
589
738
  user,
590
739
  userTarget,
591
740
  };
592
- installExitHooks();
593
741
  }
594
742
 
743
+ // Uncaught-exception handling is always installed (independent of the summary
744
+ // toggle) so the structured exception record reaches the audit file even when
745
+ // summary is off. The exit hook only flushes a summary when _summaryState is set.
746
+ installExitHooks();
747
+
595
748
  _configured = true;
596
749
  }
597
750
 
@@ -599,6 +752,8 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
599
752
 
600
753
  export function _resetForTest(): void {
601
754
  _configured = false;
755
+ _debug = false;
756
+ _runId = null;
602
757
  _loggers.clear();
603
758
  _handlers.length = 0;
604
759
  _summaryState = null;
@@ -606,4 +761,4 @@ export function _resetForTest(): void {
606
761
  // they no-op when _summaryState is null, which is the test-time state.
607
762
  }
608
763
 
609
- export { _periodForDate, RUN_SUMMARY_LOGGER };
764
+ export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace };
package/src/models.ts CHANGED
@@ -2,6 +2,9 @@ export interface LoggingConfig {
2
2
  rotate: string;
3
3
  keep: number;
4
4
  summary: boolean;
5
+ /** Log file layout: 'single' (default, one rotating {runnable}.log) or
6
+ * 'per-run' (one {runnable}.{utc-ts}.{run_id}.log per invocation). */
7
+ store?: string;
5
8
  }
6
9
 
7
10
  export interface RawConfig {
@@ -30,3 +30,19 @@ slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10
30
30
  expect(slug['minLength']).toBe(3);
31
31
  expect(slug['maxLength']).toBe(10);
32
32
  });
33
+
34
+ test('multiple arg emits array with per-item constraints inside items', () => {
35
+ const schema = schemaFor(
36
+ `
37
+ [deploy]
38
+ [deploy.args]
39
+ ticket = {type = "str", multiple = true, pattern = "[A-Z]+-[0-9]+"}
40
+ `,
41
+ 'deploy',
42
+ );
43
+ const props = (schema['inputSchema'] as Record<string, Record<string, unknown>>)['properties'];
44
+ const ticket = (props as Record<string, Record<string, unknown>>)['ticket'];
45
+ expect(ticket['type']).toBe('array');
46
+ // The per-item constraint validates each element, so it belongs in items.
47
+ expect(ticket['items']).toEqual({ type: 'string', pattern: '[A-Z]+-[0-9]+' });
48
+ });
@@ -1,4 +1,4 @@
1
- import { inferArg, inferScript, effectiveAutonomy, isMoreRestrictive } from '../src/inference';
1
+ import { inferArg, inferScript, effectiveAutonomy, isMoreRestrictive, tsTypeOf } from '../src/inference';
2
2
  import { RunSpecError } from '../src/errors';
3
3
  import type { ArgSpec, ScriptSpec } from '../src/models';
4
4
 
@@ -151,3 +151,31 @@ test('manual is more restrictive than confirm', () => {
151
151
  test('autonomous is not more restrictive than confirm', () => {
152
152
  expect(isMoreRestrictive('autonomous', 'confirm')).toBe(false);
153
153
  });
154
+
155
+ // ── tsTypeOf (derived value type, computed on demand) ──────────────────────────
156
+
157
+ test('tsTypeOf: scalar types', () => {
158
+ expect(tsTypeOf(makeArg({ type: 'str' }))).toBe('string');
159
+ expect(tsTypeOf(makeArg({ type: 'int' }))).toBe('number');
160
+ expect(tsTypeOf(makeArg({ type: 'flag' }))).toBe('boolean');
161
+ expect(tsTypeOf(makeArg({ type: 'path' }))).toBe('string');
162
+ });
163
+
164
+ test('tsTypeOf: multiple suffixes []', () => {
165
+ expect(tsTypeOf(makeArg({ type: 'str', multiple: true }))).toBe('string[]');
166
+ expect(tsTypeOf(makeArg({ type: 'int', multiple: true }))).toBe('number[]');
167
+ });
168
+
169
+ test('tsTypeOf: rest is string[]', () => {
170
+ expect(tsTypeOf(makeArg({ type: 'rest' }))).toBe('string[]');
171
+ });
172
+
173
+ test('tsTypeOf: custom type falls back to unknown', () => {
174
+ expect(tsTypeOf(makeArg({ type: 'json-file', multiple: true }))).toBe('unknown[]');
175
+ });
176
+
177
+ test('tsTypeOf works on an inferred ArgSpec; type stays the item type', () => {
178
+ const arg = inferArg(makeArg({ type: 'str', multiple: true }));
179
+ expect(arg.type).toBe('str');
180
+ expect(tsTypeOf(arg)).toBe('string[]');
181
+ });
@@ -178,7 +178,35 @@ test('normalises [config.logging] with defaults', () => {
178
178
  description = "hi"
179
179
  `);
180
180
  const raw = loadRaw(file);
181
- expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7, summary: true });
181
+ expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7, summary: true, store: 'single' });
182
+ });
183
+
184
+ test('normalises [config.logging] store = per-run', () => {
185
+ const dir = tmpDir();
186
+ const file = path.join(dir, 'runspec.toml');
187
+ fs.writeFileSync(file, `
188
+ [config.logging]
189
+ store = "per-run"
190
+
191
+ [greet]
192
+ description = "hi"
193
+ `);
194
+ const raw = loadRaw(file);
195
+ expect(raw.config.logging?.store).toBe('per-run');
196
+ });
197
+
198
+ test('normalises [config.logging] unknown store falls back to single', () => {
199
+ const dir = tmpDir();
200
+ const file = path.join(dir, 'runspec.toml');
201
+ fs.writeFileSync(file, `
202
+ [config.logging]
203
+ store = "bogus"
204
+
205
+ [greet]
206
+ description = "hi"
207
+ `);
208
+ const raw = loadRaw(file);
209
+ expect(raw.config.logging?.store).toBe('single');
182
210
  });
183
211
 
184
212
  test('normalises [config.logging] all fields', () => {
@@ -194,7 +222,7 @@ summary = false
194
222
  description = "hi"
195
223
  `);
196
224
  const raw = loadRaw(file);
197
- expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3, summary: false });
225
+ expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3, summary: false, store: 'single' });
198
226
  });
199
227
 
200
228
  test('[config.logging] summary defaults to true when omitted', () => {
@@ -407,17 +407,20 @@ test('extra fields appear under "extra" key in JSON', () => {
407
407
  getLogger('test').info('connected', { user_id: '42', region: 'eu-west' });
408
408
  const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
409
409
  const record = JSON.parse(content);
410
- expect(record.extra).toEqual({ user_id: '42', region: 'eu-west' });
410
+ // run_id is always present on file records (SPEC §run_id); user fields ride alongside.
411
+ expect(record.extra).toMatchObject({ user_id: '42', region: 'eu-west' });
412
+ expect(typeof record.extra.run_id).toBe('string');
411
413
  expect(record.message).toBe('connected');
412
414
  });
413
415
 
414
- test('no "extra" key when no extra fields', () => {
416
+ test('run_id is the only extra field when none supplied', () => {
415
417
  const dir = tmpDir();
416
418
  configureLogging(makeCfg(dir));
417
419
  getLogger('test').info('plain message');
418
420
  const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
419
421
  const record = JSON.parse(content);
420
- expect(record.extra).toBeUndefined();
422
+ expect(Object.keys(record.extra)).toEqual(['run_id']);
423
+ expect(typeof record.extra.run_id).toBe('string');
421
424
  });
422
425
 
423
426
  test('error key extracted from fields, not placed in extra', () => {
@@ -428,17 +431,17 @@ test('error key extracted from fields, not placed in extra', () => {
428
431
  const record = JSON.parse(content);
429
432
  expect(typeof record.exc).toBe('string');
430
433
  expect(record.exc).toContain('oops');
431
- expect(record.extra).toEqual({ user_id: '42' });
434
+ expect(record.extra).toMatchObject({ user_id: '42' });
432
435
  expect(record.extra?.error).toBeUndefined();
433
436
  });
434
437
 
435
- test('error-only fields: no extra key', () => {
438
+ test('error-only fields: extra holds just run_id', () => {
436
439
  const dir = tmpDir();
437
440
  configureLogging(makeCfg(dir));
438
441
  getLogger('test').error('boom', { error: new Error('oops') });
439
442
  const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
440
443
  const record = JSON.parse(content);
441
- expect(record.extra).toBeUndefined();
444
+ expect(Object.keys(record.extra)).toEqual(['run_id']);
442
445
  expect(typeof record.exc).toBe('string');
443
446
  });
444
447
 
@@ -473,3 +476,58 @@ test('extra fields appear in console output', () => {
473
476
  expect(lines.some(l => l.includes('user_id=42'))).toBe(true);
474
477
  stdoutWrite.mockRestore();
475
478
  });
479
+
480
+ // ── store = "per-run" ───────────────────────────────────────────────────────────
481
+
482
+ const PER_RUN_RE = /^myscript\.\d{8}T\d{6}Z\.[0-9a-fA-F-]{8,}\.log$/;
483
+
484
+ function perRunFile(dir: string): string | undefined {
485
+ return fs.readdirSync(path.join(dir, 'logs')).find(f => PER_RUN_RE.test(f));
486
+ }
487
+
488
+ test('per-run: writes {runnable}.{ts}.{run_id}.log, not {runnable}.log', () => {
489
+ const dir = tmpDir();
490
+ configureLogging(makeCfg(dir, { store: 'per-run' }));
491
+ getLogger('test').info('hi');
492
+ const files = fs.readdirSync(path.join(dir, 'logs'));
493
+ expect(files.some(f => PER_RUN_RE.test(f))).toBe(true);
494
+ expect(files).not.toContain('myscript.log');
495
+ });
496
+
497
+ test('per-run: run_id in filename matches extra.run_id in records', () => {
498
+ const dir = tmpDir();
499
+ configureLogging(makeCfg(dir, { store: 'per-run' }));
500
+ getLogger('test').info('hi');
501
+ const fname = perRunFile(dir)!;
502
+ const fileRunId = fname.replace(/\.log$/, '').split('.').pop();
503
+ const record = JSON.parse(fs.readFileSync(path.join(dir, 'logs', fname), 'utf-8').trim());
504
+ expect(record.extra.run_id).toBe(fileRunId);
505
+ });
506
+
507
+ test('per-run: no rotation even when rotate is set', () => {
508
+ const dir = tmpDir();
509
+ // a tiny size threshold would rotate a single file immediately; per-run must not.
510
+ configureLogging(makeCfg(dir, { store: 'per-run', rotate: '1 KB' }));
511
+ const log = getLogger('test');
512
+ // ~30 × ~60 bytes well exceeds the 1 KB threshold a rotating handler would act on.
513
+ for (let i = 0; i < 30; i++) log.info(`line ${i} ${'x'.repeat(50)}`);
514
+ const files = fs.readdirSync(path.join(dir, 'logs'));
515
+ // exactly one per-run file, and no rotated backups (.log.1 etc.)
516
+ expect(files.filter(f => PER_RUN_RE.test(f))).toHaveLength(1);
517
+ expect(files.some(f => /\.log\.\d+$/.test(f))).toBe(false);
518
+ });
519
+
520
+ test('per-run: no empty file when nothing is logged', () => {
521
+ const dir = tmpDir();
522
+ configureLogging(makeCfg(dir, { store: 'per-run' }));
523
+ // summary is off in makeCfg and we log nothing → lazy append means no file.
524
+ expect(perRunFile(dir)).toBeUndefined();
525
+ });
526
+
527
+ test('default store is single ({runnable}.log)', () => {
528
+ const dir = tmpDir();
529
+ configureLogging(makeCfg(dir)); // no store override
530
+ getLogger('test').info('hi');
531
+ expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(true);
532
+ expect(perRunFile(dir)).toBeUndefined();
533
+ });