abapgit-agent 1.10.0 → 1.11.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.
@@ -25,6 +25,37 @@ const { AdtHttp } = require('./adt-http');
25
25
  // Header required to pin all requests to the same ABAP work process.
26
26
  const STATEFUL_HEADER = { 'X-sap-adt-sessiontype': 'stateful' };
27
27
 
28
+ /**
29
+ * Retry a debug ADT call up to maxRetries times on transient ICM errors.
30
+ *
31
+ * The SAP ICM (load balancer) returns HTTP 400 with an HTML "Service cannot
32
+ * be reached" body when the target ABAP work process is momentarily unavailable
33
+ * (e.g. finishing a previous step, or briefly between requests). This is a
34
+ * transient condition that resolves within a second or two — retrying is safe
35
+ * for all debug read/navigation operations.
36
+ *
37
+ * @param {function} fn - Async function to retry (takes no args)
38
+ * @param {number} maxRetries - Max additional attempts after the first (default 3)
39
+ * @param {number} delayMs - Wait between retries in ms (default 1000)
40
+ */
41
+ async function retryOnIcmError(fn, maxRetries = 12, delayMs = 2000) {
42
+ let lastErr;
43
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
44
+ try {
45
+ return await fn();
46
+ } catch (err) {
47
+ const isIcmError = err && err.statusCode === 400 &&
48
+ err.body && err.body.includes('Service cannot be reached');
49
+ if (!isIcmError) throw err;
50
+ lastErr = err;
51
+ if (attempt < maxRetries) {
52
+ await new Promise(r => setTimeout(r, delayMs));
53
+ }
54
+ }
55
+ }
56
+ throw lastErr;
57
+ }
58
+
28
59
  class DebugSession {
29
60
  /**
30
61
  * @param {AdtHttp} adtHttp - ADT HTTP client instance (carries session cookie)
@@ -33,6 +64,56 @@ class DebugSession {
33
64
  constructor(adtHttp, sessionId) {
34
65
  this.http = adtHttp;
35
66
  this.sessionId = sessionId;
67
+ this.pinnedSessionId = null;
68
+ this._keepaliveTimer = null;
69
+ }
70
+
71
+ /**
72
+ * Start a periodic keepalive that pings ADT every 30 seconds.
73
+ * SAP's ICM drops stateful session affinity after ~60 s of idle, causing
74
+ * subsequent debug requests to route to the wrong work process (HTTP 400).
75
+ * Calling getStack() regularly keeps the connection warm.
76
+ * Call stopKeepalive() before detach/terminate to avoid racing the close.
77
+ */
78
+ startKeepalive() {
79
+ if (this._keepaliveTimer) return;
80
+ this._keepaliveTimer = setInterval(async () => {
81
+ try { await this.getStack(); } catch (e) { /* best-effort */ }
82
+ }, 30000);
83
+ if (this._keepaliveTimer.unref) this._keepaliveTimer.unref();
84
+ }
85
+
86
+ stopKeepalive() {
87
+ if (this._keepaliveTimer) {
88
+ clearInterval(this._keepaliveTimer);
89
+ this._keepaliveTimer = null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Restore the pinned SAP_SESSIONID into the HTTP client's cookie jar.
95
+ *
96
+ * SAP ADT debug sessions are bound to a specific frozen ABAP work process
97
+ * via the SAP_SESSIONID cookie. AdtHttp updates this cookie automatically
98
+ * from every response's Set-Cookie header and replaces it on CSRF refresh
99
+ * (401/403 retry). When the cookie rotates mid-session the next request
100
+ * routes to a different ABAP session that has no debug state, causing
101
+ * HTTP 400 "Service cannot be reached".
102
+ *
103
+ * This method reverts any rotation that occurred since attach() by replacing
104
+ * the current SAP_SESSIONID value with the one captured at attach time.
105
+ * It is a no-op when called before attach() (pinnedSessionId is null).
106
+ */
107
+ _restorePinnedSession() {
108
+ if (!this.pinnedSessionId || !this.http.cookies) return;
109
+
110
+ // Replace whatever SAP_SESSIONID= value is currently in the cookie jar
111
+ // with the pinned one. The cookie jar is a semicolon-separated string,
112
+ // e.g. "SAP_SESSIONID=ABC123; sap-usercontext=xyz".
113
+ this.http.cookies = this.http.cookies.replace(
114
+ /SAP_SESSIONID=[^;]*/,
115
+ `SAP_SESSIONID=${this.pinnedSessionId}`
116
+ );
36
117
  }
37
118
 
38
119
  /**
@@ -64,6 +145,14 @@ class DebugSession {
64
145
  this.sessionId = debugSessionId;
65
146
  }
66
147
 
148
+ // Pin the SAP_SESSIONID cookie that was active when we attached.
149
+ // All subsequent stateful operations must present this exact cookie so
150
+ // that SAP routes them to the same frozen ABAP work process.
151
+ if (this.http.cookies) {
152
+ const match = this.http.cookies.match(/SAP_SESSIONID=([^;]*)/);
153
+ if (match) this.pinnedSessionId = match[1];
154
+ }
155
+
67
156
  return this.sessionId;
68
157
  }
69
158
 
@@ -94,30 +183,35 @@ class DebugSession {
94
183
  // completion. When the program runs to completion ADT returns HTTP 500
95
184
  // (no suspended session left). Treat both 200 and 500 as "continued".
96
185
  if (method === 'stepContinue') {
97
- try {
98
- await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
99
- contentType: 'application/vnd.sap.as+xml',
100
- headers: STATEFUL_HEADER
101
- });
102
- // 200: program hit another breakpoint (or is still running).
103
- // Position query is not meaningful until a new breakpoint fires via
104
- // the listener, so return the sentinel and let the caller re-attach.
105
- return { position: { continued: true }, source: [] };
106
- } catch (err) {
107
- // 500: debuggee ran to completion, session ended normally.
108
- if (err && err.statusCode === 500) {
109
- return { position: { continued: true, finished: true }, source: [] };
186
+ return retryOnIcmError(async () => {
187
+ this._restorePinnedSession();
188
+ try {
189
+ await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
190
+ contentType: 'application/vnd.sap.as+xml',
191
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
192
+ });
193
+ // 200: program hit another breakpoint (or is still running).
194
+ // Position query is not meaningful until a new breakpoint fires via
195
+ // the listener, so return the sentinel and let the caller re-attach.
196
+ return { position: { continued: true }, source: [] };
197
+ } catch (err) {
198
+ // 500: debuggee ran to completion, session ended normally.
199
+ if (err && err.statusCode === 500) {
200
+ return { position: { continued: true, finished: true }, source: [] };
201
+ }
202
+ throw err;
110
203
  }
111
- throw err;
112
- }
204
+ });
113
205
  }
114
206
 
115
- await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
116
- contentType: 'application/vnd.sap.as+xml',
117
- headers: STATEFUL_HEADER
207
+ return retryOnIcmError(async () => {
208
+ this._restorePinnedSession();
209
+ await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
210
+ contentType: 'application/vnd.sap.as+xml',
211
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
212
+ });
213
+ return this.getPosition();
118
214
  });
119
-
120
- return this.getPosition();
121
215
  }
122
216
 
123
217
  /**
@@ -139,6 +233,8 @@ class DebugSession {
139
233
  * @returns {Promise<Array<{ name: string, type: string, value: string }>>}
140
234
  */
141
235
  async getVariables(name = null) {
236
+ this._restorePinnedSession();
237
+
142
238
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
143
239
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
144
240
 
@@ -220,6 +316,8 @@ class DebugSession {
220
316
  * @returns {Promise<Array<{ id: string, name: string, type: string, value: string }>>}
221
317
  */
222
318
  async getVariableChildren(parentId, meta = {}) {
319
+ this._restorePinnedSession();
320
+
223
321
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
224
322
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
225
323
 
@@ -337,13 +435,31 @@ class DebugSession {
337
435
  * @returns {Promise<Array<{ frame: number, class: string, method: string, line: number }>>}
338
436
  */
339
437
  async getStack() {
340
- const { body } = await this.http.post(
341
- '/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
342
- contentType: 'application/vnd.sap.as+xml',
343
- headers: STATEFUL_HEADER
438
+ return retryOnIcmError(async () => {
439
+ this._restorePinnedSession();
440
+ // Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
441
+ try {
442
+ const { body } = await this.http.get(
443
+ '/sap/bc/adt/debugger/stack?emode=_&semanticURIs=true', {
444
+ accept: 'application/xml',
445
+ headers: STATEFUL_HEADER
446
+ }
447
+ );
448
+ const frames = parseStack(body);
449
+ if (frames.length > 0) return frames;
450
+ } catch (e) {
451
+ // Fall through to POST approach for any GET failure (including 400 on systems
452
+ // that don't support the dedicated /debugger/stack endpoint)
344
453
  }
345
- );
346
- return parseStack(body);
454
+ // Fallback: POST approach (older ADT versions)
455
+ const { body } = await this.http.post(
456
+ '/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
457
+ contentType: 'application/vnd.sap.as+xml',
458
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
459
+ }
460
+ );
461
+ return parseStack(body);
462
+ });
347
463
  }
348
464
 
349
465
  /**
@@ -531,32 +647,38 @@ class DebugSession {
531
647
 
532
648
  /**
533
649
  * Terminate the debug session.
650
+ * Retries on transient ICM 400 errors so the ABAP work process is reliably
651
+ * released even when the system is under load (e.g. during test:all).
534
652
  */
535
653
  async terminate() {
536
- await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
537
- contentType: 'application/vnd.sap.as+xml',
538
- headers: STATEFUL_HEADER
654
+ await retryOnIcmError(async () => {
655
+ this._restorePinnedSession();
656
+ await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
657
+ contentType: 'application/vnd.sap.as+xml',
658
+ headers: STATEFUL_HEADER
659
+ });
539
660
  });
540
661
  }
541
662
 
542
663
  /**
543
664
  * Detach from the debuggee without killing it.
544
- * Issues a stepContinue so the ABAP program resumes running.
665
+ * Issues a single stepContinue so the ABAP program resumes running.
666
+ *
667
+ * ADT returns HTTP 200 when the WP resumes (regardless of whether it
668
+ * later hits another breakpoint — there is no way to distinguish "still
669
+ * running" from "re-hit breakpoint" in the stepContinue response alone).
670
+ * Sending a second stepContinue to an already-running WP races with the
671
+ * program's own execution and can stall the WP mid-run (e.g. while a
672
+ * Code Inspector job is in flight), so we issue exactly one request.
545
673
  *
546
- * stepContinue is a long-poll in ADT it only responds when the program
547
- * hits another breakpoint (200) or finishes (500), which may be never.
548
- * We use postFire() which resolves as soon as the request bytes are
549
- * flushed to the TCP send buffer — no need to wait for a response.
550
- * The existing session cookies are used so ADT recognises the request.
674
+ * Callers (e.g. the REPL 'q' handler) must delete all breakpoints before
675
+ * calling detach() to prevent an immediate re-hit on the same line.
551
676
  */
552
677
  async detach() {
553
678
  try {
554
- await this.http.postFire('/sap/bc/adt/debugger?method=stepContinue', '', {
555
- contentType: 'application/vnd.sap.as+xml',
556
- headers: STATEFUL_HEADER
557
- });
679
+ await this.step('stepContinue');
558
680
  } catch (e) {
559
- // Ignore — fire-and-forget; errors here mean the session already closed.
681
+ // Ignore — session may have already closed.
560
682
  }
561
683
  }
562
684
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Transport selector utility
3
+ * Resolves a transport request number for the pull command when none is configured.
4
+ *
5
+ * Strategy:
6
+ * - Non-interactive (AI mode, CI): run a project-configured Node.js hook
7
+ * - Interactive (TTY): show a numbered picker (list, scope-switch, create, skip)
8
+ */
9
+
10
+ const path = require('path');
11
+
12
+ /**
13
+ * Returns true when running non-interactively (no TTY, CI, AI coding tool).
14
+ * Can be forced with NO_TTY=1 env var.
15
+ */
16
+ function isNonInteractive() {
17
+ if (process.env.NO_TTY === '1') return true;
18
+ return !process.stdout.isTTY || !process.stdin.isTTY;
19
+ }
20
+
21
+ /**
22
+ * Load and execute a hook module.
23
+ * The hook must export an async function({ config, http }) → string|null.
24
+ *
25
+ * @param {string} hookPath - Absolute path to the hook module
26
+ * @param {object} context - { config, http }
27
+ * @returns {Promise<string|null>}
28
+ */
29
+ async function runHook(hookPath, context) {
30
+ const hookFn = require(hookPath);
31
+ const result = await hookFn(context);
32
+ return typeof result === 'string' ? result : null;
33
+ }
34
+
35
+ /**
36
+ * Fetch open transport requests from ABAP.
37
+ * Returns normalised lowercase-key objects.
38
+ *
39
+ * @param {object} http
40
+ * @param {string} scope - 'mine' | 'task' | 'all'
41
+ * @returns {Promise<Array>}
42
+ */
43
+ async function fetchTransports(http, scope = 'mine') {
44
+ try {
45
+ const result = await http.get(`/sap/bc/z_abapgit_agent/transport?scope=${scope}`);
46
+ const raw = result.TRANSPORTS || result.transports || [];
47
+ return raw.map(t => ({
48
+ number: t.NUMBER || t.number || '',
49
+ description: t.DESCRIPTION || t.description || '',
50
+ owner: t.OWNER || t.owner || '',
51
+ date: t.DATE || t.date || ''
52
+ }));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create a new transport request.
60
+ * @param {object} http
61
+ * @param {string} description
62
+ * @returns {Promise<string|null>} transport number or null
63
+ */
64
+ async function createTransport(http, description) {
65
+ try {
66
+ const result = await http.post(
67
+ '/sap/bc/z_abapgit_agent/transport',
68
+ { action: 'CREATE', description: description || '' },
69
+ {}
70
+ );
71
+ return result.NUMBER || result.number || null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Interactive picker — shown in TTY mode.
79
+ * Presents a numbered menu; supports scope switching, create, and skip.
80
+ *
81
+ * @param {object} http
82
+ * @returns {Promise<string|null>}
83
+ */
84
+ async function interactivePicker(http) {
85
+ const readline = require('readline');
86
+
87
+ let scope = 'mine';
88
+ let transports = [];
89
+ let fetchError = false;
90
+
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
92
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
93
+
94
+ const fetchAndDisplay = async () => {
95
+ transports = await fetchTransports(http, scope);
96
+ fetchError = transports.length === 0;
97
+
98
+ const scopeLabel = { mine: 'my transports', task: 'transports where I have a task', all: 'all open transports' }[scope];
99
+ process.stderr.write(`\nSelect a transport request (showing: ${scopeLabel}):\n\n`);
100
+
101
+ if (transports.length > 0) {
102
+ transports.forEach((t, i) => {
103
+ process.stderr.write(` ${i + 1}. ${t.number} ${t.description} (${t.owner}, ${t.date})\n`);
104
+ });
105
+ process.stderr.write(' ' + '─'.repeat(61) + '\n');
106
+ } else {
107
+ if (fetchError) {
108
+ process.stderr.write(' ⚠️ Could not fetch transports from ABAP system.\n');
109
+ } else {
110
+ process.stderr.write(' No open transport requests found.\n');
111
+ }
112
+ process.stderr.write(' ' + '─'.repeat(61) + '\n');
113
+ }
114
+
115
+ if (scope !== 'task') process.stderr.write(' s. Show transports where I have a task\n');
116
+ if (scope !== 'all') process.stderr.write(' a. Show all open transports\n');
117
+ process.stderr.write(' c. Create new transport request\n');
118
+ process.stderr.write(' 0. Skip (no transport request)\n\n');
119
+ };
120
+
121
+ await fetchAndDisplay();
122
+
123
+ // eslint-disable-next-line no-constant-condition
124
+ while (true) {
125
+ const answer = (await ask('Enter number or option: ')).trim().toLowerCase();
126
+
127
+ if (answer === '0') {
128
+ rl.close();
129
+ return null;
130
+ }
131
+
132
+ if (answer === 's') {
133
+ scope = 'task';
134
+ await fetchAndDisplay();
135
+ continue;
136
+ }
137
+
138
+ if (answer === 'a') {
139
+ scope = 'all';
140
+ await fetchAndDisplay();
141
+ continue;
142
+ }
143
+
144
+ if (answer === 'c') {
145
+ const desc = (await ask('Description: ')).trim();
146
+ rl.close();
147
+ const number = await createTransport(http, desc);
148
+ if (number) {
149
+ process.stderr.write(`\n✅ Created transport ${number}\n`);
150
+ return number;
151
+ } else {
152
+ process.stderr.write('\n❌ Could not create transport request.\n');
153
+ return null;
154
+ }
155
+ }
156
+
157
+ const idx = parseInt(answer, 10);
158
+ if (!isNaN(idx) && idx >= 1 && idx <= transports.length) {
159
+ rl.close();
160
+ return transports[idx - 1].number;
161
+ }
162
+
163
+ process.stderr.write(`Invalid selection '${answer}'. Enter a number, s, a, c, or 0.\n`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Build the `run` helper that is passed to hooks in AI mode.
169
+ *
170
+ * run(command) — accepts a full CLI command string, e.g.:
171
+ * run('transport list --scope task')
172
+ * run('transport create --description "My transport"')
173
+ *
174
+ * Splits on whitespace, forces --json, captures output, returns parsed JSON.
175
+ *
176
+ * @param {object} config - Loaded ABAP config
177
+ * @param {object} http - Pre-built AbapHttp instance
178
+ * @param {Function} loadConfig - Config factory (from pull context)
179
+ * @param {Function} AbapHttp - AbapHttp constructor (from pull context)
180
+ * @param {Function} getTransportSettings - Transport settings getter (from pull context)
181
+ * @returns {Function|undefined} - The run helper, or undefined if factories are missing
182
+ */
183
+ function buildRun(config, http, loadConfig, AbapHttp, getTransportSettings) {
184
+ if (!loadConfig || !AbapHttp) return undefined;
185
+
186
+ return async function run(command) {
187
+ const [commandName, ...args] = command.trim().split(/\s+/);
188
+ const cmdModule = require(`../commands/${commandName}`);
189
+
190
+ // Always force --json so output is parseable
191
+ const runArgs = args.includes('--json') ? args : [...args, '--json'];
192
+
193
+ // Reuse the already-authenticated http instance
194
+ const MockAbapHttp = function MockAbapHttp() { return http; };
195
+
196
+ const runContext = {
197
+ loadConfig: () => config,
198
+ AbapHttp: MockAbapHttp,
199
+ getTransportSettings: getTransportSettings || (() => ({ allowCreate: true, allowRelease: true, reason: null }))
200
+ };
201
+
202
+ // Capture console.log output; override process.exit to throw instead of exit
203
+ const captured = [];
204
+ const origLog = console.log;
205
+ const origExit = process.exit;
206
+ console.log = (...a) => captured.push(a.map(String).join(' '));
207
+ process.exit = (code) => { throw new Error(`process.exit(${code})`); };
208
+
209
+ try {
210
+ await cmdModule.execute(runArgs, runContext);
211
+ } finally {
212
+ console.log = origLog;
213
+ process.exit = origExit;
214
+ }
215
+
216
+ const output = captured.join('');
217
+ if (!output) throw new Error(`run("${command}") produced no output`);
218
+ return JSON.parse(output);
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Main export — selects a transport request for use in the pull command.
224
+ * Returns the transport number, or null to proceed without one.
225
+ *
226
+ * @param {object} config - Loaded ABAP config
227
+ * @param {object} http - Pre-built AbapHttp instance
228
+ * @param {Function} [loadConfig] - Config factory (enables run helper in hook context)
229
+ * @param {Function} [AbapHttp] - AbapHttp constructor (enables run helper in hook context)
230
+ * @param {Function} [getTransportSettings] - Transport settings getter
231
+ * @returns {Promise<string|null>}
232
+ */
233
+ async function selectTransport(config, http, loadConfig, AbapHttp, getTransportSettings) {
234
+ // Hook takes precedence over the interactive picker — runs in both TTY and non-TTY mode
235
+ const hookConfig = module.exports._getTransportHookConfig();
236
+ if (hookConfig && hookConfig.hook) {
237
+ const hookPath = path.resolve(process.cwd(), hookConfig.hook);
238
+ const run = buildRun(config, http, loadConfig, AbapHttp, getTransportSettings);
239
+ try {
240
+ return await module.exports.runHook(hookPath, { config, http, run });
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ // No hook configured — fall back based on context
247
+ if (isNonInteractive()) {
248
+ return null; // AI/CI mode: proceed without transport
249
+ }
250
+
251
+ // Manual mode: interactive picker
252
+ return interactivePicker(http);
253
+ }
254
+
255
+ /**
256
+ * Read transport hook config from .abapgit-agent.json
257
+ * (mirrors getConflictSettings pattern in config.js)
258
+ */
259
+ function _getTransportHookConfig() {
260
+ const fs = require('fs');
261
+ const projectConfigPath = path.join(process.cwd(), '.abapgit-agent.json');
262
+
263
+ if (!fs.existsSync(projectConfigPath)) return null;
264
+
265
+ try {
266
+ const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
267
+ if (projectConfig && projectConfig.transports?.hook) {
268
+ return {
269
+ hook: projectConfig.transports.hook.path || null,
270
+ description: projectConfig.transports.hook.description || null
271
+ };
272
+ }
273
+ } catch {
274
+ // ignore parse errors
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ module.exports = {
281
+ isNonInteractive,
282
+ runHook,
283
+ fetchTransports,
284
+ createTransport,
285
+ interactivePicker,
286
+ selectTransport,
287
+ buildRun,
288
+ _getTransportHookConfig
289
+ };