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.
- package/bin/arc1-cli.js +3 -7
- package/bin/arc1.js +4 -9
- package/dist/adt/abapgit.d.ts +10 -5
- package/dist/adt/abapgit.d.ts.map +1 -1
- package/dist/adt/abapgit.js +357 -76
- package/dist/adt/abapgit.js.map +1 -1
- package/dist/adt/atc.d.ts +49 -0
- package/dist/adt/atc.d.ts.map +1 -0
- package/dist/adt/atc.js +294 -0
- package/dist/adt/atc.js.map +1 -0
- package/dist/adt/aunit-package.d.ts +22 -0
- package/dist/adt/aunit-package.d.ts.map +1 -0
- package/dist/adt/aunit-package.js +78 -0
- package/dist/adt/aunit-package.js.map +1 -0
- package/dist/adt/aunit.d.ts +143 -0
- package/dist/adt/aunit.d.ts.map +1 -0
- package/dist/adt/aunit.js +987 -0
- package/dist/adt/aunit.js.map +1 -0
- package/dist/adt/client.d.ts +9 -7
- package/dist/adt/client.d.ts.map +1 -1
- package/dist/adt/client.js +55 -64
- package/dist/adt/client.js.map +1 -1
- package/dist/adt/config.d.ts +2 -0
- package/dist/adt/config.d.ts.map +1 -1
- package/dist/adt/config.js +1 -0
- package/dist/adt/config.js.map +1 -1
- package/dist/adt/devtools.d.ts +8 -30
- package/dist/adt/devtools.d.ts.map +1 -1
- package/dist/adt/devtools.js +41 -218
- package/dist/adt/devtools.js.map +1 -1
- package/dist/adt/diagnostics.d.ts +1 -1
- package/dist/adt/diagnostics.d.ts.map +1 -1
- package/dist/adt/diagnostics.js +12 -12
- package/dist/adt/diagnostics.js.map +1 -1
- package/dist/adt/errors.d.ts +7 -0
- package/dist/adt/errors.d.ts.map +1 -1
- package/dist/adt/errors.js +7 -0
- package/dist/adt/errors.js.map +1 -1
- package/dist/adt/features.d.ts +9 -3
- package/dist/adt/features.d.ts.map +1 -1
- package/dist/adt/features.js +40 -8
- package/dist/adt/features.js.map +1 -1
- package/dist/adt/gcts.d.ts +12 -45
- package/dist/adt/gcts.d.ts.map +1 -1
- package/dist/adt/gcts.js +299 -171
- package/dist/adt/gcts.js.map +1 -1
- package/dist/adt/http-deadline.d.ts +23 -0
- package/dist/adt/http-deadline.d.ts.map +1 -0
- package/dist/adt/http-deadline.js +106 -0
- package/dist/adt/http-deadline.js.map +1 -0
- package/dist/adt/http-wire-body.d.ts +2 -0
- package/dist/adt/http-wire-body.d.ts.map +1 -0
- package/dist/adt/http-wire-body.js +14 -0
- package/dist/adt/http-wire-body.js.map +1 -0
- package/dist/adt/http.d.ts +10 -24
- package/dist/adt/http.d.ts.map +1 -1
- package/dist/adt/http.js +89 -53
- package/dist/adt/http.js.map +1 -1
- package/dist/adt/path-safety.d.ts +16 -0
- package/dist/adt/path-safety.d.ts.map +1 -0
- package/dist/adt/path-safety.js +101 -0
- package/dist/adt/path-safety.js.map +1 -0
- package/dist/adt/semaphore.d.ts +2 -2
- package/dist/adt/semaphore.d.ts.map +1 -1
- package/dist/adt/semaphore.js +26 -9
- package/dist/adt/semaphore.js.map +1 -1
- package/dist/adt/source-diff.d.ts +10 -1
- package/dist/adt/source-diff.d.ts.map +1 -1
- package/dist/adt/source-diff.js +33 -7
- package/dist/adt/source-diff.js.map +1 -1
- package/dist/adt/text-search.d.ts +25 -0
- package/dist/adt/text-search.d.ts.map +1 -0
- package/dist/adt/text-search.js +152 -0
- package/dist/adt/text-search.js.map +1 -0
- package/dist/adt/transport-diff.d.ts +128 -0
- package/dist/adt/transport-diff.d.ts.map +1 -0
- package/dist/adt/transport-diff.js +524 -0
- package/dist/adt/transport-diff.js.map +1 -0
- package/dist/adt/transport.d.ts +78 -13
- package/dist/adt/transport.d.ts.map +1 -1
- package/dist/adt/transport.js +443 -41
- package/dist/adt/transport.js.map +1 -1
- package/dist/adt/types.d.ts +23 -12
- package/dist/adt/types.d.ts.map +1 -1
- package/dist/adt/xml-parser.d.ts +1 -8
- package/dist/adt/xml-parser.d.ts.map +1 -1
- package/dist/adt/xml-parser.js +54 -60
- package/dist/adt/xml-parser.js.map +1 -1
- package/dist/authz/policy.d.ts.map +1 -1
- package/dist/authz/policy.js +5 -2
- package/dist/authz/policy.js.map +1 -1
- package/dist/cli-args.d.ts +2 -2
- package/dist/cli-args.js +2 -2
- package/dist/cli-checks.d.ts +56 -0
- package/dist/cli-checks.d.ts.map +1 -0
- package/dist/cli-checks.js +368 -0
- package/dist/cli-checks.js.map +1 -0
- package/dist/cli.d.ts +68 -15
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +677 -297
- package/dist/cli.js.map +1 -1
- package/dist/handlers/cds-hints.d.ts.map +1 -1
- package/dist/handlers/cds-hints.js +1 -10
- package/dist/handlers/cds-hints.js.map +1 -1
- package/dist/handlers/diagnose.d.ts +4 -0
- package/dist/handlers/diagnose.d.ts.map +1 -1
- package/dist/handlers/diagnose.js +616 -9
- package/dist/handlers/diagnose.js.map +1 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +40 -1
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/git.d.ts.map +1 -1
- package/dist/handlers/git.js +165 -84
- package/dist/handlers/git.js.map +1 -1
- package/dist/handlers/read.d.ts.map +1 -1
- package/dist/handlers/read.js +15 -0
- package/dist/handlers/read.js.map +1 -1
- package/dist/handlers/schemas.d.ts +15 -2
- package/dist/handlers/schemas.d.ts.map +1 -1
- package/dist/handlers/schemas.js +75 -6
- package/dist/handlers/schemas.js.map +1 -1
- package/dist/handlers/search.js +1 -1
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/tools.d.ts.map +1 -1
- package/dist/handlers/tools.js +51 -42
- package/dist/handlers/tools.js.map +1 -1
- package/dist/handlers/transport.d.ts +2 -1
- package/dist/handlers/transport.d.ts.map +1 -1
- package/dist/handlers/transport.js +128 -27
- package/dist/handlers/transport.js.map +1 -1
- package/dist/handlers/write/create.d.ts.map +1 -1
- package/dist/handlers/write/create.js +11 -0
- package/dist/handlers/write/create.js.map +1 -1
- package/dist/handlers/write/update-delete.d.ts.map +1 -1
- package/dist/handlers/write/update-delete.js +33 -10
- package/dist/handlers/write/update-delete.js.map +1 -1
- package/dist/handlers/write-helpers.d.ts.map +1 -1
- package/dist/handlers/write-helpers.js +3 -2
- package/dist/handlers/write-helpers.js.map +1 -1
- package/dist/server/audit.d.ts +1 -1
- package/dist/server/audit.d.ts.map +1 -1
- package/dist/server/audit.js +228 -21
- package/dist/server/audit.js.map +1 -1
- package/dist/server/config.d.ts +16 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +145 -17
- package/dist/server/config.js.map +1 -1
- package/dist/server/effective-policy-log.d.ts +2 -1
- package/dist/server/effective-policy-log.d.ts.map +1 -1
- package/dist/server/effective-policy-log.js +8 -2
- package/dist/server/effective-policy-log.js.map +1 -1
- package/dist/server/multi-target-runtime.d.ts.map +1 -1
- package/dist/server/multi-target-runtime.js +3 -0
- package/dist/server/multi-target-runtime.js.map +1 -1
- package/dist/server/multi-target-tools.d.ts.map +1 -1
- package/dist/server/multi-target-tools.js +5 -0
- package/dist/server/multi-target-tools.js.map +1 -1
- package/dist/server/server.d.ts +15 -13
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +36 -26
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +2 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +1 -0
- package/dist/server/types.js.map +1 -1
- package/package.json +13 -13
package/dist/adt/transport.js
CHANGED
|
@@ -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
|
-
*
|
|
315
|
+
* Flatten a transport-organizer document into request/task state rows.
|
|
284
316
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
}
|