arc-1 0.9.20 → 0.9.21

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.
Files changed (123) hide show
  1. package/dist/adt/client.d.ts +29 -1
  2. package/dist/adt/client.d.ts.map +1 -1
  3. package/dist/adt/client.js +72 -3
  4. package/dist/adt/client.js.map +1 -1
  5. package/dist/adt/ddic-xml.d.ts +29 -0
  6. package/dist/adt/ddic-xml.d.ts.map +1 -1
  7. package/dist/adt/ddic-xml.js +112 -1
  8. package/dist/adt/ddic-xml.js.map +1 -1
  9. package/dist/adt/devtools.d.ts +9 -2
  10. package/dist/adt/devtools.d.ts.map +1 -1
  11. package/dist/adt/devtools.js +126 -5
  12. package/dist/adt/devtools.js.map +1 -1
  13. package/dist/adt/diagnostics.d.ts +99 -4
  14. package/dist/adt/diagnostics.d.ts.map +1 -1
  15. package/dist/adt/diagnostics.js +443 -10
  16. package/dist/adt/diagnostics.js.map +1 -1
  17. package/dist/adt/errors.d.ts +13 -1
  18. package/dist/adt/errors.d.ts.map +1 -1
  19. package/dist/adt/errors.js +58 -0
  20. package/dist/adt/errors.js.map +1 -1
  21. package/dist/adt/features.d.ts.map +1 -1
  22. package/dist/adt/features.js +2 -1
  23. package/dist/adt/features.js.map +1 -1
  24. package/dist/adt/gcts.d.ts +3 -3
  25. package/dist/adt/gcts.d.ts.map +1 -1
  26. package/dist/adt/gcts.js +24 -3
  27. package/dist/adt/gcts.js.map +1 -1
  28. package/dist/adt/http.d.ts.map +1 -1
  29. package/dist/adt/http.js +4 -3
  30. package/dist/adt/http.js.map +1 -1
  31. package/dist/adt/transport.d.ts +43 -4
  32. package/dist/adt/transport.d.ts.map +1 -1
  33. package/dist/adt/transport.js +84 -10
  34. package/dist/adt/transport.js.map +1 -1
  35. package/dist/adt/types.d.ts +104 -0
  36. package/dist/adt/types.d.ts.map +1 -1
  37. package/dist/adt/xml-parser.d.ts +37 -0
  38. package/dist/adt/xml-parser.d.ts.map +1 -1
  39. package/dist/adt/xml-parser.js +102 -0
  40. package/dist/adt/xml-parser.js.map +1 -1
  41. package/dist/authz/policy.d.ts.map +1 -1
  42. package/dist/authz/policy.js +11 -1
  43. package/dist/authz/policy.js.map +1 -1
  44. package/dist/cache/sqlite.d.ts.map +1 -1
  45. package/dist/cache/sqlite.js +10 -0
  46. package/dist/cache/sqlite.js.map +1 -1
  47. package/dist/cli.js +4 -2
  48. package/dist/cli.js.map +1 -1
  49. package/dist/handlers/diagnose.d.ts.map +1 -1
  50. package/dist/handlers/diagnose.js +88 -4
  51. package/dist/handlers/diagnose.js.map +1 -1
  52. package/dist/handlers/dispatch.d.ts.map +1 -1
  53. package/dist/handlers/dispatch.js +9 -0
  54. package/dist/handlers/dispatch.js.map +1 -1
  55. package/dist/handlers/feature-cache.d.ts +5 -0
  56. package/dist/handlers/feature-cache.d.ts.map +1 -1
  57. package/dist/handlers/feature-cache.js +10 -0
  58. package/dist/handlers/feature-cache.js.map +1 -1
  59. package/dist/handlers/git.d.ts.map +1 -1
  60. package/dist/handlers/git.js +3 -3
  61. package/dist/handlers/git.js.map +1 -1
  62. package/dist/handlers/manage.d.ts.map +1 -1
  63. package/dist/handlers/manage.js +44 -0
  64. package/dist/handlers/manage.js.map +1 -1
  65. package/dist/handlers/object-types.d.ts.map +1 -1
  66. package/dist/handlers/object-types.js +7 -0
  67. package/dist/handlers/object-types.js.map +1 -1
  68. package/dist/handlers/query.d.ts.map +1 -1
  69. package/dist/handlers/query.js +35 -3
  70. package/dist/handlers/query.js.map +1 -1
  71. package/dist/handlers/read.d.ts.map +1 -1
  72. package/dist/handlers/read.js +29 -7
  73. package/dist/handlers/read.js.map +1 -1
  74. package/dist/handlers/schemas.d.ts +67 -1
  75. package/dist/handlers/schemas.d.ts.map +1 -1
  76. package/dist/handlers/schemas.js +31 -0
  77. package/dist/handlers/schemas.js.map +1 -1
  78. package/dist/handlers/tool-registry.d.ts +2 -2
  79. package/dist/handlers/tool-registry.d.ts.map +1 -1
  80. package/dist/handlers/tool-registry.js +2 -0
  81. package/dist/handlers/tool-registry.js.map +1 -1
  82. package/dist/handlers/tools.d.ts.map +1 -1
  83. package/dist/handlers/tools.js +103 -11
  84. package/dist/handlers/tools.js.map +1 -1
  85. package/dist/handlers/transport.d.ts.map +1 -1
  86. package/dist/handlers/transport.js +82 -5
  87. package/dist/handlers/transport.js.map +1 -1
  88. package/dist/handlers/write/create.d.ts.map +1 -1
  89. package/dist/handlers/write/create.js +135 -3
  90. package/dist/handlers/write/create.js.map +1 -1
  91. package/dist/handlers/write-helpers.d.ts +1 -0
  92. package/dist/handlers/write-helpers.d.ts.map +1 -1
  93. package/dist/handlers/write-helpers.js +49 -5
  94. package/dist/handlers/write-helpers.js.map +1 -1
  95. package/dist/handlers/write.d.ts.map +1 -1
  96. package/dist/handlers/write.js +18 -2
  97. package/dist/handlers/write.js.map +1 -1
  98. package/dist/server/audit.d.ts +4 -2
  99. package/dist/server/audit.d.ts.map +1 -1
  100. package/dist/server/audit.js +42 -13
  101. package/dist/server/audit.js.map +1 -1
  102. package/dist/server/config.d.ts.map +1 -1
  103. package/dist/server/config.js +30 -1
  104. package/dist/server/config.js.map +1 -1
  105. package/dist/server/logger.d.ts +1 -1
  106. package/dist/server/logger.d.ts.map +1 -1
  107. package/dist/server/logger.js +9 -4
  108. package/dist/server/logger.js.map +1 -1
  109. package/dist/server/server.d.ts +1 -1
  110. package/dist/server/server.js +2 -2
  111. package/dist/server/server.js.map +1 -1
  112. package/dist/server/sinks/file.d.ts +3 -0
  113. package/dist/server/sinks/file.d.ts.map +1 -1
  114. package/dist/server/sinks/file.js +15 -3
  115. package/dist/server/sinks/file.js.map +1 -1
  116. package/dist/server/sinks/stderr.d.ts.map +1 -1
  117. package/dist/server/sinks/stderr.js +2 -30
  118. package/dist/server/sinks/stderr.js.map +1 -1
  119. package/dist/server/types.d.ts +6 -0
  120. package/dist/server/types.d.ts.map +1 -1
  121. package/dist/server/types.js +3 -0
  122. package/dist/server/types.js.map +1 -1
  123. package/package.json +1 -1
