arc-1 1.0.2 → 1.1.1

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 (171) hide show
  1. package/README.md +15 -1
  2. package/bin/arc1-cli.js +3 -7
  3. package/bin/arc1.js +4 -9
  4. package/dist/adt/abapgit.d.ts +10 -5
  5. package/dist/adt/abapgit.d.ts.map +1 -1
  6. package/dist/adt/abapgit.js +357 -76
  7. package/dist/adt/abapgit.js.map +1 -1
  8. package/dist/adt/atc.d.ts +59 -0
  9. package/dist/adt/atc.d.ts.map +1 -0
  10. package/dist/adt/atc.js +373 -0
  11. package/dist/adt/atc.js.map +1 -0
  12. package/dist/adt/aunit-package.d.ts +22 -0
  13. package/dist/adt/aunit-package.d.ts.map +1 -0
  14. package/dist/adt/aunit-package.js +78 -0
  15. package/dist/adt/aunit-package.js.map +1 -0
  16. package/dist/adt/aunit.d.ts +143 -0
  17. package/dist/adt/aunit.d.ts.map +1 -0
  18. package/dist/adt/aunit.js +987 -0
  19. package/dist/adt/aunit.js.map +1 -0
  20. package/dist/adt/bsp-path.d.ts +15 -0
  21. package/dist/adt/bsp-path.d.ts.map +1 -0
  22. package/dist/adt/bsp-path.js +33 -0
  23. package/dist/adt/bsp-path.js.map +1 -0
  24. package/dist/adt/client.d.ts +23 -9
  25. package/dist/adt/client.d.ts.map +1 -1
  26. package/dist/adt/client.js +84 -75
  27. package/dist/adt/client.js.map +1 -1
  28. package/dist/adt/config.d.ts +2 -0
  29. package/dist/adt/config.d.ts.map +1 -1
  30. package/dist/adt/config.js +1 -0
  31. package/dist/adt/config.js.map +1 -1
  32. package/dist/adt/devtools.d.ts +8 -30
  33. package/dist/adt/devtools.d.ts.map +1 -1
  34. package/dist/adt/devtools.js +41 -218
  35. package/dist/adt/devtools.js.map +1 -1
  36. package/dist/adt/diagnostics.d.ts +1 -1
  37. package/dist/adt/diagnostics.d.ts.map +1 -1
  38. package/dist/adt/diagnostics.js +12 -12
  39. package/dist/adt/diagnostics.js.map +1 -1
  40. package/dist/adt/errors.d.ts +7 -0
  41. package/dist/adt/errors.d.ts.map +1 -1
  42. package/dist/adt/errors.js +7 -0
  43. package/dist/adt/errors.js.map +1 -1
  44. package/dist/adt/features.d.ts +9 -3
  45. package/dist/adt/features.d.ts.map +1 -1
  46. package/dist/adt/features.js +40 -8
  47. package/dist/adt/features.js.map +1 -1
  48. package/dist/adt/gcts.d.ts +12 -45
  49. package/dist/adt/gcts.d.ts.map +1 -1
  50. package/dist/adt/gcts.js +299 -171
  51. package/dist/adt/gcts.js.map +1 -1
  52. package/dist/adt/http-deadline.d.ts +23 -0
  53. package/dist/adt/http-deadline.d.ts.map +1 -0
  54. package/dist/adt/http-deadline.js +106 -0
  55. package/dist/adt/http-deadline.js.map +1 -0
  56. package/dist/adt/http-wire-body.d.ts +2 -0
  57. package/dist/adt/http-wire-body.d.ts.map +1 -0
  58. package/dist/adt/http-wire-body.js +14 -0
  59. package/dist/adt/http-wire-body.js.map +1 -0
  60. package/dist/adt/http.d.ts +10 -24
  61. package/dist/adt/http.d.ts.map +1 -1
  62. package/dist/adt/http.js +89 -53
  63. package/dist/adt/http.js.map +1 -1
  64. package/dist/adt/path-safety.d.ts +16 -0
  65. package/dist/adt/path-safety.d.ts.map +1 -0
  66. package/dist/adt/path-safety.js +101 -0
  67. package/dist/adt/path-safety.js.map +1 -0
  68. package/dist/adt/semaphore.d.ts +2 -2
  69. package/dist/adt/semaphore.d.ts.map +1 -1
  70. package/dist/adt/semaphore.js +26 -9
  71. package/dist/adt/semaphore.js.map +1 -1
  72. package/dist/adt/source-diff.d.ts +10 -1
  73. package/dist/adt/source-diff.d.ts.map +1 -1
  74. package/dist/adt/source-diff.js +33 -7
  75. package/dist/adt/source-diff.js.map +1 -1
  76. package/dist/adt/text-search.d.ts +25 -0
  77. package/dist/adt/text-search.d.ts.map +1 -0
  78. package/dist/adt/text-search.js +152 -0
  79. package/dist/adt/text-search.js.map +1 -0
  80. package/dist/adt/transport-diff.d.ts +128 -0
  81. package/dist/adt/transport-diff.d.ts.map +1 -0
  82. package/dist/adt/transport-diff.js +524 -0
  83. package/dist/adt/transport-diff.js.map +1 -0
  84. package/dist/adt/transport.d.ts +78 -13
  85. package/dist/adt/transport.d.ts.map +1 -1
  86. package/dist/adt/transport.js +447 -44
  87. package/dist/adt/transport.js.map +1 -1
  88. package/dist/adt/types.d.ts +23 -12
  89. package/dist/adt/types.d.ts.map +1 -1
  90. package/dist/adt/xml-parser.d.ts +4 -10
  91. package/dist/adt/xml-parser.d.ts.map +1 -1
  92. package/dist/adt/xml-parser.js +57 -62
  93. package/dist/adt/xml-parser.js.map +1 -1
  94. package/dist/authz/policy.d.ts.map +1 -1
  95. package/dist/authz/policy.js +5 -2
  96. package/dist/authz/policy.js.map +1 -1
  97. package/dist/cli-args.d.ts +2 -2
  98. package/dist/cli-args.js +2 -2
  99. package/dist/cli-checks.d.ts +56 -0
  100. package/dist/cli-checks.d.ts.map +1 -0
  101. package/dist/cli-checks.js +368 -0
  102. package/dist/cli-checks.js.map +1 -0
  103. package/dist/cli.d.ts +68 -15
  104. package/dist/cli.d.ts.map +1 -1
  105. package/dist/cli.js +677 -297
  106. package/dist/cli.js.map +1 -1
  107. package/dist/handlers/cds-hints.d.ts.map +1 -1
  108. package/dist/handlers/cds-hints.js +1 -10
  109. package/dist/handlers/cds-hints.js.map +1 -1
  110. package/dist/handlers/diagnose.d.ts +4 -0
  111. package/dist/handlers/diagnose.d.ts.map +1 -1
  112. package/dist/handlers/diagnose.js +621 -11
  113. package/dist/handlers/diagnose.js.map +1 -1
  114. package/dist/handlers/dispatch.d.ts.map +1 -1
  115. package/dist/handlers/dispatch.js +40 -1
  116. package/dist/handlers/dispatch.js.map +1 -1
  117. package/dist/handlers/git.d.ts.map +1 -1
  118. package/dist/handlers/git.js +165 -84
  119. package/dist/handlers/git.js.map +1 -1
  120. package/dist/handlers/read.d.ts.map +1 -1
  121. package/dist/handlers/read.js +19 -10
  122. package/dist/handlers/read.js.map +1 -1
  123. package/dist/handlers/schemas.d.ts +15 -2
  124. package/dist/handlers/schemas.d.ts.map +1 -1
  125. package/dist/handlers/schemas.js +75 -6
  126. package/dist/handlers/schemas.js.map +1 -1
  127. package/dist/handlers/search.js +1 -1
  128. package/dist/handlers/search.js.map +1 -1
  129. package/dist/handlers/tools.d.ts.map +1 -1
  130. package/dist/handlers/tools.js +54 -45
  131. package/dist/handlers/tools.js.map +1 -1
  132. package/dist/handlers/transport.d.ts +2 -1
  133. package/dist/handlers/transport.d.ts.map +1 -1
  134. package/dist/handlers/transport.js +132 -30
  135. package/dist/handlers/transport.js.map +1 -1
  136. package/dist/handlers/write/create.d.ts.map +1 -1
  137. package/dist/handlers/write/create.js +11 -0
  138. package/dist/handlers/write/create.js.map +1 -1
  139. package/dist/handlers/write/update-delete.d.ts.map +1 -1
  140. package/dist/handlers/write/update-delete.js +33 -10
  141. package/dist/handlers/write/update-delete.js.map +1 -1
  142. package/dist/handlers/write-helpers.d.ts.map +1 -1
  143. package/dist/handlers/write-helpers.js +3 -2
  144. package/dist/handlers/write-helpers.js.map +1 -1
  145. package/dist/server/audit.d.ts +1 -1
  146. package/dist/server/audit.d.ts.map +1 -1
  147. package/dist/server/audit.js +228 -21
  148. package/dist/server/audit.js.map +1 -1
  149. package/dist/server/config.d.ts +16 -0
  150. package/dist/server/config.d.ts.map +1 -1
  151. package/dist/server/config.js +145 -17
  152. package/dist/server/config.js.map +1 -1
  153. package/dist/server/effective-policy-log.d.ts +2 -1
  154. package/dist/server/effective-policy-log.d.ts.map +1 -1
  155. package/dist/server/effective-policy-log.js +8 -2
  156. package/dist/server/effective-policy-log.js.map +1 -1
  157. package/dist/server/multi-target-runtime.d.ts.map +1 -1
  158. package/dist/server/multi-target-runtime.js +3 -0
  159. package/dist/server/multi-target-runtime.js.map +1 -1
  160. package/dist/server/multi-target-tools.d.ts.map +1 -1
  161. package/dist/server/multi-target-tools.js +5 -0
  162. package/dist/server/multi-target-tools.js.map +1 -1
  163. package/dist/server/server.d.ts +15 -13
  164. package/dist/server/server.d.ts.map +1 -1
  165. package/dist/server/server.js +36 -26
  166. package/dist/server/server.js.map +1 -1
  167. package/dist/server/types.d.ts +2 -0
  168. package/dist/server/types.d.ts.map +1 -1
  169. package/dist/server/types.js +1 -0
  170. package/dist/server/types.js.map +1 -1
  171. package/package.json +16 -16
