arc-1 1.0.2 → 1.1.0

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