@@ -2,15 +2,15 @@
2
2
  * Runtime diagnostics for SAP ADT.
3
3
  *
4
4
  * - Short dumps (ST22): list and read ABAP runtime errors
5
- * - ABAP traces: list and analyze profiler trace files
5
+ * - ABAP traces: list/analyze recorded profiler traces, and arm/list/cancel trace requests
6
6
  *
7
- * All operations are read-only (GET requests).
8
- * Follows the same pure-function pattern as devtools.ts.
7
+ * Most operations are read-only (GET); arming/cancelling a trace request and changing ST05 SQL-trace
8
+ * state mutate server state via OperationType.Update. Follows the same pure-function pattern as devtools.ts.
9
9
  */
10
10
  import { createHash } from 'node:crypto';
11
11
  import { AdtApiError } from './errors.js';
12
12
  import { checkOperation, OperationType } from './safety.js';
13
- import { findDeepNodes, parseXml } from './xml-parser.js';
13
+ import { escapeXmlAttr, findDeepNodes, parseXml } from './xml-parser.js';
14
14
  // ─── Short Dumps ────────────────────────────────────────────────────
15
15
  const DEFAULT_DUMP_MAX_RESULTS = 50;
16
16
  const DEFAULT_SYSTEM_MESSAGE_MAX_RESULTS = 50;