@@ -4,7 +4,7 @@
4
4
  * Transport mutations require explicit opt-in via allowWrites + allowTransportWrites.
5
5
  * Safety checks are applied at every entry point.
6
6
  */
7
- import { AdtApiError } from './errors.js';
7
+ import { AdtApiError, AdtNetworkError, AdtSafetyError } from './errors.js';
8
8
  import { checkOperation, checkTransport, OperationType } from './safety.js';
9
9
  import { decodeXmlEntities, escapeXmlAttr, findDeepNodes, parseNamedItems, parseXml } from './xml-parser.js';
10
10
  /**
@@ -30,15 +30,27 @@ export const CTS_ACCEPT_TREE = 'application/vnd.sap.adt.transportorganizertree.v
30
30
  export const CTS_CONTENT_TYPE_ORGANIZER = 'application/vnd.sap.adt.transportorganizer.v1+xml';
31
31
  /** XML namespace for CTS ADT transport manager payloads */
32
32
  export const CTS_NAMESPACE_TM = 'http://www.sap.com/cts/adt/tm';
33
+ export const DEFAULT_RELEASE_TIMEOUT_MS = 300_000;
34
+ const DEFAULT_RELEASE_INITIAL_DELAY_MS = 250;
35
+ const DEFAULT_RELEASE_MAX_DELAY_MS = 2_000;
36
+ const MODIFIABLE_RELEASE_STATUSES = new Set(['D', 'L']);
37
+ const IN_FLIGHT_RELEASE_STATUSES = new Set(['O', 'P']);
38
+ const TERMINAL_RELEASE_STATUSES = new Set(['R', 'N']);
39
+ function isKnownReleaseStatus(status) {
40
+ return (MODIFIABLE_RELEASE_STATUSES.has(status) ||
41
+ IN_FLIGHT_RELEASE_STATUSES.has(status) ||
42
+ TERMINAL_RELEASE_STATUSES.has(status));
43
+ }
33
44
  /** List transport requests for a user, optionally filtered by status (client-side) */
