@jacebenson/jsn 4.3.7 → 4.3.8

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,14 +1,22 @@
1
1
  import { errUsageHint, errUsage } from '../errors.js';
2
2
  import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
3
+ import { topTransactionsCmd } from './transactions.js';
3
4
  import { paginatedSearch } from '../paginated-search.js';
5
+ import { canUsePicker } from '../helpers.js';
4
6
 
5
- function pickerEnabled(app) {
6
- return Boolean(process.stdin.isTTY && process.stdout.isTTY && app.output?.effectiveFormat?.() !== 'json' && app.output?.effectiveFormat?.() !== 'quiet');
7
+ /**
8
+ * A picker is only safe when a human is reachable AND the invocation did
9
+ * not ask for machine output (`--json`/`--quiet`) or a one-shot extraction
10
+ * (`--get`). canUsePicker() owns that rule so CI/agent markers and
11
+ * JSN_NO_PROMPTS are honored too.
12
+ */
13
+ function pickerEnabled(app, argv = {}) {
14
+ return canUsePicker(app, argv);
7
15
  }
8
16
 
9
- async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture' } = {}) {
17
+ async function pickRun(app, { exclude = new Set(), message = 'Select a performance capture', argv = {} } = {}) {
10
18
  const runs = listRuns({ limit: 1000 }).filter(run => !exclude.has(run.run_id));
11
- if (!pickerEnabled(app) || runs.length === 0) return null;
19
+ if (!pickerEnabled(app, argv) || runs.length === 0) return null;
12
20
  const selected = await paginatedSearch({
13
21
  message,
14
22
  totalCount: runs.length,
@@ -77,7 +85,7 @@ export function perfCmd(wrap) {
77
85
  builder: (b) => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum runs to show' }),
78
86
  handler: wrap(async (argv, app) => {
79
87
  const runs = listRuns({ limit: argv.limit });
80
- const picked = await pickRun(app, { message: 'Select a performance capture' });
88
+ const picked = await pickRun(app, { message: 'Select a performance capture', argv });
81
89
  if (picked) {
82
90
  const run = getRun(picked.run_id);
83
91
  app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
@@ -99,22 +107,41 @@ export function perfCmd(wrap) {
99
107
  command: 'show [run_id]',
100
108
  describe: 'Show one saved performance capture',
101
109
  handler: wrap(async (argv, app) => {
102
- const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app);
103
- if (!run) return;
110
+ // No run ID and no human to pick one: fail with a usable error
111
+ // instead of exiting 0 with no output at all.
112
+ if (!argv.run_id && !pickerEnabled(app, argv)) {
113
+ throw errUsageHint(
114
+ 'Missing run ID: `jsn perf show` needs a run ID when prompts are unavailable.',
115
+ 'Find a capture with `jsn perf list`, then run `jsn perf show <run_id>`.'
116
+ );
117
+ }
118
+ const run = argv.run_id ? requireRun(argv.run_id, 'target') : await pickRun(app, { argv });
119
+ if (!run) return; // cancelled at the picker
104
120
  app.ok({ ...run, _formatted: formatRunDetailed(run) }, {
105
121
  summary: `Performance capture ${run.run_id}`,
106
122
  breadcrumbs: [listHint(), compareHint(run.run_id)],
107
123
  });
108
124
  }),
109
125
  })
126
+ .command({
127
+ command: 'transactions [subcommand]',
128
+ describe: 'Inspect Client Transaction Timings',
129
+ builder: (b) => b.command(topTransactionsCmd(wrap)),
130
+ })
110
131
  .command({
111
132
  command: 'compare [baseline] [new]',
112
133
  describe: 'Compare exactly two saved captures',
113
134
  handler: wrap(async (argv, app) => {
114
135
  if (!argv.baseline && !argv.new) {
115
- const baseline = await pickRun(app, { message: 'Select the baseline capture' });
136
+ if (!pickerEnabled(app, argv)) {
137
+ throw errUsageHint(
138
+ 'Compare requires exactly two run IDs when prompts are unavailable.',
139
+ 'Run `jsn perf compare <baseline> <new>` with two run IDs from `jsn perf list`.'
140
+ );
141
+ }
142
+ const baseline = await pickRun(app, { message: 'Select the baseline capture', argv });
116
143
  if (!baseline) return;
117
- const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture' });
144
+ const newer = await pickRun(app, { exclude: new Set([baseline.run_id]), message: 'Select the new capture', argv });
118
145
  if (!newer) return;
119
146
  const baselineRun = getRun(baseline.run_id);
120
147
  const newerRun = getRun(newer.run_id);
@@ -1,3 +1,6 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { formatCommandList } from '../helpers.js';
3
+
1
4
  function numberOrString(value) {
2
5
  const n = Number(value);
3
6
  return value !== '' && Number.isFinite(n) ? n : value;
@@ -24,6 +27,179 @@ function statsForWindow(xml, parent, window) {
24
27
  return tagAttributes(parentMatch[1], window);
25
28
  }
26
29
 
30
+ export const XMLSTATS_MAX_BYTES = 4 * 1024 * 1024;
31
+
32
+ function metric(value) {
33
+ if (value === undefined || value === null || String(value).trim() === '') {
34
+ return { value: null, availability: 'unavailable' };
35
+ }
36
+ return { value: numberOrString(String(value).trim()), availability: 'available' };
37
+ }
38
+
39
+ function firstNestedText(xml, parent, child) {
40
+ const parentMatch = xml.match(new RegExp(`<${parent}\\b[^>]*>([\\s\\S]*?)</${parent}>`));
41
+ return parentMatch ? tagText(parentMatch[1], child) : undefined;
42
+ }
43
+
44
+ function allowlistedStats(xml) {
45
+ const unavailable = [];
46
+ const add = (path, value) => {
47
+ const result = metric(value);
48
+ if (result.availability === 'unavailable') unavailable.push(path);
49
+ return result;
50
+ };
51
+
52
+ const cluster = xml.match(/<cluster\.status\b[^>]*>([\s\S]*?)<\/cluster\.status>/);
53
+ const nodeXml = cluster?.[1]?.match(/<node\b[^>]*>([\s\S]*?)<\/node>/)?.[1] || '';
54
+ const daily = statsForWindow(xml, 'all_transactions', 'daily') || {};
55
+ const semaphores = [];
56
+ for (const match of xml.matchAll(/<semaphores([^>]*)\/>/g)) {
57
+ const attrs = {};
58
+ for (const [, key, value] of match[1].matchAll(/([\w.-]+)="([^"]*)"/g)) attrs[key] = value;
59
+ if (!attrs.name && !attrs.available && !attrs.borrowed && !attrs.maximum_concurrency && !attrs.queue_depth) continue;
60
+ semaphores.push({
61
+ name: attrs.name || 'unnamed',
62
+ available: add(`semaphores[${attrs.name || 'unnamed'}].available`, attrs.available),
63
+ borrowed: add(`semaphores[${attrs.name || 'unnamed'}].borrowed`, attrs.borrowed),
64
+ maximum: add(`semaphores[${attrs.name || 'unnamed'}].maximum`, attrs.maximum_concurrency),
65
+ queue_depth: add(`semaphores[${attrs.name || 'unnamed'}].queue_depth`, attrs.queue_depth),
66
+ });
67
+ }
68
+ if (semaphores.length === 0) unavailable.push('semaphores');
69
+
70
+ return {
71
+ node: {
72
+ status: add('node.status', tagText(nodeXml, 'status')),
73
+ type: add('node.type', firstNestedText(nodeXml, 'node_type', 'name')),
74
+ },
75
+ queue: {
76
+ length: add('queue.length', tagText(xml, 'queue.length')),
77
+ age: add('queue.age', tagText(xml, 'queue.age')),
78
+ },
79
+ active_sessions: add('active_sessions', tagText(xml, 'servlet.active.sessions')),
80
+ memory_pressure: add('memory_pressure', tagText(xml, 'memory_pressure')),
81
+ semaphores,
82
+ transactions: {
83
+ daily: {
84
+ count: add('transactions.daily.count', daily.count),
85
+ mean: add('transactions.daily.mean', daily.mean),
86
+ median: add('transactions.daily.median', daily.median),
87
+ ninetypercent: add('transactions.daily.ninetypercent', daily.ninetypercent),
88
+ max: add('transactions.daily.max', daily.max),
89
+ },
90
+ },
91
+ unavailable,
92
+ };
93
+ }
94
+
95
+ export function parsePlatformStatsXml(xml) {
96
+ const source = String(xml || '');
97
+ if (Buffer.byteLength(source) > XMLSTATS_MAX_BYTES) throw new Error('xmlstats response exceeds the bounded size limit');
98
+ if (!/^\s*(?:<\?xml[\s\S]*?\?>\s*)?<xmlstats\b[^>]*>/i.test(source)
99
+ || !/<\/xmlstats>\s*$/i.test(source)) {
100
+ throw new Error('Malformed XML: expected an xmlstats document');
101
+ }
102
+ const stack = [];
103
+ for (const match of source.matchAll(/<\/?([A-Za-z][\w.:-]*)(?:\s[^>]*)?>/g)) {
104
+ const token = match[0];
105
+ const name = match[1];
106
+ if (/^<\//.test(token)) {
107
+ if (stack.pop() !== name) throw new Error('Malformed XML: mismatched tags');
108
+ } else if (!/\/\s*>$/.test(token)) {
109
+ stack.push(name);
110
+ }
111
+ }
112
+ if (stack.length !== 0) throw new Error('Malformed XML: unclosed tags');
113
+ return allowlistedStats(source);
114
+ }
115
+
116
+ async function readBoundedResponse(response, maxBytes = XMLSTATS_MAX_BYTES) {
117
+ if (!response.body?.getReader) {
118
+ const text = await response.text();
119
+ return Buffer.byteLength(text) > maxBytes ? { tooLarge: true, bytes: Buffer.byteLength(text) } : { text, bytes: Buffer.byteLength(text) };
120
+ }
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let bytes = 0;
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done) break;
127
+ bytes += value.byteLength;
128
+ if (bytes > maxBytes) {
129
+ await reader.cancel();
130
+ return { tooLarge: true, bytes };
131
+ }
132
+ chunks.push(value);
133
+ }
134
+ const body = new Uint8Array(bytes);
135
+ let offset = 0;
136
+ for (const chunk of chunks) {
137
+ body.set(chunk, offset);
138
+ offset += chunk.byteLength;
139
+ }
140
+ return { text: new TextDecoder().decode(body), bytes };
141
+ }
142
+
143
+ function looksLikeLoginHtml(response, body) {
144
+ const contentType = response.headers?.get?.('content-type') || '';
145
+ return /text\/html/i.test(contentType) || /^\s*<(?:!doctype\s+html|html\b)/i.test(body);
146
+ }
147
+
148
+ export async function collectPlatformStats(sdk, instance) {
149
+ let response;
150
+ try {
151
+ response = await sdk.fetchResponse(`${instance}/xmlstats.do`, {
152
+ method: 'GET',
153
+ headers: { Accept: 'application/xml,text/xml' },
154
+ });
155
+ } catch (error) {
156
+ const message = String(error?.message || '').toLowerCase();
157
+ return { status: /timeout|timed out/.test(message) ? 'timeout' : 'unavailable', reason: /timeout|timed out/.test(message) ? 'request timed out' : 'request failed' };
158
+ }
159
+
160
+ if (response.status === 401 || response.status === 403) return { status: 'permission_denied', reason: `HTTP ${response.status}` };
161
+ if (response.status === 404 || response.status === 405) return { status: 'unsupported', reason: `HTTP ${response.status}` };
162
+ if (!response.ok) return { status: 'unavailable', reason: `HTTP ${response.status}` };
163
+
164
+ const body = await readBoundedResponse(response);
165
+ if (body.tooLarge) return { status: 'too_large', reason: `response exceeded ${XMLSTATS_MAX_BYTES} bytes`, bytes: body.bytes };
166
+ if (looksLikeLoginHtml(response, body.text)) return { status: 'login_redirect', reason: 'received HTML instead of xmlstats XML', bytes: body.bytes };
167
+
168
+ try {
169
+ const metrics = parsePlatformStatsXml(body.text);
170
+ const status = metrics.unavailable.length > 0 ? 'partial' : 'success';
171
+ return {
172
+ status,
173
+ reason: status === 'partial' ? 'some allowlisted metrics were unavailable' : null,
174
+ bytes: body.bytes,
175
+ metrics,
176
+ };
177
+ } catch {
178
+ return { status: 'malformed_xml', reason: 'response was not a valid xmlstats document', bytes: body.bytes };
179
+ }
180
+ }
181
+
182
+ function metricText(item) {
183
+ return item?.availability === 'available' ? String(item.value) : 'unavailable';
184
+ }
185
+
186
+ export function formatPlatformStats(result) {
187
+ const lines = [`STATUS: ${result.status}`];
188
+ if (result.reason) lines.push(`REASON: ${result.reason}`);
189
+ const m = result.metrics;
190
+ if (!m) return `${lines.join('\\n')}\\n`;
191
+ lines.push(`NODE: ${metricText(m.node.status)} / ${metricText(m.node.type)}`);
192
+ lines.push(`QUEUE: ${metricText(m.queue.length)} length, ${metricText(m.queue.age)} age`);
193
+ lines.push(`SESSIONS: ${metricText(m.active_sessions)}`);
194
+ lines.push(`MEMORY PRESSURE: ${metricText(m.memory_pressure)}`);
195
+ lines.push(`TRANSACTIONS DAILY: ${metricText(m.transactions.daily.count)} count, ${metricText(m.transactions.daily.mean)} mean, ${metricText(m.transactions.daily.ninetypercent)} p90, ${metricText(m.transactions.daily.max)} max`);
196
+ if (m.semaphores.length === 0) lines.push('SEMAPHORES: unavailable');
197
+ else for (const semaphore of m.semaphores) {
198
+ lines.push(`SEMAPHORE ${semaphore.name}: ${metricText(semaphore.available)} available, ${metricText(semaphore.borrowed)} borrowed, ${metricText(semaphore.maximum)} maximum, ${metricText(semaphore.queue_depth)} queue`);
199
+ }
200
+ return `${lines.join('\\n')}\\n`;
201
+ }
202
+
27
203
  export function parseNodeStatsXml(xml) {
28
204
  const source = String(xml || '');
29
205
  const result = {};
@@ -91,9 +267,26 @@ function formatHealth(nodes) {
91
267
  }
92
268
 
93
269
  export function platformCmd(wrap) {
270
+ declareCapabilities('platform', { mutationSubcommands: [] });
94
271
  return {
95
272
  command: 'platform',
96
273
  describe: 'Inspect read-only platform health and node statistics',
274
+ // Parent discovery. This parent had no handler, so `jsn platform` and
275
+ // `jsn platform --json` exited 0 with no output at all — silent success.
276
+ // Children come from the registry, not a second hand-written list.
277
+ handler: wrap(async (argv, app) => {
278
+ const subcommands = argv.subcommands || [];
279
+ const summary = 'Inspect read-only platform health and node statistics.';
280
+ app.ok({
281
+ command: 'platform',
282
+ subcommands,
283
+ _formatted: formatCommandList({
284
+ title: summary,
285
+ commands: subcommands,
286
+ footer: 'Run "jsn platform <command> --help" for details.',
287
+ }),
288
+ }, { summary });
289
+ }),
97
290
  builder: (y) => y.command({
98
291
  command: 'health',
99
292
  describe: 'Show cluster, queue, semaphore, and node health',
@@ -122,6 +315,15 @@ export function platformCmd(wrap) {
122
315
  }
123
316
  app.ok({ table: 'sys_cluster_state', node_count: nodes.length, nodes, _formatted: formatHealth(nodes), security: { raw_xml: 'excluded', sensitive_fields: 'excluded' }, context: { instance_url: app.getEffectiveInstance() } }, { summary: `Platform health: ${nodes.length} node(s)` });
124
317
  }),
318
+ }).command({
319
+ command: 'stats',
320
+ describe: 'Show bounded, read-only platform statistics from xmlstats.do',
321
+ handler: wrap(async (argv, app) => {
322
+ app.requireInstance();
323
+ const result = await collectPlatformStats(app.sdk, app.getEffectiveInstance());
324
+ result._formatted = formatPlatformStats(result);
325
+ app.ok(result, { summary: `Platform stats: ${result.status}` });
326
+ }),
125
327
  }),
126
328
  };
127
329
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
3
+ import { formatCommandList, formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
4
4
  import { declareCapabilities } from '../capabilities.js';
5
5
 
6
6
  declareCapabilities('records', {
@@ -514,20 +514,24 @@ export function recordsCmd(wrap) {
514
514
  })
515
515
 
516
516
  },
517
- handler: (argv) => {
517
+ // Parent discovery through the output controller: the subcommand list is
518
+ // registry-derived (argv.subcommands), so `jsn records --json` returns a
519
+ // structured envelope instead of prose, and the descriptions can no longer
520
+ // describe a different surface than the registrations (the previous
521
+ // hand-written list advertised `inspect` as table schema/statistics).
522
+ handler: wrap(async (argv, app) => {
518
523
  if (argv._[1]) return; // a subcommand ran — its own handler handled it
519
- console.log('Query and manage records in any ServiceNow table.');
520
- console.log('');
521
- console.log('Available subcommands:');
522
- console.log(' list List records from a table (includes total by default)');
523
- console.log(' get Get a single record by sys_id (--attachments to include files)');
524
- console.log(' create Create a record');
525
- console.log(' update Update a record');
526
- console.log(' delete Delete a record');
527
- console.log(' aggregate Run Stats API aggregation with optional grouping');
528
- console.log(' inspect Inspect table schema and statistics');
529
- console.log('');
530
- console.log('Run "jsn records <command> --help" for details.');
531
- },
524
+ const subcommands = argv.subcommands || [];
525
+ const summary = 'Query and manage records in any ServiceNow table.';
526
+ app.ok({
527
+ command: 'records',
528
+ subcommands,
529
+ _formatted: formatCommandList({
530
+ title: summary,
531
+ commands: subcommands,
532
+ footer: 'Run "jsn records <command> --help" for details.',
533
+ }),
534
+ }, { summary });
535
+ }),
532
536
  };
533
537
  }
@@ -8,6 +8,7 @@ import { execSync } from 'node:child_process';
8
8
 
9
9
  import { globalConfigPath } from '../config.js';
10
10
  import { declareCapabilities } from '../capabilities.js';
11
+ import { canUsePicker } from '../helpers.js';
11
12
 
12
13
  declareCapabilities('skill', { noInstance: true, skipDailyChecks: true });
13
14
 
@@ -305,8 +306,10 @@ export function skillCmd(wrap) {
305
306
  }
306
307
  }
307
308
  }
308
- } else if (process.stdin.isTTY) {
309
- // Interactive: show multi-select picker
309
+ } else if (canUsePicker(app, argv)) {
310
+ // Interactive: show multi-select picker. canUsePicker() (not a
311
+ // bare stdin TTY check) keeps a PTY allocated for an agent,
312
+ // CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
310
313
  const fetchedVersion = extractVersion(content);
311
314
  // Check if any existing installed copies are outdated
312
315
  const outdated = [];
@@ -39,6 +39,7 @@ export function classifyTransactionType(type, url = '') {
39
39
  }
40
40
 
41
41
  function numericValue(value) {
42
+ if (value == null || String(value).trim() === '') return null;
42
43
  const n = Number(String(value ?? '').replaceAll(',', ''));
43
44
  return Number.isFinite(n) ? n : null;
44
45
  }
@@ -65,6 +66,83 @@ function summarizeGroup(group) {
65
66
  };
66
67
  }
67
68
 
69
+ function topQuery(argv = {}) {
70
+ const parts = [];
71
+ if (argv.query) parts.push(String(argv.query));
72
+ for (const field of ['type', 'table', 'view']) if (argv[field]) parts.push(`${field}=${argv[field]}`);
73
+ if (argv.window != null && String(argv.window) !== '') {
74
+ const days = Math.max(1, Math.min(30, Number(argv.window) || 1));
75
+ parts.push(`sys_created_on>=javascript:gs.daysAgoStart(${days})`);
76
+ }
77
+ if (argv.since) parts.push(`sys_created_on>=${argv.since}`);
78
+ if (argv.until) parts.push(`sys_created_on<=${argv.until}`);
79
+ return normalizeTransactionQuery(parts.join('^'));
80
+ }
81
+
82
+ function topGroup(group) {
83
+ const stats = group?.stats || {};
84
+ return {
85
+ type: groupValue(group, 'type') || 'unavailable',
86
+ table: groupValue(group, 'table') || 'unavailable',
87
+ view: groupValue(group, 'view') || 'unavailable',
88
+ count: numericValue(stats.count) ?? 0,
89
+ avg_response_time_ms: metricValue(stats, 'avg', 'response_time'),
90
+ min_response_time_ms: metricValue(stats, 'min', 'response_time'),
91
+ max_response_time_ms: metricValue(stats, 'max', 'response_time'),
92
+ };
93
+ }
94
+
95
+ function formatTopTransactions(groups) {
96
+ const value = metric => metric == null ? 'unavailable' : `${Math.round(metric * 100) / 100} ms`;
97
+ const lines = [
98
+ 'TYPE TABLE VIEW COUNT AVG MIN MAX',
99
+ '----------- ----------------- --------------- ----- ----------- ----------- -----------',
100
+ ];
101
+ for (const row of groups) lines.push(`${String(row.type).slice(0, 11).padEnd(11)} ${String(row.table).slice(0, 17).padEnd(17)} ${String(row.view).slice(0, 15).padEnd(15)} ${String(row.count).padStart(5)} ${value(row.avg_response_time_ms).padEnd(11)} ${value(row.min_response_time_ms).padEnd(11)} ${value(row.max_response_time_ms)}`);
102
+ if (groups.length === 0) lines.push('(no transaction groups)');
103
+ return `${lines.join('\\n')}\\n`;
104
+ }
105
+
106
+ export function topTransactionsCmd(wrap) {
107
+ return {
108
+ command: 'top',
109
+ describe: 'Rank Client Transaction Timings by type, table, and view',
110
+ builder: (y) => y
111
+ .option('type', { type: 'string', describe: 'Filter transaction type' })
112
+ .option('table', { type: 'string', describe: 'Filter transaction table' })
113
+ .option('view', { type: 'string', describe: 'Filter transaction view' })
114
+ .option('query', { type: 'string', describe: 'Additional encoded query' })
115
+ .option('window', { type: 'number', default: 1, describe: 'Look back this many days (max 30)' })
116
+ .option('since', { type: 'string', describe: 'Only records on or after this timestamp' })
117
+ .option('until', { type: 'string', describe: 'Only records on or before this timestamp' })
118
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Maximum groups to return' }),
119
+ handler: wrap(async (argv, app) => {
120
+ app.requireInstance();
121
+ const query = topQuery(argv);
122
+ const limit = Math.max(1, Math.min(100, Number(argv.limit) || 20));
123
+ const result = await app.sdk.aggregate('syslog_transaction', {
124
+ query,
125
+ groupBy: ['type', 'table', 'view'],
126
+ count: true,
127
+ averageFields: ['response_time'],
128
+ minimumFields: ['response_time'],
129
+ maximumFields: ['response_time'],
130
+ orderBy: 'countDESC',
131
+ });
132
+ const groups = (Array.isArray(result.groups) ? result.groups : []).map(topGroup).slice(0, limit);
133
+ app.ok({
134
+ table: 'syslog_transaction',
135
+ query,
136
+ group_by: ['type', 'table', 'view'],
137
+ response_time_unit: 'ms',
138
+ groups,
139
+ _formatted: formatTopTransactions(groups),
140
+ context: { instance_url: app.getEffectiveInstance() },
141
+ }, { summary: `Top transaction groups: ${groups.length}` });
142
+ }),
143
+ };
144
+ }
145
+
68
146
  export function transactionsCmd(wrap) {
69
147
  return {
70
148
  command: 'transactions',
package/src/errors.js CHANGED
@@ -10,6 +10,7 @@ export const CodeAPI = 'api_error';
10
10
  export const CodeAmbiguous = 'ambiguous';
11
11
  export const CodeEmptyResult = 'empty_result';
12
12
  export const CodeConfirmationRequired = 'confirmation_required';
13
+ export const CodeExtractionNotFound = 'extraction_not_found';
13
14
 
14
15
  export class AppError extends Error {
15
16
  constructor(code, message, hint = '', status = 0, cause = null) {
@@ -118,17 +119,48 @@ export function errAmbiguous(resource, matches) {
118
119
  );
119
120
  }
120
121
 
122
+ /**
123
+ * Structured error for `--get <path>` resolving to nothing.
124
+ *
125
+ * An unresolvable path (typo, wrong envelope shape, out-of-range index) is a
126
+ * failure, not a value: printing `undefined` and exiting 0 made a misspelled
127
+ * extract look like a successful empty result until the next tool in the pipe
128
+ * tried to parse it. A path that resolves to a present null, false, 0, or ""
129
+ * is a real value and is returned unchanged — only an unresolved path errors.
130
+ */
131
+ export function errExtractionNotFound(path) {
132
+ return new AppError(
133
+ CodeExtractionNotFound,
134
+ `No value at --get path "${path}".`,
135
+ 'Check the path against the envelope (run the same command without --get, or with --json, to see its shape). '
136
+ + 'Present null, false, 0, and "" values are returned as-is; only an unresolvable path fails.'
137
+ );
138
+ }
139
+
121
140
  /**
122
141
  * Structured error for a destructive action that requires confirmation
123
142
  * but no human is available to answer (non-TTY, pipes, or AI agents).
143
+ *
124
144
  * The hint is written to be machine-parseable: an agent can read the
125
- * re-run command and either present it to its user or execute it.
145
+ * exact re-run command — target, table, pinned profile/instance — and
146
+ * present it to its user for approval before executing. It deliberately
147
+ * does NOT recommend permanently disabling confirmations: an approval for
148
+ * one destructive action is not consent to disarm the guard for every
149
+ * future action.
150
+ *
151
+ * @param {string} question — e.g. `Delete incident INC0010001?`
152
+ * @param {string} [rerunCmd] — complete re-run command WITHOUT --force,
153
+ * already shell-quoted. Empty when the caller cannot reconstruct it.
126
154
  */
127
- export function errConfirmationRequired(question, rerunCmd) {
155
+ export function errConfirmationRequired(question, rerunCmd = '') {
156
+ const cmd = String(rerunCmd || '').trim();
157
+ const hint = cmd
158
+ ? `This is a destructive action. Ask your user to approve this exact command, then re-run:\n ${cmd} --force`
159
+ : 'This is a destructive action. Ask your user to approve it, then re-run the same command with --force.';
128
160
  return new AppError(
129
161
  CodeConfirmationRequired,
130
- `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`,
131
- `This is a destructive action. Ask your user to confirm, then re-run:\n ${rerunCmd} --force\nOr set skip_confirmations on the profile to disable prompts:\n jsn auth login <instance> --skip-confirmations`
162
+ `${question} — confirmation required. Pass --force only after your user approves this operation.`,
163
+ hint
132
164
  );
133
165
  }
134
166
 
@@ -152,12 +184,20 @@ export function isErrorCode(err, code) {
152
184
  *
153
185
  * Exit-code contract (observable behavior, pinned by tests):
154
186
  * not_found → 1 stderr, "Error (not_found): …" + identifier hint
155
- * usage / usage_error → 2 stderr, "Error (usage): …"
187
+ * extraction_not_found → 1 JSON envelope on stdout in JSON mode, else stderr
188
+ * usage / usage_error → 2 JSON envelope on stdout in JSON mode
189
+ * (machine consumers), else stderr "Error (usage): …"
156
190
  * system_error → 3 stderr, "Error (system): …"
157
191
  * confirmation_required → 1 JSON envelope on stdout in JSON mode
158
192
  * (machine consumers), else stderr + hint
159
193
  * anything else → 1 stderr, "Error: …"
160
194
  *
195
+ * The JSON envelope is the SAME shape for every failure that a machine
196
+ * consumer can hit — guardError(), the mutation middleware, parse-time
197
+ * failures routed by cli.js, and handler-thrown AppErrors all end up here, so
198
+ * `--json` never yields human prose on stdout and never yields a
199
+ * success-shaped payload for a failed command.
200
+ *
161
201
  * jsonMode resolution: prefer app.output.effectiveFormat() (respects
162
202
  * --json/--quiet/etc. shortcuts AND piped-stdout auto-detection); fall
163
203
  * back to argv.format for callers without an App (unit tests).
@@ -201,10 +241,35 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
201
241
  return { stream: 'stderr', text, exitCode: 1 };
202
242
  }
203
243
 
244
+ if (code === CodeExtractionNotFound) {
245
+ // Same contract as every other machine-consumable failure: a parseable
246
+ // envelope on stdout when the caller is in JSON mode, human text on
247
+ // stderr otherwise. Exit 1 — the command ran, the value was absent.
248
+ if (resolveJsonMode(app, argv) && app) {
249
+ return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
250
+ }
251
+ let text = `Error (${code}): ${err.message}\n`;
252
+ if (err.hint) text += `\nHint: ${err.hint}\n`;
253
+ return { stream: 'stderr', text, exitCode: 1 };
254
+ }
255
+
204
256
  // 'usage' is the legacy literal; CodeUsage ('usage_error') is the
205
257
  // exported constant. Both render as the usage class.
206
258
  if (code === CodeUsage || code === 'usage') {
207
- return { stream: 'stderr', text: `Error (usage): ${err.message}\n`, exitCode: 2 };
259
+ // Usage failures were the last split-brain case: a parse-time failure
260
+ // exited 1 with raw prose, a middleware guard exited 2 with a JSON
261
+ // envelope, and a handler-thrown usage error exited 2 with prose — all
262
+ // for the same class of mistake. One renderer, one exit code (2), and a
263
+ // structured envelope whenever the caller is in JSON mode.
264
+ if (resolveJsonMode(app, argv) && app) {
265
+ return { stream: 'stdout', text: null, exitCode: 2, appErr: true };
266
+ }
267
+ // Keep the actionable hint (the failing command's --help) in the human view
268
+ // too — it used to be dropped on this path while every other error class
269
+ // printed one.
270
+ let text = `Error (usage): ${err.message}\n`;
271
+ if (err.hint) text += `\nHint: ${err.hint}\n`;
272
+ return { stream: 'stderr', text, exitCode: 2 };
208
273
  }
209
274
 
210
275
  if (code === 'system_error') {