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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/inference.d.ts +6 -0
- package/dist/inference.d.ts.map +1 -1
- package/dist/inference.js +25 -0
- package/dist/inference.js.map +1 -1
- package/dist/loader.js +5 -0
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +29 -1
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +170 -34
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +3 -0
- package/dist/models.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/index.ts +1 -0
- package/src/inference.ts +25 -0
- package/src/loader.ts +5 -0
- package/src/logging_setup.ts +188 -33
- package/src/models.ts +3 -0
- package/tests/test_emit_schema.test.ts +16 -0
- package/tests/test_inference.test.ts +29 -1
- package/tests/test_loader.test.ts +30 -2
- package/tests/test_logging_setup.test.ts +64 -6
- package/tests/test_run_summary.test.ts +99 -0
package/dist/models.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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": [
|
|
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
|
|
package/src/logging_setup.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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 —
|
|
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
|
|
480
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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('
|
|
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).
|
|
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).
|
|
434
|
+
expect(record.extra).toMatchObject({ user_id: '42' });
|
|
432
435
|
expect(record.extra?.error).toBeUndefined();
|
|
433
436
|
});
|
|
434
437
|
|
|
435
|
-
test('error-only fields:
|
|
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).
|
|
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
|
+
});
|