34
45
  export async function listTransports(http, safety, user, status) {
35
46
  checkTransport(safety, '', 'ListTransports', false);
36
- // Build query params following sapcli's pattern:
37
- // user={user}&target=true&requestType=KWT&requestStatus=DR
47
+ // Build query params.
38
48
  // requestType=KWT covers Workbench, Customizing, Transport of Copies.
39
49
  // requestStatus is sent server-side; we also filter client-side as a fallback.
50
+ // Keep user=* in the request: SAP treats '*' as a wildcard owner pattern.
51
+ // Omitting user defaults it to sy-uname on SAP_BASIS 7.50 and 7.58.
40
52
  const params = new URLSearchParams();
41
- if (user && user !== '*') {
53
+ if (user) {
42
54
  params.set('user', user);
43
55
  }
44
56
  params.set('target', 'true');
@@ -274,59 +286,431 @@ export function failedReleaseReports(reports) {
274
286
  return reports.filter((r) => r.status !== '' && !r.released);
275
287
  }
276
288
  /** Release a transport request; returns the parsed release check report(s) (`[]` if none/unparseable). */
277
- export async function releaseTransport(http, safety, transportId) {
289
+ export async function releaseTransport(http, safety, transportId, requestOptions) {
278
290
  checkTransport(safety, transportId, 'ReleaseTransport', true);
279
- const resp = await http.post(`/sap/bc/adt/cts/transportrequests/${encodeURIComponent(transportId)}/newreleasejobs`, undefined, undefined, { Accept: CTS_CONTENT_TYPE_ORGANIZER });
291
+ const resp = await http.post(`/sap/bc/adt/cts/transportrequests/${encodeURIComponent(transportId)}/newreleasejobs`, undefined, undefined, { Accept: CTS_CONTENT_TYPE_ORGANIZER }, requestOptions);
280
292
  return parseReleaseReports(resp.body);
281
293
  }
294
+ /** Collect every node with a local XML name, rather than stopping at the first matching branch. */
295
+ function collectAllNamedNodes(value, name, output = []) {
296
+ if (!value || typeof value !== 'object')
297
+ return output;
298
+ if (Array.isArray(value)) {
299
+ for (const entry of value)
300
+ collectAllNamedNodes(entry, name, output);
301
+ return output;
302
+ }
303
+ for (const [key, child] of Object.entries(value)) {
304
+ if (key === name) {
305
+ const candidates = Array.isArray(child) ? child : [child];
306
+ for (const candidate of candidates) {
307
+ if (candidate && typeof candidate === 'object')
308
+ output.push(candidate);
309
+ }
310
+ }
311
+ collectAllNamedNodes(child, name, output);
312
+ }
313
+ return output;
314
+ }
282
315
  /**
283
- * Release a transport request recursively — tasks first, then the parent request.
316
+ * Flatten a transport-organizer document into request/task state rows.
284
317
  *
285
- * The parent request's **refreshed state** is authoritative when SAP contradicts itself: a4h 758 can
286
- * return HTTP 200 with `abortrelapifail` for an empty/unclassified recursive release even though the
287
- * parent has already reached terminal status `R`. Task releases are best-effort — the parent release
288
- * folds them in. A failed *task* release is therefore not fatal, and a failed parent report is
289
- * reconciled with one fresh request read before it is treated as blocked. `reports` remains the raw SAP
290
- * response; `released` lists ids confirmed either by a clean report or terminal request state.
318
+ * Unlike `parseTransportList`, this deliberately supports a standalone `<tm:task>` response and a
319
+ * task lookup whose response contains the parent request tree. That makes it safe to verify either
320
+ * kind of id after `newreleasejobs`.
291
321
  */
292
- export async function releaseTransportRecursive(http, safety, transportId) {
293
- checkTransport(safety, transportId, 'ReleaseTransportRecursive', true);
294
- const transport = await getTransport(http, safety, transportId);
295
- const released = [];
296
- if (transport) {
297
- for (const task of transport.tasks) {
298
- if (task.status !== 'R') {
299
- checkTransport(safety, task.id, 'ReleaseTransportRecursive', true);
300
- const taskReports = await releaseTransport(http, safety, task.id);
301
- // Don't abort on a benign task failure; don't list a task that didn't actually release.
302
- if (failedReleaseReports(taskReports).length === 0)
303
- released.push(task.id);
304
- }
305
- }
306
- // Skip parent if already released (idempotent/retry-safe)
307
- if (transport.status === 'R') {
308
- return { released, reports: [] };
322
+ export function parseTransportNodeStates(xml) {
323
+ const parsed = parseXml(xml);
324
+ const states = new Map();
325
+ const parentIdFrom = (value) => {
326
+ const raw = String(value ?? '')
327
+ .trim()
328
+ .replace(/\/+$/, '');
329
+ return raw.slice(raw.lastIndexOf('/') + 1);
330
+ };
331
+ const add = (node, kind, structuralParent) => {
332
+ const id = String(node['@_number'] ?? '').trim();
333
+ if (!id)
334
+ return;
335
+ const key = id.toUpperCase();
336
+ const status = String(node['@_status'] ?? '').toUpperCase();
337
+ const parentId = parentIdFrom(structuralParent ?? node['@_parent']);
338
+ const previous = states.get(key);
339
+ states.set(key, {
340
+ id,
341
+ kind,
342
+ ...(parentId ? { parentId } : previous?.parentId ? { parentId: previous.parentId } : {}),
343
+ initialStatus: status || previous?.initialStatus || '',
344
+ lastStatus: status || previous?.lastStatus || '',
345
+ confirmedReleased: TERMINAL_RELEASE_STATUSES.has(status || previous?.lastStatus || ''),
346
+ ...(TERMINAL_RELEASE_STATUSES.has(status || previous?.lastStatus || '')
347
+ ? { confirmation: 'observed_terminal' }
348
+ : {}),
349
+ });
350
+ };
351
+ for (const request of collectAllNamedNodes(parsed, 'request')) {
352
+ const requestId = String(request['@_number'] ?? '').trim();
353
+ add(request, 'request');
354
+ for (const task of collectAllNamedNodes(request, 'task'))
355
+ add(task, 'task', requestId);
356
+ }
357
+ for (const task of collectAllNamedNodes(parsed, 'task'))
358
+ add(task, 'task');
359
+ return [...states.values()];
360
+ }
361
+ async function readTransportNodeStates(http, safety, lookupId, requestOptions) {
362
+ checkTransport(safety, lookupId, 'GetTransportReleaseState', false);
363
+ const resp = await http.get(`/sap/bc/adt/cts/transportrequests/${encodeURIComponent(lookupId)}`, {
364
+ Accept: CTS_CONTENT_TYPE_ORGANIZER,
365
+ }, requestOptions);
366
+ return parseTransportNodeStates(resp.body);
367
+ }
368
+ const transportIdsEqual = (left, right) => (left ?? '').toUpperCase() === right.toUpperCase();
369
+ function releaseErrorText(err) {
370
+ // Convergence errors bypass dispatch's minimal-error formatter. Never copy SAP
371
+ // response text or ADT paths into this normal tool-result evidence.
372
+ if (err instanceof AdtApiError)
373
+ return `SAP CTS request failed with HTTP ${err.statusCode}.`;
374
+ if (err instanceof AdtNetworkError)
375
+ return 'SAP CTS network request failed.';
376
+ const message = err instanceof Error ? err.message : String(err);
377
+ return message.length <= 300 ? message : `${message.slice(0, 300)}…`;
378
+ }
379
+ /** Fail closed when the transport allowlist cannot authorize a concurrently changing CTS subtree. */
380
+ export function checkRecursiveTransportReleaseScope(safety) {
381
+ const explicitlyUnrestricted = safety.allowedTransports.length === 0 || safety.allowedTransports.some((entry) => entry.trim() === '*');
382
+ if (explicitlyUnrestricted)
383
+ return;
384
+ throw new AdtSafetyError('Recursive transport release is blocked by a restrictive allowedTransports policy. ' +
385
+ 'SAP can fold a child task attached concurrently into the parent release, so exact/prefix allowlists ' +
386
+ 'cannot authorize the complete live subtree atomically. Use an empty legacy allowlist or explicit "*" ' +
387
+ 'only when every current and concurrently attached child of the request is authorized.');
388
+ }
389
+ const isTerminalReleaseReadError = (err) => err instanceof AdtApiError && [400, 401, 403, 404].includes(err.statusCode);
390
+ function numericOption(value, fallback) {
391
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : fallback;
392
+ }
393
+ async function waitForReleasePoll(milliseconds, options) {
394
+ if (milliseconds <= 0 || options.signal?.aborted)
395
+ return;
396
+ if (options.sleep) {
397
+ await options.sleep(milliseconds);
398
+ return;
399
+ }
400
+ await new Promise((resolve) => {
401
+ const timer = setTimeout(done, milliseconds);
402
+ const signal = options.signal;
403
+ signal?.addEventListener('abort', done, { once: true });
404
+ function done() {
405
+ clearTimeout(timer);
406
+ signal?.removeEventListener('abort', done);
407
+ resolve();
309
408
  }
409
+ });
410
+ }
411
+ function buildReleaseResult(requestedId, recursive, outcome, intended, submissions, polls, elapsedMs, lastReadError, unexpectedChildren = []) {
412
+ const released = (recursive
413
+ ? [...intended.filter((node) => node.kind === 'task'), ...intended.filter((node) => node.kind === 'request')]
414
+ : intended)
415
+ .filter((node) => node.confirmedReleased)
416
+ .map((node) => node.id);
417
+ const reports = submissions.find((submission) => transportIdsEqual(submission.id, requestedId))?.reports ?? [];
418
+ const reportConflicts = submissions
419
+ .filter((submission) => failedReleaseReports(submission.reports).length > 0 &&
420
+ intended.some((node) => transportIdsEqual(node.id, submission.id) && node.confirmedReleased))
421
+ .map((submission) => submission.id);
422
+ return {
423
+ requestedId,
424
+ recursive,
425
+ outcome,
426
+ verified: outcome === 'released',
427
+ released,
428
+ intended: intended.map((node) => ({ ...node })),
429
+ submissions: submissions.map((submission) => ({ ...submission, reports: [...submission.reports] })),
430
+ reports,
431
+ polls,
432
+ elapsedMs,
433
+ ...(lastReadError ? { lastReadError } : {}),
434
+ ...(reportConflicts.length > 0 ? { reportConflicts } : {}),
435
+ ...(unexpectedChildren.length > 0 ? { unexpectedChildren: [...unexpectedChildren] } : {}),
436
+ };
437
+ }
438
+ async function prepareTransportRelease(http, safety, transportId, recursive, operation, requestOptions, stoppedBeforeSubmission) {
439
+ const terminal = (intended, detail, outcome = 'unknown') => ({ intended, terminal: { outcome, detail } });
440
+ let discovered;
441
+ try {
442
+ discovered = await readTransportNodeStates(http, safety, transportId, requestOptions);
443
+ }
444
+ catch (err) {
445
+ return terminal([], releaseErrorText(err));
446
+ }
447
+ const requested = discovered.find((node) => transportIdsEqual(node.id, transportId));
448
+ if (!requested) {
449
+ return terminal([], `Transport '${transportId}' was not present in the CTS state response.`);
450
+ }
451
+ if (recursive && requested.kind !== 'request') {
452
+ return terminal([requested], `Recursive release requires a parent request, but '${transportId}' is a task.`);
310
453
  }
311
- const reports = await releaseTransport(http, safety, transportId);
312
- if (failedReleaseReports(reports).length === 0) {
313
- released.push(transportId);
454
+ const intended = [
455
+ requested,
456
+ ...(recursive
457
+ ? discovered.filter((node) => node.kind === 'task' && transportIdsEqual(node.parentId, transportId))
458
+ : []),
459
+ ].map((node) => ({ ...node }));
460
+ for (const node of intended)
461
+ checkTransport(safety, node.id, operation, true);
462
+ const unknown = intended.filter((node) => !isKnownReleaseStatus(node.lastStatus));
463
+ if (unknown.length > 0) {
464
+ const detail = `CTS returned an unknown initial status for: ${unknown
465
+ .map((node) => `${node.id}=${node.lastStatus || '(missing)'}`)
466
+ .join(', ')}.`;
467
+ return terminal(intended, detail);
314
468
  }
315
- else {
316
- // SAP can report abortrelapifail after it has already committed the recursive parent release.
317
- // Reconcile that contradictory HTTP-200 body against the actual request state. If the read fails
318
- // or the request is still modifiable, preserve the failed report as the authoritative outcome.
469
+ if (intended.every((node) => node.confirmedReleased))
470
+ return terminal(intended, undefined, 'released');
471
+ const stopped = stoppedBeforeSubmission();
472
+ if (!stopped)
473
+ return { intended };
474
+ const detail = stopped === 'aborted'
475
+ ? 'Transport release verification was aborted before submission.'
476
+ : 'Transport release deadline expired before submission.';
477
+ return terminal(intended, detail);
478
+ }
479
+ async function releaseTransportWithConvergence(http, safety, transportId, recursive, options) {
480
+ const operation = recursive ? 'ReleaseTransportRecursive' : 'ReleaseTransport';
481
+ checkTransport(safety, transportId, operation, true);
482
+ if (recursive)
483
+ checkRecursiveTransportReleaseScope(safety);
484
+ const now = options.now ?? Date.now;
485
+ const startedAt = now();
486
+ const relativeDeadline = startedAt + numericOption(options.timeoutMs, DEFAULT_RELEASE_TIMEOUT_MS);
487
+ const suppliedDeadline = typeof options.deadline === 'number' && Number.isFinite(options.deadline)
488
+ ? options.deadline
489
+ : Number.POSITIVE_INFINITY;
490
+ const deadline = Math.min(relativeDeadline, suppliedDeadline);
491
+ const elapsed = () => Math.max(0, now() - startedAt);
492
+ const requestOptions = { deadline, signal: options.signal };
493
+ const initialResult = (outcome, intended = [], detail) => buildReleaseResult(transportId, recursive, outcome, intended, [], 0, elapsed(), detail);
494
+ const preparation = await prepareTransportRelease(http, safety, transportId, recursive, operation, requestOptions, () => (options.signal?.aborted ? 'aborted' : now() >= deadline ? 'deadline' : undefined));
495
+ const { intended } = preparation;
496
+ if (preparation.terminal) {
497
+ return initialResult(preparation.terminal.outcome, intended, preparation.terminal.detail);
498
+ }
499
+ const submissions = [];
500
+ const requestedState = intended[0];
501
+ const taskSubmissionOrder = recursive ? intended.filter((node) => node.kind === 'task') : [];
502
+ const frozenTaskKeys = new Set(taskSubmissionOrder.map((node) => node.id.toUpperCase()));
503
+ let polls = 0;
504
+ let lastReadError;
505
+ let missingIds = [];
506
+ let unexpectedChildren = [];
507
+ const maxDelayMs = numericOption(options.maxDelayMs, DEFAULT_RELEASE_MAX_DELAY_MS);
508
+ let delayMs = Math.min(numericOption(options.initialDelayMs, DEFAULT_RELEASE_INITIAL_DELAY_MS), maxDelayMs);
509
+ const lookupId = recursive ? requestedState.id : transportId;
510
+ const allReleased = () => intended.every((node) => node.confirmedReleased);
511
+ const hasInFlightNode = () => intended.some((node) => IN_FLIGHT_RELEASE_STATUSES.has(node.lastStatus) && !node.confirmedReleased);
512
+ const submissionFailed = () => submissions.some((submission) => submission.error !== undefined);
513
+ const finalOutcomeIsDecided = () => !hasInFlightNode() &&
514
+ submissions.some((submission) => submission.error !== undefined ||
515
+ (transportIdsEqual(submission.id, requestedState.id) && failedReleaseReports(submission.reports).length > 0));
516
+ const submissionWasAccepted = (id) => {
517
+ const submission = submissions.find((entry) => transportIdsEqual(entry.id, id));
518
+ return (submission !== undefined &&
519
+ submission.error === undefined &&
520
+ submission.reports.length > 0 &&
521
+ failedReleaseReports(submission.reports).length === 0);
522
+ };
523
+ const result = (outcome, detail) => buildReleaseResult(transportId, recursive, outcome, intended, submissions, polls, elapsed(), detail, unexpectedChildren);
524
+ const submissionFailureDetail = () => {
525
+ const failed = submissions.filter((submission) => submission.error);
526
+ return failed.length === 0
527
+ ? undefined
528
+ : `Release submission returned an error for ${failed
529
+ .map((submission) => `${submission.id}: ${submission.error}`)
530
+ .join('; ')}. The submission outcome is uncertain; use the returned CTS state before retrying.`;
531
+ };
532
+ const finishWithoutTerminalRelease = () => {
533
+ const unknownStatuses = intended.filter((node) => !isKnownReleaseStatus(node.lastStatus));
534
+ const submissionFailure = submissionFailureDetail();
535
+ const stateDetail = lastReadError ??
536
+ (missingIds.length > 0
537
+ ? `CTS state omitted intended id(s): ${missingIds.join(', ')}.`
538
+ : unknownStatuses.length > 0
539
+ ? `CTS returned unknown status(es): ${unknownStatuses
540
+ .map((node) => `${node.id}=${node.lastStatus || '(missing)'}`)
541
+ .join(', ')}.`
542
+ : undefined);
543
+ const detail = [submissionFailure, stateDetail].filter(Boolean).join(' ') || undefined;
544
+ const unknown = detail || unexpectedChildren.length > 0;
545
+ const blocked = submissions.some((submission) => failedReleaseReports(submission.reports).length > 0);
546
+ return result(unknown ? 'unknown' : blocked ? 'blocked' : 'timeout', detail);
547
+ };
548
+ const submit = async (node) => {
549
+ try {
550
+ const reports = await releaseTransport(http, safety, node.id, requestOptions);
551
+ submissions.push({ id: node.id, reports });
552
+ }
553
+ catch (err) {
554
+ submissions.push({ id: node.id, reports: [], error: releaseErrorText(err) });
555
+ }
556
+ };
557
+ const refresh = async () => {
319
558
  try {
320
- const refreshed = await getTransport(http, safety, transportId);
321
- if (refreshed?.status === 'R')
322
- released.push(transportId);
559
+ const latest = await readTransportNodeStates(http, safety, lookupId, requestOptions);
560
+ polls += 1;
561
+ lastReadError = undefined;
562
+ const latestById = new Map(latest.map((node) => [node.id.toUpperCase(), node]));
563
+ unexpectedChildren = recursive
564
+ ? latest
565
+ .filter((node) => node.kind === 'task' &&
566
+ transportIdsEqual(node.parentId, requestedState.id) &&
567
+ !frozenTaskKeys.has(node.id.toUpperCase()))
568
+ .map((node) => node.id)
569
+ .sort()
570
+ : [];
571
+ missingIds = [];
572
+ const observedParent = latestById.get(requestedState.id.toUpperCase());
573
+ const parentIsTerminal = recursive && observedParent !== undefined && TERMINAL_RELEASE_STATUSES.has(observedParent.lastStatus);
574
+ for (const node of intended) {
575
+ const observed = latestById.get(node.id.toUpperCase());
576
+ if (observed) {
577
+ node.lastStatus = observed.lastStatus;
578
+ node.confirmedReleased = TERMINAL_RELEASE_STATUSES.has(node.lastStatus);
579
+ node.confirmation = node.confirmedReleased ? 'observed_terminal' : undefined;
580
+ continue;
581
+ }
582
+ const alreadyConfirmed = node.confirmedReleased;
583
+ const existingConfirmation = node.confirmation;
584
+ const disappearedReleasedTask = node.kind === 'task' && (parentIsTerminal || alreadyConfirmed || submissionWasAccepted(node.id));
585
+ if (disappearedReleasedTask) {
586
+ node.confirmedReleased = true;
587
+ node.confirmation = parentIsTerminal
588
+ ? 'parent_terminal'
589
+ : alreadyConfirmed
590
+ ? existingConfirmation
591
+ : 'accepted_submission_absence';
592
+ }
593
+ else {
594
+ node.lastStatus = '';
595
+ node.confirmedReleased = false;
596
+ node.confirmation = undefined;
597
+ missingIds.push(node.id);
598
+ }
599
+ }
600
+ }
601
+ catch (err) {
602
+ polls += 1;
603
+ if (err instanceof AdtApiError &&
604
+ err.statusCode === 404 &&
605
+ requestedState.kind === 'task' &&
606
+ submissionWasAccepted(requestedState.id)) {
607
+ requestedState.confirmedReleased = true;
608
+ requestedState.confirmation = 'accepted_submission_absence';
609
+ lastReadError = undefined;
610
+ missingIds = [];
611
+ return result('released');
612
+ }
613
+ lastReadError = releaseErrorText(err);
614
+ if (isTerminalReleaseReadError(err)) {
615
+ return result('unknown', [submissionFailureDetail(), lastReadError].filter(Boolean).join(' '));
616
+ }
617
+ return undefined;
323
618
  }
324
- catch {
325
- // The caller will surface the original release report, which is more actionable than a
326
- // best-effort reconciliation-read failure.
619
+ if (unexpectedChildren.length > 0) {
620
+ const parentWasSubmitted = submissions.some((submission) => transportIdsEqual(submission.id, requestedState.id));
621
+ const detail = parentWasSubmitted
622
+ ? `CTS added non-frozen child task(s) during recursive release: ${unexpectedChildren.join(', ')}. ` +
623
+ 'Exact-set terminal verification was refused.'
624
+ : `CTS added non-frozen child task(s) before parent release: ${unexpectedChildren.join(', ')}. ` +
625
+ 'The parent release was not submitted.';
626
+ return result('unknown', detail);
327
627
  }
628
+ return allReleased() ? result('released') : undefined;
629
+ };
630
+ const stopped = () => {
631
+ if (options.signal?.aborted) {
632
+ const detail = [submissionFailureDetail(), 'Transport release verification was aborted.']
633
+ .filter(Boolean)
634
+ .join(' ');
635
+ return result('unknown', detail);
636
+ }
637
+ return now() >= deadline ? finishWithoutTerminalRelease() : undefined;
638
+ };
639
+ const pollUntil = async (ready, immediate) => {
640
+ while (true) {
641
+ if (!immediate) {
642
+ const beforeSleep = stopped();
643
+ if (beforeSleep)
644
+ return beforeSleep;
645
+ await waitForReleasePoll(Math.min(delayMs, Math.max(0, deadline - now())), options);
646
+ delayMs = Math.min(maxDelayMs, Math.max(delayMs === 0 ? 1 : delayMs, delayMs * 2));
647
+ const afterSleep = stopped();
648
+ if (afterSleep)
649
+ return afterSleep;
650
+ }
651
+ immediate = false;
652
+ const terminal = await refresh();
653
+ if (terminal)
654
+ return terminal;
655
+ // A transient read leaves lastReadError set. Never authorize a phase transition from stale state.
656
+ if (lastReadError === undefined && ready())
657
+ return stopped();
658
+ }
659
+ };
660
+ // Phase 1: poll O, then submit each frozen D task at most once.
661
+ if (recursive) {
662
+ if (hasInFlightNode()) {
663
+ const terminal = await pollUntil(() => !hasInFlightNode(), true);
664
+ if (terminal)
665
+ return terminal;
666
+ }
667
+ for (const node of taskSubmissionOrder) {
668
+ if (node.confirmedReleased)
669
+ continue;
670
+ if (!MODIFIABLE_RELEASE_STATUSES.has(node.lastStatus))
671
+ break;
672
+ await submit(node);
673
+ if (submissionFailed())
674
+ break;
675
+ }
676
+ // Phase 2: only a coherent fresh tree snapshot may authorize the parent POST.
677
+ const preParent = await pollUntil(() => submissionFailed() ||
678
+ (missingIds.length === 0 &&
679
+ (MODIFIABLE_RELEASE_STATUSES.has(requestedState.lastStatus) || requestedState.confirmedReleased) &&
680
+ taskSubmissionOrder.every((node) => MODIFIABLE_RELEASE_STATUSES.has(node.lastStatus) || node.confirmedReleased)), true);
681
+ if (preParent)
682
+ return preParent;
683
+ if (!submissionFailed() && MODIFIABLE_RELEASE_STATUSES.has(requestedState.lastStatus)) {
684
+ await submit(requestedState);
685
+ const terminal = await pollUntil(finalOutcomeIsDecided, true);
686
+ return terminal ?? finishWithoutTerminalRelease();
687
+ }
688
+ const terminal = await pollUntil(finalOutcomeIsDecided, false);
689
+ return terminal ?? finishWithoutTerminalRelease();
690
+ }
691
+ if (IN_FLIGHT_RELEASE_STATUSES.has(requestedState.lastStatus)) {
692
+ const terminal = await pollUntil(() => MODIFIABLE_RELEASE_STATUSES.has(requestedState.lastStatus), true);
693
+ if (terminal)
694
+ return terminal;
328
695
  }
329
- return { released, reports };
696
+ if (MODIFIABLE_RELEASE_STATUSES.has(requestedState.lastStatus))
697
+ await submit(requestedState);
698
+ const terminal = await pollUntil(finalOutcomeIsDecided, true);
699
+ return terminal ?? finishWithoutTerminalRelease();
700
+ }
701
+ /** Release one request/task and return after terminal CTS state or accepted released-task disappearance. */
702
+ export async function releaseTransportAndWait(http, safety, transportId, options = {}) {
703
+ return releaseTransportWithConvergence(http, safety, transportId, false, options);
704
+ }
705
+ /**
706
+ * Release a parent request recursively — tasks first, then the parent — and wait until the frozen
707
+ * parent/task set has terminal CTS evidence. Raw failed reports are preserved but final CTS state is authoritative.
708
+ * SAP has no atomic compare-tree-and-release operation: exact/prefix transport allowlists are therefore
709
+ * refused, and any non-frozen child observed by the mandatory pre-parent snapshot or readback makes the
710
+ * result unverified. The fresh snapshot narrows, but cannot eliminate, the backend's GET-to-POST race.
711
+ */
712
+ export async function releaseTransportRecursive(http, safety, transportId, options = {}) {
713
+ return releaseTransportWithConvergence(http, safety, transportId, true, options);
330
714
  }
331
715
  /**
332
716
  * Remove a single object from a transport task.
@@ -696,6 +1080,24 @@ function parseTransportList(xml) {
696
1080
  objects,
697
1081
  };
698
1082
  });
1083
+ // Request-level <tm:abap_object> children, read STRUCTURALLY.
1084
+ //
1085
+ // findDeepNodes must not be used here: it only stops early when the key is a DIRECT
1086
+ // property, and on the common shape (objects only under tasks) it recurses into <tm:task>
1087
+ // and returns the first task's objects — which would duplicate them onto the request and
1088
+ // stamp them with the request id instead of their task's.
1089
+ const directObjects = req.abap_object;
1090
+ const requestObjects = (Array.isArray(directObjects) ? directObjects : directObjects ? [directObjects] : [])
1091
+ .filter((o) => !!o && typeof o === 'object')
1092
+ .map((o) => ({
1093
+ pgmid: String(o['@_pgmid'] ?? ''),
1094
+ type: String(o['@_type'] ?? ''),
1095
+ name: String(o['@_name'] ?? ''),
1096
+ wbtype: String(o['@_wbtype'] ?? ''),
1097
+ description: String(o['@_obj_desc'] ?? o['@_obj_info'] ?? ''),
1098
+ locked: String(o['@_lock_status'] ?? '') === 'X',
1099
+ position: String(o['@_position'] ?? '000000'),
1100
+ }));
699
1101
  return {
700
1102
  id: String(req['@_number'] ?? ''),
701
1103
  description: String(req['@_desc'] ?? ''),
@@ -705,6 +1107,7 @@ function parseTransportList(xml) {
705
1107
  target: String(req['@_target'] ?? ''),
706
1108
  targetDesc: String(req['@_target_desc'] ?? ''),
707
1109
  tasks,
1110
+ ...(requestObjects.length ? { requestObjects } : {}),
708
1111
  };
709
1112
  });
710
1113
  }