@@ -115,7 +115,7 @@ export async function listDumps(http, safety, options) {
115
115
  */
116
116
  export async function getDump(http, safety, dumpId) {
117
117
  checkOperation(safety, OperationType.Read, 'GetDump');
118
- const safeId = normalizeDumpId(dumpId);
118
+ const safeId = normalizeAdtPathSegment(dumpId);
119
119
  // Fetch XML metadata and formatted text in parallel
120
120
  const [xmlResp, textResp] = await Promise.all([
121
121
  http.get(`/sap/bc/adt/runtime/dump/${safeId}`, {
@@ -133,8 +133,8 @@ export async function getDump(http, safety, dumpId) {
133
133
  * `%20` for spaces); otherwise we encode it once. Trims surrounding
134
134
  * whitespace which would otherwise be encoded as `%20` and break lookup.
135
135
  */
136
- function normalizeDumpId(dumpId) {
137
- const trimmed = String(dumpId ?? '').trim();
136
+ function normalizeAdtPathSegment(value) {
137
+ const trimmed = String(value ?? '').trim();
138
138
  if (!trimmed)
139
139
  return '';
140
140
  return trimmed.includes('%') ? trimmed : encodeURIComponent(trimmed);
@@ -209,7 +209,8 @@ export async function listTraces(http, safety) {
209
209
  */
210
210
  export async function getTraceHitlist(http, safety, traceId) {
211
211
  checkOperation(safety, OperationType.Read, 'GetTraceHitlist');
212
- const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${traceId}/hitlist`, {
212
+ const safeTraceId = normalizeAdtPathSegment(traceId);
213
+ const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${safeTraceId}/hitlist`, {
213
214
  Accept: 'application/xml',
214
215
  });
215
216
  return parseTraceHitlist(resp.body);
@@ -221,7 +222,8 @@ export async function getTraceHitlist(http, safety, traceId) {
221
222
  */
222
223
  export async function getTraceStatements(http, safety, traceId) {
223
224
  checkOperation(safety, OperationType.Read, 'GetTraceStatements');
224
- const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${traceId}/statements`, {
225
+ const safeTraceId = normalizeAdtPathSegment(traceId);
226
+ const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${safeTraceId}/statements`, {
225
227
  Accept: 'application/xml',
226
228
  });
227
229
  return parseTraceStatements(resp.body);
@@ -233,11 +235,213 @@ export async function getTraceStatements(http, safety, traceId) {
233
235
  */
234
236
  export async function getTraceDbAccesses(http, safety, traceId) {
235
237
  checkOperation(safety, OperationType.Read, 'GetTraceDbAccesses');
236
- const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${traceId}/dbAccesses`, {
238
+ const safeTraceId = normalizeAdtPathSegment(traceId);
239
+ const resp = await http.get(`/sap/bc/adt/runtime/traces/abaptraces/${safeTraceId}/dbAccesses`, {
237
240
  Accept: 'application/xml',
238
241
  });
239
242
  return parseTraceDbAccesses(resp.body);
240
243
  }
244
+ // ─── ABAP Trace Requests (arm a profiler trace, then read it back) ───
245
+ const TRACE_BASE = '/sap/bc/adt/runtime/traces/abaptraces';
246
+ const TRACE_PROCESS_TYPE_URIS = {
247
+ any: `${TRACE_BASE}/processtypes/any`,
248
+ http: `${TRACE_BASE}/processtypes/http`,
249
+ dialog: `${TRACE_BASE}/processtypes/dialog`,
250
+ batch: `${TRACE_BASE}/processtypes/batch`,
251
+ rfc: `${TRACE_BASE}/processtypes/rfc`,
252
+ };
253
+ const TRACE_OBJECT_TYPE_URIS = {
254
+ any: `${TRACE_BASE}/objecttypes/any`,
255
+ url: `${TRACE_BASE}/objecttypes/url`,
256
+ transaction: `${TRACE_BASE}/objecttypes/transaction`,
257
+ report: `${TRACE_BASE}/objecttypes/report`,
258
+ functionModule: `${TRACE_BASE}/objecttypes/functionmodule`,
259
+ };
260
+ /** Object types valid per process type; the first entry is the sensible default. */
261
+ const TRACE_PROCESS_OBJECTS = {
262
+ any: ['any', 'url', 'transaction', 'report', 'functionModule'],
263
+ http: ['url'],
264
+ dialog: ['transaction', 'report'],
265
+ batch: ['report'],
266
+ rfc: ['functionModule'],
267
+ };
268
+ /**
269
+ * Build the trc:parameters body. Procedural units stay on (hitlist/statements); SQL/aggregate per opts.
270
+ * allDbEvents follows sqlTrace: without it the `dbAccesses` analysis comes back EMPTY (only kernel summary
271
+ * rows) — live-verified — so the whole point of the trace (which tables/joins were hit) is lost.
272
+ */
273
+ function buildTraceParametersXml(p) {
274
+ return `<?xml version="1.0" encoding="UTF-8"?>
275
+ <trc:parameters xmlns:trc="http://www.sap.com/adt/runtime/traces/abaptraces">
276
+ <trc:allMiscAbapStatements value="false"/>
277
+ <trc:allProceduralUnits value="true"/>
278
+ <trc:allInternalTableEvents value="false"/>
279
+ <trc:allDynproEvents value="false"/>
280
+ <trc:description value="${escapeXmlAttr(p.description)}"/>
281
+ <trc:aggregate value="${p.aggregate}"/>
282
+ <trc:explicitOnOff value="false"/>
283
+ <trc:withRfcTracing value="false"/>
284
+ <trc:allSystemKernelEvents value="false"/>
285
+ <trc:sqlTrace value="${p.sqlTrace}"/>
286
+ <trc:allDbEvents value="${p.sqlTrace}"/>
287
+ <trc:maxSizeForTraceFile value="100"/>
288
+ <trc:maxTimeForTracing value="600"/>
289
+ </trc:parameters>`;
290
+ }
291
+ /**
292
+ * Arm a profiler trace request. The next execution matching {traceUser, processType, objectType} is
293
+ * recorded; read it back later via listTraces + the analysis sub-resources (dbAccesses needs sqlTrace).
294
+ * Two ADT POSTs: parameters (capture flags → parametersId in Location) then requests (when/who).
295
+ */
296
+ export async function createTraceRequest(http, safety, connectedUser, client, opts = {}) {
297
+ checkOperation(safety, OperationType.Update, 'CreateTraceRequest');
298
+ const processType = opts.processType ?? 'http';
299
+ const validObjects = TRACE_PROCESS_OBJECTS[processType];
300
+ const objectType = opts.objectType ?? validObjects[0];
301
+ if (!validObjects.includes(objectType)) {
302
+ throw new AdtApiError(`Invalid objectType "${objectType}" for processType "${processType}". Valid: ${validObjects.join(', ')}.`, 400, `${TRACE_BASE}/requests`);
303
+ }
304
+ const traceUser = (opts.traceUser ?? connectedUser).toUpperCase();
305
+ const sqlTrace = opts.sqlTrace !== false; // default true
306
+ const aggregate = opts.aggregate !== false; // default true
307
+ const maxExecutions = Math.max(1, Math.floor(opts.maxExecutions ?? 1));
308
+ const expiresHours = opts.expiresHours ?? 24;
309
+ if (!Number.isFinite(expiresHours) || expiresHours <= 0) {
310
+ throw new AdtApiError('expiresHours must be a positive number of hours.', 400, `${TRACE_BASE}/requests`);
311
+ }
312
+ const expires = new Date(Date.now() + expiresHours * 3_600_000).toISOString().replace(/\.\d+Z$/, 'Z');
313
+ const description = opts.description ?? `arc-1 ${processType}/${objectType} trace`;
314
+ // 1) capture parameters → parametersId (Location header)
315
+ const paramsResp = await http.post(`${TRACE_BASE}/parameters`, buildTraceParametersXml({ description, aggregate, sqlTrace }), 'application/xml');
316
+ const parametersId = paramsResp.headers.location;
317
+ if (!parametersId) {
318
+ throw new AdtApiError('Trace parameters POST returned no Location header (parametersId).', paramsResp.statusCode, `${TRACE_BASE}/parameters`, paramsResp.body);
319
+ }
320
+ // 2) create the request (when/who)
321
+ const qs = new URLSearchParams({
322
+ server: '*',
323
+ description,
324
+ traceUser,
325
+ traceClient: client,
326
+ processType: TRACE_PROCESS_TYPE_URIS[processType],
327
+ objectType: TRACE_OBJECT_TYPE_URIS[objectType],
328
+ expires,
329
+ maximalExecutions: String(maxExecutions),
330
+ parametersId,
331
+ }).toString();
332
+ const resp = await http.post(`${TRACE_BASE}/requests?${qs}`, '', 'application/xml');
333
+ const created = parseTraceRequestFeed(resp.body)[0];
334
+ if (!created?.id) {
335
+ throw new AdtApiError('Trace request POST returned no usable request entry (missing id).', resp.statusCode, `${TRACE_BASE}/requests`, resp.body);
336
+ }
337
+ return created;
338
+ }
339
+ /** List armed trace requests for a user (read-only). */
340
+ export async function listTraceRequests(http, safety, user) {
341
+ checkOperation(safety, OperationType.Read, 'ListTraceRequests');
342
+ const resp = await http.get(`${TRACE_BASE}/requests?user=${encodeURIComponent(user.toUpperCase())}`, {
343
+ Accept: 'application/atom+xml;type=feed',
344
+ });
345
+ return parseTraceRequestFeed(resp.body);
346
+ }
347
+ /** Cancel (delete) an armed trace request by id (the full ADT path from a request entry). */
348
+ export async function deleteTraceRequest(http, safety, id) {
349
+ checkOperation(safety, OperationType.Update, 'DeleteTraceRequest');
350
+ await http.delete(traceRequestPath(id));
351
+ }
352
+ // Normalize full-path, absolute-URL, or bare trace-request ids without creating an arbitrary DELETE primitive.
353
+ function traceRequestPath(id) {
354
+ const prefix = `${TRACE_BASE}/requests/`;
355
+ const trimmed = String(id ?? '').trim();
356
+ if (!trimmed)
357
+ throw new AdtApiError('Refusing to cancel an empty trace-request id.', 400, prefix);
358
+ if (trimmed.includes('\\'))
359
+ throw new AdtApiError(`Refusing to cancel "${id}": malformed trace-request id.`, 400, trimmed);
360
+ const isAbsoluteUrl = /^https?:\/\//i.test(trimmed);
361
+ let raw = '';
362
+ try {
363
+ if (isAbsoluteUrl)
364
+ new URL(trimmed);
365
+ raw = isAbsoluteUrl
366
+ ? (trimmed.match(/^https?:\/\/[^/?#]*(\/[^?#]*)?/i)?.[1] ?? '/')
367
+ : trimmed.startsWith('/')
368
+ ? trimmed
369
+ : `${prefix}${trimmed}`;
370
+ }
371
+ catch {
372
+ throw new AdtApiError(`Refusing to cancel "${id}": malformed trace-request id.`, 400, trimmed);
373
+ }
374
+ const decoded = decodeTraceRequestPath(raw, id);
375
+ const tail = decoded.startsWith(prefix) ? decoded.slice(prefix.length) : '';
376
+ if (!tail || tail !== tail.trim() || /[\\/?#\s]/.test(tail) || tail === '.' || tail === '..') {
377
+ throw new AdtApiError(`Refusing to cancel "${id}": not an abaptraces trace-request id (expected ${prefix}<id>).`, 400, decoded);
378
+ }
379
+ return raw;
380
+ }
381
+ function decodeTraceRequestPath(path, originalId) {
382
+ let decoded = path;
383
+ for (let i = 0; i < 5; i++) {
384
+ try {
385
+ const next = decodeURIComponent(decoded);
386
+ if (next === decoded)
387
+ return decoded;
388
+ decoded = next;
389
+ }
390
+ catch {
391
+ throw new AdtApiError(`Refusing to cancel "${originalId}": malformed trace-request id.`, 400, decoded);
392
+ }
393
+ }
394
+ throw new AdtApiError(`Refusing to cancel "${originalId}": over-encoded trace-request id.`, 400, decoded);
395
+ }
396
+ /** Pick the value of a node that repeats per role (admin/trace); prefer `trace`, else the first. */
397
+ function pickTraceRole(node, valueKey) {
398
+ const arr = toRecordArray(node);
399
+ const match = arr.find((n) => n['@_role'] === 'trace') ?? arr[0];
400
+ const value = match?.[valueKey];
401
+ return value != null ? String(value) : undefined;
402
+ }
403
+ /**
404
+ * Parse the abaptraces/requests atom feed into TraceRequest[] (used for both list + create responses).
405
+ * Request id = the `<atom:id>` element (full ADT path with URL-encoded commas) → passed verbatim to DELETE.
406
+ */
407
+ export function parseTraceRequestFeed(xml) {
408
+ if (!xml || xml.trim().length === 0)
409
+ return [];
410
+ const parsed = parseXml(xml);
411
+ const entries = findDeepNodes(parsed, 'entry');
412
+ return entries.map((entry) => {
413
+ const ext = (entry.extendedData ?? {});
414
+ const executions = (ext.executions ?? {});
415
+ const processType = (ext.processType ?? {});
416
+ const object = (ext.object ?? {});
417
+ const content = (entry.content ?? {});
418
+ const req = {
419
+ id: String(entry.id ?? content['@_src'] ?? ''),
420
+ title: String(entry.title ?? ext.description ?? ''),
421
+ };
422
+ const user = pickTraceRole(entry.author, 'name');
423
+ if (user)
424
+ req.user = user;
425
+ const client = pickTraceRole(ext.client, '#text');
426
+ if (client)
427
+ req.client = client;
428
+ if (ext.expires != null)
429
+ req.expires = String(ext.expires);
430
+ if (processType['@_processTypeId'] != null)
431
+ req.processType = String(processType['@_processTypeId']);
432
+ if (object['@_objectTypeId'] != null)
433
+ req.objectType = String(object['@_objectTypeId']);
434
+ const maximal = Number(executions['@_maximal']);
435
+ if (Number.isFinite(maximal))
436
+ req.maxExecutions = maximal;
437
+ const completed = Number(executions['@_completed']);
438
+ if (Number.isFinite(completed))
439
+ req.completedExecutions = completed;
440
+ if (ext.host != null)
441
+ req.host = String(ext.host);
442
+ return req;
443
+ });
444
+ }
241
445
  // ─── Parsers ────────────────────────────────────────────────────────
242
446
  /**
243
447
  * Parse dump listing Atom feed.
@@ -577,6 +781,235 @@ export function parseTraceDbAccesses(xml) {
577
781
  accessTime: Number(node['@_accessTime'] ?? 0),
578
782
  }));
579
783
  }
784
+ /** Parse the `sap-statistics` header (`k=v,k=v,…`) into a numeric map. Tolerates missing/extra fields. */
785
+ export function parseSapStatistics(header) {
786
+ const out = {};
787
+ for (const pair of header.split(',')) {
788
+ const idx = pair.indexOf('=');
789
+ if (idx <= 0)
790
+ continue;
791
+ const key = pair.slice(0, idx).trim();
792
+ const val = Number(pair.slice(idx + 1).trim());
793
+ if (key && Number.isFinite(val))
794
+ out[key] = val;
795
+ }
796
+ return out;
797
+ }
798
+ /** Route to the dominant time component so the LLM picks the right deeper signal. */
799
+ export function verdictFromStatistics(m) {
800
+ const total = m.gwtotal ?? m.total ?? 0;
801
+ if (total <= 0) {
802
+ return {
803
+ bound: 'unknown',
804
+ note: 'No Gateway timing in sap-statistics — ensure the URL is an OData/Gateway service on the SAP host ARC-1 connects to.',
805
+ };
806
+ }
807
+ const db = m.gwappdb ?? 0;
808
+ const candidates = [
809
+ [
810
+ 'db',
811
+ db,
812
+ 'DB-bound: the CDS/SQL query dominates. Inspect the generated SQL with SAPDiagnose(action="cds_sql"), measure it with SAPQuery (queryExecutionTime), or arm an ST05 SQL trace.',
813
+ ],
814
+ [
815
+ 'app',
816
+ Math.max((m.gwapp ?? 0) - db, 0),
817
+ 'ABAP/SADL-bound: application logic dominates, not the DB (an $expand N+1 is the classic cause) — do not ST05-hunt for a slow query. Find the OData implementation (the CDS behind a V4/RAP binding, or the SEGW Gateway DPC class) and arm a profiler trace via SAPDiagnose(action="trace_start"), then read it with action="traces" analysis="dbAccesses". Note: for HTTP/OData traces the ABAP hitlist/statements are usually empty — use ST12/SAT in SAP GUI for the call tree.',
818
+ ],
819
+ [
820
+ // gwfw (GW framework) and gwhub (GW hub processing) both measure framework time; older
821
+ // releases (e.g. NW 7.50) report only gwhub, so take whichever is present.
822
+ 'framework',
823
+ Math.max(m.gwfw ?? 0, m.gwhub ?? 0),
824
+ 'Gateway-framework-bound: likely metadata/first-call (cold-cache) cost. Re-probe to compare warm vs cold.',
825
+ ],
826
+ ['auth', m.icfauth ?? 0, 'Auth-bound: ICF/DCL authorization overhead dominates.'],
827
+ ];
828
+ candidates.sort((a, b) => b[1] - a[1]);
829
+ const [bound, top, note] = candidates[0];
830
+ // Gateway reported a total but no component is > 0 (the release didn't break it down) — don't
831
+ // guess "db". Report the total as undifferentiated Gateway time and point to the deeper traces.
832
+ if (top <= 0) {
833
+ return {
834
+ bound: 'unknown',
835
+ note: `Gateway total ${total}ms, but sap-statistics didn't break it into components on this release (no gwappdb/gwapp/gwfw). Treat it as Gateway processing; arm an ST05 SQL trace or ABAP profiler trace (SAPDiagnose action="traces") for the breakdown.`,
836
+ };
837
+ }
838
+ return { bound, note };
839
+ }
840
+ /**
841
+ * GET an OData URL with `?sap-statistics=true` + a wall-clock timer → server-side timing split + verdict.
842
+ * Security: `url` must be a host-relative path on the configured SAP system (no absolute URLs / SSRF).
843
+ */
844
+ export async function probeODataPerformance(http, safety, url) {
845
+ checkOperation(safety, OperationType.Query, 'ProbeODataPerformance');
846
+ assertODataPerfUrl(url);
847
+ const withStat = url.includes('?') ? `${url}&sap-statistics=true` : `${url}?sap-statistics=true`;
848
+ const t0 = Date.now();
849
+ const resp = await http.get(withStat);
850
+ const wallClockMs = Date.now() - t0;
851
+ const statistics = parseSapStatistics(resp.headers['sap-statistics'] ?? '');
852
+ const fesrec = Number(resp.headers['sap-perf-fesrec']);
853
+ return {
854
+ url: withStat,
855
+ statusCode: resp.statusCode,
856
+ wallClockMs,
857
+ statistics,
858
+ fesrecMicros: Number.isFinite(fesrec) ? fesrec : undefined,
859
+ responseBytes: Buffer.byteLength(resp.body ?? ''),
860
+ verdict: verdictFromStatistics(statistics),
861
+ };
862
+ }
863
+ function assertODataPerfUrl(url) {
864
+ const rawPath = url.split(/[?#]/, 1)[0] ?? '';
865
+ let parsed;
866
+ try {
867
+ parsed = new URL(url, 'https://arc1.invalid');
868
+ }
869
+ catch {
870
+ throw invalidODataPerfUrl();
871
+ }
872
+ if (url.length > 4096 ||
873
+ !url.startsWith('/') ||
874
+ url.startsWith('//') ||
875
+ url.includes('://') ||
876
+ url.includes('\\') ||
877
+ url.includes('#') ||
878
+ /(?:^|\/)(?:\.|%2e)(?:\/|$)/i.test(rawPath) ||
879
+ /(?:^|\/)(?:\.|%2e){2}(?:\/|$)/i.test(rawPath) ||
880
+ /%5c/i.test(rawPath) ||
881
+ parsed.origin !== 'https://arc1.invalid' ||
882
+ !isODataPath(parsed.pathname)) {
883
+ throw invalidODataPerfUrl();
884
+ }
885
+ }
886
+ function isODataPath(pathname) {
887
+ return (pathname === '/sap/opu/odata' ||
888
+ pathname.startsWith('/sap/opu/odata/') ||
889
+ pathname === '/sap/opu/odata4' ||
890
+ pathname.startsWith('/sap/opu/odata4/'));
891
+ }
892
+ function invalidODataPerfUrl() {
893
+ return new Error('odata_perf url must be a host-relative OData path on the configured SAP system (e.g. "/sap/opu/odata4/.../Entity?$filter=..."); absolute URLs and non-OData SAP paths are not allowed.');
894
+ }
895
+ function nodeText(value) {
896
+ // `statement` is an ARRAY_TAG in the XML parser, so a single <ddl:statement> arrives as [text].
897
+ if (Array.isArray(value))
898
+ return value.map(nodeText).join('\n');
899
+ if (typeof value === 'string')
900
+ return value;
901
+ if (value && typeof value === 'object')
902
+ return String(value['#text'] ?? '');
903
+ return '';
904
+ }
905
+ /** Parse the `ddl:createStatements` response → the native SQL each CDS view compiles to. */
906
+ export function parseCdsCreateStatements(xml, fallbackName = '') {
907
+ const parsed = parseXml(xml);
908
+ const sourceNodes = findDeepNodes(parsed, 'source');
909
+ const name = sourceNodes.length > 0 && sourceNodes[0]['@_name'] != null ? String(sourceNodes[0]['@_name']) : fallbackName;
910
+ const statements = findDeepNodes(parsed, 'createStatement')
911
+ .map((node) => ({
912
+ name: node['@_name'] != null ? String(node['@_name']) : undefined,
913
+ type: node['@_type'] != null ? String(node['@_type']) : undefined,
914
+ state: node['@_state'] != null ? String(node['@_state']) : undefined,
915
+ sql: nodeText(node.statement).trim(),
916
+ }))
917
+ .filter((s) => s.sql.length > 0);
918
+ return { name, statements };
919
+ }
920
+ /**
921
+ * Return the native SQL `CREATE VIEW` statements a CDS view (DDLS) generates.
922
+ * POST-only + CSRF (auto-managed) + a dedicated Accept type. May 404/405 on releases without the
923
+ * modern CDS DDL stack (e.g. NW 7.50) — let that surface so the caller learns the feature is absent.
924
+ */
925
+ export async function getCdsCreateStatements(http, safety, name) {
926
+ checkOperation(safety, OperationType.Read, 'GetCdsCreateStatements');
927
+ const resp = await http.post(`/sap/bc/adt/ddic/ddl/createstatements/${encodeURIComponent(name)}`, '', undefined, {
928
+ Accept: 'application/vnd.sap.adt.ddl.createStatements+xml',
929
+ });
930
+ return parseCdsCreateStatements(resp.body, name);
931
+ }
932
+ // ─── ST05 SQL-trace state control ───────────────────────────────────
933
+ const ST05_STATE_URL = '/sap/bc/adt/st05/trace/state';
934
+ const ST05_DIRECTORY_URL = '/sap/bc/adt/st05/trace/directory';
935
+ const ST05_STATE_CONTENT_TYPE = 'application/vnd.sap.adt.perf.trace.state.v1+xml';
936
+ function boolText(value) {
937
+ return nodeText(value).trim().toLowerCase() === 'true';
938
+ }
939
+ function optText(value) {
940
+ const t = nodeText(value).trim();
941
+ return t.length > 0 ? t : undefined;
942
+ }
943
+ /** Parse the `ts:traceStateInstanceTable` (ST05 trace on/off per instance + filter). */
944
+ export function parseSqlTraceState(xml) {
945
+ const parsed = parseXml(xml);
946
+ return findDeepNodes(parsed, 'traceStateInstance').map((node) => {
947
+ const tt = (node.traceTypes ?? {});
948
+ const f = (node.traceFilter ?? {});
949
+ return {
950
+ instance: nodeText(node.instance),
951
+ host: optText(node.host),
952
+ isLocal: boolText(node.isLocal),
953
+ isSelected: boolText(node.isSelected),
954
+ traceTypes: {
955
+ sql: boolText(tt.sqlOn),
956
+ buf: boolText(tt.bufOn),
957
+ enq: boolText(tt.enqOn),
958
+ rfc: boolText(tt.rfcOn),
959
+ http: boolText(tt.httpOn),
960
+ apc: boolText(tt.apcOn),
961
+ amc: boolText(tt.amcOn),
962
+ auth: boolText(tt.authOn),
963
+ },
964
+ filter: {
965
+ user: optText(f.traceUser),
966
+ transactionCode: optText(f.transactionCode),
967
+ program: optText(f.program),
968
+ rfcFunction: optText(f.rfcFunction),
969
+ url: optText(f.url),
970
+ wpId: optText(f.wpId),
971
+ },
972
+ };
973
+ });
974
+ }
975
+ /** Parse `td:traceDirectory` — SAP returns the TMC "SQL Trace Analysis" deep-link (no ADT record API). */
976
+ export function parseSqlTraceDirectory(xml) {
977
+ const parsed = parseXml(xml);
978
+ const dir = findDeepNodes(parsed, 'traceDirectory')[0];
979
+ const url = dir ? optText(dir.uri) : undefined;
980
+ return {
981
+ recordViewerUrl: url,
982
+ note: url
983
+ ? 'SAP returns the SQL Trace Analysis (Technical Monitoring Cockpit) deep-link as the place to read the recorded SQL — there is no ADT SQL-record API. Open this URL in a browser (needs the /sap/bc/stmc SICF service active).'
984
+ : 'No trace-directory URL returned by ADT on this system.',
985
+ };
986
+ }
987
+ export async function getSqlTraceState(http, safety) {
988
+ checkOperation(safety, OperationType.Read, 'GetSqlTraceState');
989
+ const resp = await http.get(ST05_STATE_URL, { Accept: ST05_STATE_CONTENT_TYPE });
990
+ return parseSqlTraceState(resp.body);
991
+ }
992
+ export async function getSqlTraceDirectory(http, safety) {
993
+ checkOperation(safety, OperationType.Read, 'GetSqlTraceDirectory');
994
+ const resp = await http.get(ST05_DIRECTORY_URL, { Accept: 'application/*' });
995
+ return parseSqlTraceDirectory(resp.body);
996
+ }
997
+ /** Arm/disarm the ST05 SQL trace (optionally filtered to one user): GET the state, edit, PUT it back. */
998
+ export async function setSqlTraceState(http, safety, opts) {
999
+ checkOperation(safety, OperationType.Update, 'SetSqlTraceState');
1000
+ const current = await http.get(ST05_STATE_URL, { Accept: ST05_STATE_CONTENT_TYPE });
1001
+ // Known ceiling: targeted replace on the raw state XML preserves SAP's exact element set/order and any
1002
+ // fields ARC-1 doesn't model; flips ALL instances (add per-instance targeting only if a multi-instance
1003
+ // system needs it).
1004
+ let body = current.body.replace(/<ts:sqlOn>[^<]*<\/ts:sqlOn>/g, () => `<ts:sqlOn>${opts.sqlOn}</ts:sqlOn>`);
1005
+ if (opts.traceUser !== undefined) {
1006
+ const u = opts.traceUser.trim();
1007
+ const repl = u.length > 0 ? `<ts:traceUser>${escapeXmlAttr(u)}</ts:traceUser>` : '<ts:traceUser/>';
1008
+ body = body.replace(/<ts:traceUser\/>|<ts:traceUser>[^<]*<\/ts:traceUser>/g, () => repl);
1009
+ }
1010
+ const resp = await http.put(ST05_STATE_URL, body, ST05_STATE_CONTENT_TYPE);
1011
+ return parseSqlTraceState(resp.body);
1012
+ }
580
1013
  // ─── Helpers ────────────────────────────────────────────────────────
581
1014
  function buildFeedQueryString(options, defaultMaxResults, userAttribute) {
582
1015
  const params = [];