abapgit-agent 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -34,7 +34,8 @@ function loadConfig() {
34
34
  language: process.env.ABAP_LANGUAGE || 'EN',
35
35
  gitUsername: process.env.GIT_USERNAME,
36
36
  gitPassword: process.env.GIT_PASSWORD,
37
- transport: process.env.ABAP_TRANSPORT
37
+ transport: process.env.ABAP_TRANSPORT,
38
+ protocol: process.env.ABAP_PROTOCOL || 'https'
38
39
  };
39
40
  }
40
41
 
@@ -51,7 +52,8 @@ function getAbapConfig() {
51
52
  password: cfg.password,
52
53
  language: cfg.language || 'EN',
53
54
  gitUsername: cfg.gitUsername || process.env.GIT_USERNAME,
54
- gitPassword: cfg.gitPassword || process.env.GIT_PASSWORD
55
+ gitPassword: cfg.gitPassword || process.env.GIT_PASSWORD,
56
+ protocol: cfg.protocol || process.env.ABAP_PROTOCOL || 'https'
55
57
  };
56
58
  }
57
59
 
@@ -141,6 +143,62 @@ function getProjectInfo() {
141
143
  return projectConfig?.project || null;
142
144
  }
143
145
 
146
+ /**
147
+ * Get conflict detection configuration from project-level config
148
+ * Precedence: CLI flag > project config > default ('abort')
149
+ * @returns {Object} Conflict detection config with mode and reason
150
+ */
151
+ function getConflictSettings() {
152
+ const projectConfig = loadProjectConfig();
153
+
154
+ if (projectConfig?.conflictDetection) {
155
+ const validModes = ['ignore', 'abort'];
156
+ const mode = projectConfig.conflictDetection.mode;
157
+ if (mode && !validModes.includes(mode)) {
158
+ console.warn(`⚠️ Warning: Invalid conflictDetection.mode '${mode}' in .abapgit-agent.json. Must be one of: ${validModes.join(', ')}. Falling back to 'abort'.`);
159
+ return { mode: 'abort', reason: null };
160
+ }
161
+ return {
162
+ mode: mode || 'abort',
163
+ reason: projectConfig.conflictDetection.reason || null
164
+ };
165
+ }
166
+
167
+ // Default: abort (conflict detection on by default)
168
+ return { mode: 'abort', reason: null };
169
+ }
170
+
171
+ /**
172
+ * Get transport hook configuration from project-level config
173
+ * @returns {{ hook: string|null, description: string|null }}
174
+ */
175
+ function getTransportHookConfig() {
176
+ const projectConfig = loadProjectConfig();
177
+ if (projectConfig?.transports?.hook) {
178
+ return {
179
+ hook: projectConfig.transports.hook.path || null,
180
+ description: projectConfig.transports.hook.description || null
181
+ };
182
+ }
183
+ return { hook: null, description: null };
184
+ }
185
+
186
+ /**
187
+ * Get transport operation settings from project-level config
188
+ * @returns {{ allowCreate: boolean, allowRelease: boolean, reason: string|null }}
189
+ */
190
+ function getTransportSettings() {
191
+ const projectConfig = loadProjectConfig();
192
+ if (projectConfig?.transports) {
193
+ return {
194
+ allowCreate: projectConfig.transports.allowCreate !== false,
195
+ allowRelease: projectConfig.transports.allowRelease !== false,
196
+ reason: projectConfig.transports.reason || null
197
+ };
198
+ }
199
+ return { allowCreate: true, allowRelease: true, reason: null };
200
+ }
201
+
144
202
  module.exports = {
145
203
  loadConfig,
146
204
  getAbapConfig,
@@ -150,5 +208,8 @@ module.exports = {
150
208
  getWorkflowConfig,
151
209
  getSafeguards,
152
210
  getProjectInfo,
153
- loadProjectConfig
211
+ getConflictSettings,
212
+ loadProjectConfig,
213
+ getTransportHookConfig,
214
+ getTransportSettings
154
215
  };
@@ -129,7 +129,7 @@ class AbapHttp {
129
129
  * @returns {Promise<string>} CSRF token
130
130
  */
131
131
  async fetchCsrfToken() {
132
- const url = new URL(`/sap/bc/z_abapgit_agent/health`, `https://${this.config.host}:${this.config.sapport}`);
132
+ const url = new URL(`/sap/bc/z_abapgit_agent/health`, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
133
133
 
134
134
  return new Promise((resolve, reject) => {
135
135
  const options = {
@@ -144,10 +144,10 @@ class AbapHttp {
144
144
  'X-CSRF-Token': 'fetch',
145
145
  'Content-Type': 'application/json'
146
146
  },
147
- agent: new https.Agent({ rejectUnauthorized: false })
147
+ agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
148
148
  };
149
149
 
150
- const req = https.request(options, (res) => {
150
+ const req = (this.config.protocol === 'http' ? http : https).request(options, (res) => {
151
151
  const csrfToken = res.headers['x-csrf-token'];
152
152
 
153
153
  // Save cookies from response
@@ -222,7 +222,7 @@ class AbapHttp {
222
222
  */
223
223
  async _makeRequest(method, urlPath, data = null, options = {}) {
224
224
  return new Promise((resolve, reject) => {
225
- const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
225
+ const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
226
226
 
227
227
  const headers = {
228
228
  'Content-Type': 'application/json',
@@ -250,7 +250,7 @@ class AbapHttp {
250
250
  path: url.pathname + url.search,
251
251
  method,
252
252
  headers,
253
- agent: new https.Agent({ rejectUnauthorized: false })
253
+ agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
254
254
  };
255
255
 
256
256
  const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
@@ -75,7 +75,7 @@ class AdtHttp {
75
75
  */
76
76
  async fetchCsrfToken() {
77
77
  return new Promise((resolve, reject) => {
78
- const url = new URL('/sap/bc/adt/discovery', `https://${this.config.host}:${this.config.sapport}`);
78
+ const url = new URL('/sap/bc/adt/discovery', `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
79
79
  const options = {
80
80
  hostname: url.hostname,
81
81
  port: url.port,
@@ -88,10 +88,10 @@ class AdtHttp {
88
88
  'X-CSRF-Token': 'fetch',
89
89
  'Accept': 'application/atomsvc+xml'
90
90
  },
91
- agent: new https.Agent({ rejectUnauthorized: false })
91
+ agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
92
92
  };
93
93
 
94
- const req = https.request(options, (res) => {
94
+ const req = (this.config.protocol === 'http' ? http : https).request(options, (res) => {
95
95
  const csrfToken = res.headers['x-csrf-token'];
96
96
  const setCookie = res.headers['set-cookie'];
97
97
  if (setCookie) {
@@ -140,7 +140,7 @@ class AdtHttp {
140
140
 
141
141
  async _makeRequest(method, urlPath, body = null, options = {}) {
142
142
  return new Promise((resolve, reject) => {
143
- const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
143
+ const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
144
144
 
145
145
  const headers = {
146
146
  'Content-Type': options.contentType || 'application/atom+xml',
@@ -169,7 +169,7 @@ class AdtHttp {
169
169
  path: url.pathname + url.search,
170
170
  method,
171
171
  headers,
172
- agent: new https.Agent({ rejectUnauthorized: false })
172
+ agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
173
173
  };
174
174
 
175
175
  const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
@@ -272,7 +272,7 @@ class AdtHttp {
272
272
  */
273
273
  async postFire(urlPath, body = null, options = {}) {
274
274
  return new Promise((resolve, reject) => {
275
- const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
275
+ const url = new URL(urlPath, `${this.config.protocol || 'https'}://${this.config.host}:${this.config.sapport}`);
276
276
 
277
277
  const headers = {
278
278
  'Content-Type': options.contentType || 'application/atom+xml',
@@ -301,7 +301,7 @@ class AdtHttp {
301
301
  path: url.pathname + url.search,
302
302
  method: 'POST',
303
303
  headers,
304
- agent: new https.Agent({ rejectUnauthorized: false })
304
+ agent: this.config.protocol === 'http' ? undefined : new https.Agent({ rejectUnauthorized: false })
305
305
  };
306
306
 
307
307
  const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (_res) => {
@@ -67,6 +67,17 @@ async function startDaemon(config, sessionId, socketPath, snapshot) {
67
67
 
68
68
  const session = new DebugSession(adt, sessionId);
69
69
 
70
+ // Pin the SAP_SESSIONID so _restorePinnedSession() works inside the daemon.
71
+ // DebugSession.attach() normally sets pinnedSessionId, but the daemon skips
72
+ // attach() and reconstructs the session from the snapshot. Without this,
73
+ // any CSRF refresh that AdtHttp performs internally (401/403 retry → HEAD
74
+ // request → new Set-Cookie) silently overwrites the session cookie and routes
75
+ // subsequent IPC calls to the wrong ABAP work process → HTTP 400.
76
+ if (snapshot && snapshot.cookies) {
77
+ const m = snapshot.cookies.match(/SAP_SESSIONID=([^;]*)/);
78
+ if (m) session.pinnedSessionId = m[1];
79
+ }
80
+
70
81
  // Remove stale socket file from a previous crash
71
82
  try { fs.unlinkSync(socketPath); } catch (e) { /* ignore ENOENT */ }
72
83
 
@@ -85,6 +96,17 @@ async function startDaemon(config, sessionId, socketPath, snapshot) {
85
96
  process.exit(code || 0);
86
97
  }
87
98
 
99
+ // On SIGTERM (e.g. pkill from ensure_breakpoint cleanup), attempt to release
100
+ // the frozen ABAP work process before exiting. Without this, killing the
101
+ // daemon leaves the work process paused at the breakpoint until SAP's own
102
+ // session-timeout fires (up to several minutes).
103
+ process.once('SIGTERM', async () => {
104
+ try {
105
+ await session.terminate();
106
+ } catch (e) { /* ignore — best effort */ }
107
+ cleanupAndExit(0);
108
+ });
109
+
88
110
  const server = net.createServer((socket) => {
89
111
  resetIdle();
90
112
  let buf = '';
@@ -76,6 +76,11 @@ async function startRepl(session, initialState, onBeforeExit) {
76
76
 
77
77
  renderState(position, source, variables);
78
78
 
79
+ // Keep the ADT stateful session alive with periodic getStack() pings.
80
+ // SAP's ICM drops session affinity after ~60 s of idle, causing stepContinue
81
+ // to route to the wrong work process (HTTP 400) when the user eventually quits.
82
+ session.startKeepalive();
83
+
79
84
  const rl = readline.createInterface({
80
85
  input: process.stdin,
81
86
  output: process.stdout,
@@ -178,6 +183,7 @@ async function startRepl(session, initialState, onBeforeExit) {
178
183
 
179
184
  } else if (cmd === 'q' || cmd === 'quit') {
180
185
  console.log('\n Detaching debugger — program will continue running...');
186
+ session.stopKeepalive();
181
187
  try {
182
188
  await session.detach();
183
189
  } catch (e) {
@@ -190,6 +196,7 @@ async function startRepl(session, initialState, onBeforeExit) {
190
196
 
191
197
  } else if (cmd === 'kill') {
192
198
  console.log('\n Terminating program (hard abort)...');
199
+ session.stopKeepalive();
193
200
  try {
194
201
  await session.terminate();
195
202
  } catch (e) {
@@ -213,7 +220,23 @@ async function startRepl(session, initialState, onBeforeExit) {
213
220
  });
214
221
 
215
222
  rl.on('close', async () => {
223
+ // stdin closed (EOF or Ctrl+D) without an explicit 'q' or 'kill' command.
224
+ // Detach so the ABAP work process is released — without this the WP stays
225
+ // frozen in SM50 (e.g. when attach is started with </dev/null stdin and
226
+ // readline gets immediate EOF before the user can type a command).
227
+ if (!exitCleanupDone) {
228
+ session.stopKeepalive();
229
+ try { await session.detach(); } catch (e) { /* ignore */ }
230
+ }
216
231
  await runExitCleanup();
232
+ // Drain stdout/stderr before exiting so the OS TCP stack has flushed the
233
+ // outbound stepContinue request bytes. process.exit() tears down file
234
+ // descriptors immediately — without this the WP can stay frozen if the
235
+ // socket buffer hasn't been written to the NIC yet.
236
+ await new Promise(resolve => {
237
+ if (process.stdout.writableEnded) return resolve();
238
+ process.stdout.write('', resolve);
239
+ });
217
240
  process.exit(0);
218
241
  });
219
242
 
@@ -38,7 +38,7 @@ const STATEFUL_HEADER = { 'X-sap-adt-sessiontype': 'stateful' };
38
38
  * @param {number} maxRetries - Max additional attempts after the first (default 3)
39
39
  * @param {number} delayMs - Wait between retries in ms (default 1000)
40
40
  */
41
- async function retryOnIcmError(fn, maxRetries = 3, delayMs = 1000) {
41
+ async function retryOnIcmError(fn, maxRetries = 12, delayMs = 2000) {
42
42
  let lastErr;
43
43
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
44
44
  try {
@@ -64,6 +64,56 @@ class DebugSession {
64
64
  constructor(adtHttp, sessionId) {
65
65
  this.http = adtHttp;
66
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
+ );
67
117
  }
68
118
 
69
119
  /**
@@ -95,6 +145,14 @@ class DebugSession {
95
145
  this.sessionId = debugSessionId;
96
146
  }
97
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
+
98
156
  return this.sessionId;
99
157
  }
100
158
 
@@ -126,6 +184,7 @@ class DebugSession {
126
184
  // (no suspended session left). Treat both 200 and 500 as "continued".
127
185
  if (method === 'stepContinue') {
128
186
  return retryOnIcmError(async () => {
187
+ this._restorePinnedSession();
129
188
  try {
130
189
  await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
131
190
  contentType: 'application/vnd.sap.as+xml',
@@ -146,6 +205,7 @@ class DebugSession {
146
205
  }
147
206
 
148
207
  return retryOnIcmError(async () => {
208
+ this._restorePinnedSession();
149
209
  await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
150
210
  contentType: 'application/vnd.sap.as+xml',
151
211
  headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
@@ -173,6 +233,8 @@ class DebugSession {
173
233
  * @returns {Promise<Array<{ name: string, type: string, value: string }>>}
174
234
  */
175
235
  async getVariables(name = null) {
236
+ this._restorePinnedSession();
237
+
176
238
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
177
239
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
178
240
 
@@ -254,6 +316,8 @@ class DebugSession {
254
316
  * @returns {Promise<Array<{ id: string, name: string, type: string, value: string }>>}
255
317
  */
256
318
  async getVariableChildren(parentId, meta = {}) {
319
+ this._restorePinnedSession();
320
+
257
321
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
258
322
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
259
323
 
@@ -372,6 +436,7 @@ class DebugSession {
372
436
  */
373
437
  async getStack() {
374
438
  return retryOnIcmError(async () => {
439
+ this._restorePinnedSession();
375
440
  // Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
376
441
  try {
377
442
  const { body } = await this.http.get(
@@ -383,9 +448,8 @@ class DebugSession {
383
448
  const frames = parseStack(body);
384
449
  if (frames.length > 0) return frames;
385
450
  } catch (e) {
386
- // Re-throw ICM errors so the outer retryOnIcmError can catch them
387
- if (e && e.statusCode === 400 && e.body && e.body.includes('Service cannot be reached')) throw e;
388
- // Otherwise fall through to POST approach
451
+ // Fall through to POST approach for any GET failure (including 400 on systems
452
+ // that don't support the dedicated /debugger/stack endpoint)
389
453
  }
390
454
  // Fallback: POST approach (older ADT versions)
391
455
  const { body } = await this.http.post(
@@ -583,32 +647,38 @@ class DebugSession {
583
647
 
584
648
  /**
585
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).
586
652
  */
587
653
  async terminate() {
588
- await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
589
- contentType: 'application/vnd.sap.as+xml',
590
- 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
+ });
591
660
  });
592
661
  }
593
662
 
594
663
  /**
595
664
  * Detach from the debuggee without killing it.
596
- * 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.
597
673
  *
598
- * stepContinue is a long-poll in ADT it only responds when the program
599
- * hits another breakpoint (200) or finishes (500), which may be never.
600
- * We use postFire() which resolves as soon as the request bytes are
601
- * flushed to the TCP send buffer — no need to wait for a response.
602
- * 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.
603
676
  */
604
677
  async detach() {
605
678
  try {
606
- await this.http.postFire('/sap/bc/adt/debugger?method=stepContinue', '', {
607
- contentType: 'application/vnd.sap.as+xml',
608
- headers: STATEFUL_HEADER
609
- });
679
+ await this.step('stepContinue');
610
680
  } catch (e) {
611
- // Ignore — fire-and-forget; errors here mean the session already closed.
681
+ // Ignore — session may have already closed.
612
682
  }
613
683
  }
614
684
  }