sentinelayer-cli 0.4.5 → 0.8.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 (72) hide show
  1. package/README.md +16 -18
  2. package/package.json +7 -6
  3. package/src/agents/jules/config/definition.js +13 -62
  4. package/src/agents/jules/config/system-prompt.js +8 -1
  5. package/src/agents/jules/fix-cycle.js +12 -372
  6. package/src/agents/jules/loop.js +116 -26
  7. package/src/agents/jules/pulse.js +10 -327
  8. package/src/agents/jules/stream.js +13 -12
  9. package/src/agents/jules/swarm/orchestrator.js +3 -3
  10. package/src/agents/jules/swarm/sub-agent.js +6 -3
  11. package/src/agents/jules/tools/aidenid-email.js +189 -0
  12. package/src/agents/jules/tools/auth-audit.js +1187 -45
  13. package/src/agents/jules/tools/dispatch.js +25 -12
  14. package/src/agents/jules/tools/file-edit.js +2 -180
  15. package/src/agents/jules/tools/file-read.js +2 -100
  16. package/src/agents/jules/tools/glob.js +2 -168
  17. package/src/agents/jules/tools/grep.js +2 -228
  18. package/src/agents/jules/tools/path-guards.js +2 -161
  19. package/src/agents/jules/tools/runtime-audit.js +6 -2
  20. package/src/agents/jules/tools/shell.js +2 -383
  21. package/src/agents/persona-visuals.js +64 -0
  22. package/src/agents/shared-tools/dispatch-core.js +320 -0
  23. package/src/agents/shared-tools/file-edit.js +180 -0
  24. package/src/agents/shared-tools/file-read.js +100 -0
  25. package/src/agents/shared-tools/glob.js +168 -0
  26. package/src/agents/shared-tools/grep.js +228 -0
  27. package/src/agents/shared-tools/index.js +46 -0
  28. package/src/agents/shared-tools/path-guards.js +161 -0
  29. package/src/agents/shared-tools/shell.js +383 -0
  30. package/src/ai/aidenid.js +56 -7
  31. package/src/ai/client.js +45 -0
  32. package/src/ai/proxy.js +137 -0
  33. package/src/auth/gate.js +290 -16
  34. package/src/auth/http.js +450 -39
  35. package/src/auth/service.js +262 -47
  36. package/src/auth/session-store.js +475 -21
  37. package/src/cli.js +5 -0
  38. package/src/commands/audit.js +13 -8
  39. package/src/commands/auth.js +53 -9
  40. package/src/commands/omargate.js +10 -2
  41. package/src/commands/scan.js +10 -4
  42. package/src/commands/session.js +590 -0
  43. package/src/commands/spec.js +62 -0
  44. package/src/commands/watch.js +3 -2
  45. package/src/daemon/assignment-ledger.js +196 -0
  46. package/src/daemon/error-worker.js +599 -16
  47. package/src/daemon/fix-cycle.js +384 -0
  48. package/src/daemon/ingest-refresh.js +10 -9
  49. package/src/daemon/jira-lifecycle.js +135 -0
  50. package/src/daemon/pulse.js +327 -0
  51. package/src/daemon/scope-engine.js +1068 -0
  52. package/src/events/schema.js +190 -0
  53. package/src/interactive/index.js +18 -16
  54. package/src/legacy-cli.js +606 -37
  55. package/src/prompt/generator.js +19 -1
  56. package/src/review/ai-review.js +11 -1
  57. package/src/review/local-review.js +75 -19
  58. package/src/review/omargate-interactive.js +68 -0
  59. package/src/review/omargate-orchestrator.js +404 -0
  60. package/src/review/persona-prompts.js +296 -0
  61. package/src/review/scan-modes.js +48 -0
  62. package/src/scan/generator.js +1 -1
  63. package/src/session/agent-registry.js +352 -0
  64. package/src/session/daemon.js +801 -0
  65. package/src/session/paths.js +33 -0
  66. package/src/session/runtime-bridge.js +739 -0
  67. package/src/session/store.js +388 -0
  68. package/src/session/stream.js +325 -0
  69. package/src/spec/generator.js +100 -0
  70. package/src/telemetry/session-tracker.js +148 -32
  71. package/src/telemetry/sync.js +6 -2
  72. package/src/ui/command-hints.js +13 -0
package/src/auth/http.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
2
6
 
3
7
  /**
4
8
  * Default timeout applied to Sentinelayer API requests when no override is provided.
@@ -13,33 +17,232 @@ export const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
13
17
 
14
18
  const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
15
19
  const CIRCUIT_TRACK_STATUS_CODES = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
16
- const circuitState = {
17
- consecutiveFailures: 0,
18
- openedAtMs: 0,
19
- };
20
+ const circuitStateByScope = new Map();
21
+
22
+ // File-backed cache. Lets cooldown + failure counts survive across CLI
23
+ // invocations — without it, each new process hammers a degraded upstream
24
+ // until it hits the threshold again. Best-effort: if file ops fail, we
25
+ // silently fall back to in-memory behavior (no hard dependency).
26
+ const CIRCUIT_STATE_FILE = (() => {
27
+ const base = process.env.SENTINELAYER_CIRCUIT_STATE_DIR ||
28
+ path.join(os.homedir() || os.tmpdir(), ".sentinelayer");
29
+ try {
30
+ fs.mkdirSync(base, { recursive: true });
31
+ } catch {
32
+ /* noop */
33
+ }
34
+ return path.join(base, "circuit-state.json");
35
+ })();
36
+ let circuitStateLoaded = false;
37
+
38
+ function loadCircuitStateFromDisk() {
39
+ if (circuitStateLoaded) return;
40
+ circuitStateLoaded = true;
41
+ try {
42
+ const raw = fs.readFileSync(CIRCUIT_STATE_FILE, "utf-8");
43
+ const parsed = JSON.parse(raw);
44
+ if (!parsed || typeof parsed !== "object") return;
45
+ const now = Date.now();
46
+ for (const [key, value] of Object.entries(parsed)) {
47
+ if (!value || typeof value !== "object") continue;
48
+ const consecutiveFailures = Number(value.consecutiveFailures) || 0;
49
+ const openedAtMs = Number(value.openedAtMs) || 0;
50
+ // TTL: only carry over entries within the cooldown window — older
51
+ // entries are stale and should reset naturally on this process.
52
+ if (openedAtMs > 0 && now - openedAtMs < CIRCUIT_BREAKER_COOLDOWN_MS) {
53
+ circuitStateByScope.set(String(key), { consecutiveFailures, openedAtMs });
54
+ }
55
+ }
56
+ } catch {
57
+ /* noop: missing, corrupt, or unreadable — start fresh */
58
+ }
59
+ }
60
+
61
+ function persistCircuitState() {
62
+ try {
63
+ const snapshot = {};
64
+ for (const [key, value] of circuitStateByScope.entries()) {
65
+ snapshot[key] = {
66
+ consecutiveFailures: value.consecutiveFailures || 0,
67
+ openedAtMs: value.openedAtMs || 0,
68
+ };
69
+ }
70
+ const tmp = `${CIRCUIT_STATE_FILE}.${process.pid}.tmp`;
71
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), "utf-8");
72
+ fs.renameSync(tmp, CIRCUIT_STATE_FILE);
73
+ } catch {
74
+ /* noop */
75
+ }
76
+ }
77
+ const REQUEST_ID_HEADERS = ["x-request-id", "request-id", "x-correlation-id"];
78
+ const DEBUG_API_ERRORS_ENV = "SENTINELAYER_DEBUG_ERRORS";
79
+ const MAX_API_ERROR_MESSAGE_LENGTH = 512;
80
+ const IDEMPOTENCY_KEY_MIN_LENGTH = 32;
81
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
82
+ const PREFIXED_UUID_V4_PATTERN = /^[a-z0-9][a-z0-9_-]{1,48}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
83
+
84
+ function resolveCircuitScope(url) {
85
+ try {
86
+ const parsed = new URL(String(url));
87
+ return parsed.origin;
88
+ } catch {
89
+ return "unknown";
90
+ }
91
+ }
92
+
93
+ function getCircuitState(scope) {
94
+ loadCircuitStateFromDisk();
95
+ const key = String(scope || "unknown");
96
+ if (!circuitStateByScope.has(key)) {
97
+ circuitStateByScope.set(key, { consecutiveFailures: 0, openedAtMs: 0 });
98
+ }
99
+ return circuitStateByScope.get(key);
100
+ }
20
101
 
21
102
  function normalizeApiError(errorPayload = {}) {
103
+ const fallbackMessage = "Unknown API error";
22
104
  if (!errorPayload || typeof errorPayload !== "object" || Array.isArray(errorPayload)) {
23
105
  return {
24
106
  code: "UNKNOWN",
25
- message: "Unknown API error",
107
+ message: sanitizeApiErrorMessage(fallbackMessage, fallbackMessage),
26
108
  requestId: null,
27
109
  };
28
110
  }
111
+ const rawMessage = String(errorPayload.message || fallbackMessage);
112
+ const safeMessage = sanitizeApiErrorMessage(rawMessage, fallbackMessage);
113
+ const message = appendDebugContext(safeMessage, {
114
+ code: String(errorPayload.code || "UNKNOWN"),
115
+ requestId: errorPayload.request_id ? String(errorPayload.request_id) : null,
116
+ });
29
117
  return {
30
118
  code: String(errorPayload.code || "UNKNOWN"),
31
- message: String(errorPayload.message || "Unknown API error"),
119
+ message,
32
120
  requestId: errorPayload.request_id ? String(errorPayload.request_id) : null,
33
121
  };
34
122
  }
35
123
 
124
+ function resolveRequestId(headers) {
125
+ if (!headers || typeof headers.get !== "function") {
126
+ return null;
127
+ }
128
+ for (const headerName of REQUEST_ID_HEADERS) {
129
+ const value = headers.get(headerName);
130
+ if (value) {
131
+ return String(value);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function resolveIdempotencyKey(headers) {
138
+ if (!headers) {
139
+ return null;
140
+ }
141
+ if (typeof headers.get === "function") {
142
+ const value =
143
+ headers.get("idempotency-key") ||
144
+ headers.get("Idempotency-Key") ||
145
+ headers.get("IDEMPOTENCY-KEY");
146
+ return String(value || "").trim() || null;
147
+ }
148
+ if (typeof headers !== "object") {
149
+ return null;
150
+ }
151
+ for (const [key, value] of Object.entries(headers)) {
152
+ if (String(key || "").toLowerCase() === "idempotency-key") {
153
+ const normalized = String(value || "").trim();
154
+ return normalized || null;
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function isValidIdempotencyKey(value) {
161
+ const normalized = String(value || "").trim();
162
+ if (!normalized) {
163
+ return false;
164
+ }
165
+ if (UUID_V4_PATTERN.test(normalized)) {
166
+ return true;
167
+ }
168
+ if (PREFIXED_UUID_V4_PATTERN.test(normalized)) {
169
+ return true;
170
+ }
171
+ if (normalized.length < IDEMPOTENCY_KEY_MIN_LENGTH) {
172
+ return false;
173
+ }
174
+ return /^[A-Za-z0-9_-]+$/.test(normalized);
175
+ }
176
+
177
+ function validateIdempotencyKey(value) {
178
+ if (!isValidIdempotencyKey(value)) {
179
+ throw new SentinelayerApiError("Idempotency-Key must be a UUIDv4 or a prefixed UUID.", {
180
+ status: 400,
181
+ code: "IDEMPOTENCY_KEY_INVALID",
182
+ });
183
+ }
184
+ }
185
+
186
+ function sanitizeOperationName(value) {
187
+ const normalized = String(value || "").toLowerCase();
188
+ const cleaned = normalized.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
189
+ return cleaned.slice(0, 32) || "mutation";
190
+ }
191
+
192
+ function createIdempotencyKeyForOperation(operationName) {
193
+ const op = sanitizeOperationName(operationName);
194
+ let suffix;
195
+ try {
196
+ suffix = crypto.randomUUID();
197
+ } catch {
198
+ suffix = crypto.randomBytes(16).toString("hex");
199
+ }
200
+ return `sl-cli-${op}-${suffix}`;
201
+ }
202
+
203
+ function normalizeHeaderObject(headers) {
204
+ if (!headers) {
205
+ return {};
206
+ }
207
+ if (typeof headers.get === "function") {
208
+ const normalized = {};
209
+ for (const [key, value] of headers.entries()) {
210
+ normalized[key] = value;
211
+ }
212
+ return normalized;
213
+ }
214
+ if (typeof headers !== "object") {
215
+ return {};
216
+ }
217
+ return { ...headers };
218
+ }
219
+
220
+ function isMutationVerb(method) {
221
+ const normalized = String(method || "").trim().toUpperCase();
222
+ return (
223
+ normalized === "POST" ||
224
+ normalized === "PUT" ||
225
+ normalized === "PATCH" ||
226
+ normalized === "DELETE"
227
+ );
228
+ }
229
+
230
+ function applyIdempotencyKey(headers, idempotencyKey) {
231
+ const normalized = normalizeHeaderObject(headers);
232
+ if (idempotencyKey && !resolveIdempotencyKey(normalized)) {
233
+ normalized["Idempotency-Key"] = idempotencyKey;
234
+ }
235
+ return normalized;
236
+ }
237
+
36
238
  export class SentinelayerApiError extends Error {
37
239
  /**
38
240
  * @param {string} message
39
241
  * @param {{ status?: number, code?: string, requestId?: string | null }} [options]
40
242
  */
41
243
  constructor(message, { status = 500, code = "UNKNOWN", requestId = null } = {}) {
42
- super(String(message || "Sentinelayer API error"));
244
+ const safeMessage = sanitizeApiErrorMessage(message, "Sentinelayer API error");
245
+ super(appendDebugContext(safeMessage, { code, status, requestId }));
43
246
  this.name = "SentinelayerApiError";
44
247
  this.status = Number(status || 500);
45
248
  this.code = String(code || "UNKNOWN");
@@ -92,28 +295,87 @@ function computeBackoffMs({ attempt, retryDelayMs, retryAfterHeader }) {
92
295
  return Math.min(Math.max(1, computed), MAX_RETRY_DELAY_MS);
93
296
  }
94
297
 
95
- function isCircuitOpen() {
298
+ function isCircuitOpen(scope) {
299
+ const circuitState = getCircuitState(scope);
96
300
  if (circuitState.openedAtMs <= 0) {
97
301
  return false;
98
302
  }
99
303
  if (Date.now() - circuitState.openedAtMs >= CIRCUIT_BREAKER_COOLDOWN_MS) {
100
304
  circuitState.openedAtMs = 0;
101
305
  circuitState.consecutiveFailures = 0;
306
+ persistCircuitState();
102
307
  return false;
103
308
  }
104
309
  return true;
105
310
  }
106
311
 
107
- function recordFailureForCircuit() {
312
+ function recordFailureForCircuit(scope) {
313
+ const circuitState = getCircuitState(scope);
108
314
  circuitState.consecutiveFailures += 1;
109
315
  if (circuitState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
110
316
  circuitState.openedAtMs = Date.now();
111
317
  }
318
+ persistCircuitState();
112
319
  }
113
320
 
114
- function recordSuccessForCircuit() {
321
+ function shouldExposeApiErrorDetails() {
322
+ const normalized = String(process.env[DEBUG_API_ERRORS_ENV] || "").trim().toLowerCase();
323
+ return normalized === "true" || normalized === "1" || normalized === "yes";
324
+ }
325
+
326
+ function isTestNonIdempotentAllowed() {
327
+ return (
328
+ process.env.NODE_ENV === "test" &&
329
+ process.env.SENTINELAYER_ALLOW_NON_IDEMPOTENT === "1" &&
330
+ process.env.SENTINELAYER_CLI_TEST_MODE === "1" &&
331
+ !process.env.CI
332
+ );
333
+ }
334
+
335
+ function sanitizeApiErrorMessage(message, fallback = "Sentinelayer API error") {
336
+ const fallbackMessage = String(fallback || "Sentinelayer API error");
337
+ const normalized = String(message || "").trim();
338
+ const candidate = normalized || fallbackMessage;
339
+ const sanitized = candidate
340
+ .replace(/\bbearer\s+[a-z0-9._~+/=-]+\b/gi, "bearer [REDACTED]")
341
+ .replace(/\b(token|secret|password|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)\b\s*[:=]\s*["']?[^"'\s,;]+["']?/gi, "$1=[REDACTED]")
342
+ .replace(/\b[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+\b/gi, "[REDACTED_JWT]")
343
+ .replace(/\bhttps?:\/\/[^\s"'`]+/gi, () => "<redacted-url>")
344
+ .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/gi, "<redacted-email>");
345
+ if (sanitized.length <= MAX_API_ERROR_MESSAGE_LENGTH) {
346
+ return sanitized;
347
+ }
348
+ return `${sanitized.slice(0, MAX_API_ERROR_MESSAGE_LENGTH - 3)}...`;
349
+ }
350
+
351
+ function anonymizeRequestIdForDebug(requestId) {
352
+ const normalized = String(requestId || "").trim();
353
+ if (!normalized) {
354
+ return "";
355
+ }
356
+ return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 12);
357
+ }
358
+
359
+ function appendDebugContext(safeMessage, { code, status, requestId } = {}) {
360
+ if (!shouldExposeApiErrorDetails()) {
361
+ return safeMessage;
362
+ }
363
+ const parts = [];
364
+ const normalizedCode = String(code || "").trim();
365
+ const normalizedStatus = Number.isFinite(Number(status)) ? Number(status) : null;
366
+ const requestIdHash = anonymizeRequestIdForDebug(requestId);
367
+ if (normalizedCode) parts.push(`code=${normalizedCode}`);
368
+ if (normalizedStatus) parts.push(`status=${normalizedStatus}`);
369
+ if (requestIdHash) parts.push(`request_id_hash=${requestIdHash}`);
370
+ if (parts.length === 0) return safeMessage;
371
+ return `${safeMessage} (${parts.join(", ")})`;
372
+ }
373
+
374
+ function recordSuccessForCircuit(scope) {
375
+ const circuitState = getCircuitState(scope);
115
376
  circuitState.consecutiveFailures = 0;
116
377
  circuitState.openedAtMs = 0;
378
+ persistCircuitState();
117
379
  }
118
380
 
119
381
  function shouldRetryStatus(statusCode) {
@@ -124,9 +386,21 @@ function shouldRecordFailureForStatus(statusCode) {
124
386
  return CIRCUIT_TRACK_STATUS_CODES.has(Number(statusCode || 0));
125
387
  }
126
388
 
127
- export function __resetRequestCircuitForTests() {
128
- circuitState.consecutiveFailures = 0;
129
- circuitState.openedAtMs = 0;
389
+ export function __resetRequestCircuitForTests(scope) {
390
+ if (scope) {
391
+ const circuitState = getCircuitState(scope);
392
+ circuitState.consecutiveFailures = 0;
393
+ circuitState.openedAtMs = 0;
394
+ persistCircuitState();
395
+ return;
396
+ }
397
+ circuitStateByScope.clear();
398
+ circuitStateLoaded = true; // block disk reload after manual reset
399
+ try {
400
+ fs.rmSync(CIRCUIT_STATE_FILE, { force: true });
401
+ } catch {
402
+ /* noop */
403
+ }
130
404
  }
131
405
 
132
406
  /**
@@ -138,9 +412,12 @@ export function __resetRequestCircuitForTests() {
138
412
  * method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
139
413
  * headers?: Record<string, string>,
140
414
  * body?: unknown,
415
+ * idempotencyKey?: string | null,
416
+ * allowNonIdempotent?: boolean,
141
417
  * timeoutMs?: number
142
418
  * maxRetries?: number,
143
419
  * retryDelayMs?: number
420
+ * allowEmptyBody?: boolean
144
421
  * }} [options]
145
422
  * @returns {Promise<any>}
146
423
  */
@@ -150,12 +427,46 @@ export async function requestJson(
150
427
  method = "GET",
151
428
  headers = {},
152
429
  body,
430
+ idempotencyKey = null,
431
+ allowNonIdempotent = false,
153
432
  timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
154
433
  maxRetries = DEFAULT_MAX_RETRIES,
155
434
  retryDelayMs = DEFAULT_RETRY_DELAY_MS,
435
+ allowEmptyBody = false,
156
436
  } = {}
157
437
  ) {
158
- if (isCircuitOpen()) {
438
+ const normalizedMethod = String(method || "GET").trim().toUpperCase();
439
+ const explicitIdempotencyKey = String(idempotencyKey || "").trim() || null;
440
+ const existingIdempotencyKey = explicitIdempotencyKey || resolveIdempotencyKey(headers);
441
+ const isMutationMethod = isMutationVerb(normalizedMethod);
442
+ const resolvedIdempotencyKey = existingIdempotencyKey;
443
+ const requestHeaders = applyIdempotencyKey(headers, resolvedIdempotencyKey);
444
+ const outgoingHeaders = { ...requestHeaders };
445
+ if (body !== undefined) {
446
+ outgoingHeaders["Content-Type"] = "application/json";
447
+ }
448
+ const isIdempotentMutation = Boolean(resolvedIdempotencyKey);
449
+ const allowUnsafeMutation = Boolean(allowNonIdempotent) && isTestNonIdempotentAllowed();
450
+ if (isMutationMethod && !isIdempotentMutation && !allowUnsafeMutation) {
451
+ throw new SentinelayerApiError("Idempotency-Key is required for mutation requests.", {
452
+ status: 400,
453
+ code: "IDEMPOTENCY_KEY_REQUIRED",
454
+ });
455
+ }
456
+ if (isMutationMethod && isIdempotentMutation) {
457
+ validateIdempotencyKey(resolvedIdempotencyKey);
458
+ }
459
+ const retryableMethod =
460
+ normalizedMethod === "GET" ||
461
+ normalizedMethod === "HEAD" ||
462
+ normalizedMethod === "OPTIONS" ||
463
+ (isIdempotentMutation &&
464
+ (normalizedMethod === "POST" ||
465
+ normalizedMethod === "PUT" ||
466
+ normalizedMethod === "PATCH" ||
467
+ normalizedMethod === "DELETE"));
468
+ const circuitScope = resolveCircuitScope(url);
469
+ if (isCircuitOpen(circuitScope)) {
159
470
  throw new SentinelayerApiError("Request circuit breaker is open after consecutive API failures.", {
160
471
  status: 503,
161
472
  code: "CIRCUIT_OPEN",
@@ -169,52 +480,84 @@ export async function requestJson(
169
480
  let lastRetryableError = null;
170
481
  for (let attempt = 0; attempt <= normalizedMaxRetries; attempt += 1) {
171
482
  const controller = new AbortController();
172
- const timeout = setTimeout(() => controller.abort(), normalizedTimeoutMs);
483
+ let timeoutTriggered = false;
484
+ let timeoutHandle;
485
+ timeoutHandle = setTimeout(() => {
486
+ timeoutTriggered = true;
487
+ controller.abort();
488
+ }, normalizedTimeoutMs);
173
489
 
174
490
  try {
175
491
  const response = await fetch(String(url), {
176
- method,
177
- headers: {
178
- "Content-Type": "application/json",
179
- ...headers,
180
- },
492
+ method: normalizedMethod,
493
+ headers: outgoingHeaders,
181
494
  body: body === undefined ? undefined : JSON.stringify(body),
495
+ redirect: "error",
182
496
  signal: controller.signal,
183
497
  });
184
498
 
185
499
  const rawBody = await response.text();
500
+ const trimmedBody = rawBody.trim();
501
+ const contentType = response.headers.get("content-type") || "";
502
+ const isJson = /application\/json/i.test(contentType);
186
503
  let json = {};
187
- if (rawBody.trim()) {
188
- try {
189
- json = JSON.parse(rawBody);
190
- } catch {
191
- if (response.ok) {
192
- throw new SentinelayerApiError("Invalid JSON returned by API.", {
193
- status: response.status,
194
- code: "INVALID_JSON",
195
- });
504
+ if (!trimmedBody) {
505
+ const statusCode = Number(response.status || 0);
506
+ const allowEmpty = Boolean(allowEmptyBody) || statusCode === 204 || statusCode === 205;
507
+ if (response.ok && !allowEmpty) {
508
+ const requestId = resolveRequestId(response.headers);
509
+ throw new SentinelayerApiError("Empty response body returned by API.", {
510
+ status: response.status,
511
+ code: "EMPTY_BODY",
512
+ requestId,
513
+ });
514
+ }
515
+ } else {
516
+ if (response.ok && !isJson) {
517
+ const requestId = resolveRequestId(response.headers);
518
+ throw new SentinelayerApiError("Invalid content-type returned by API.", {
519
+ status: response.status,
520
+ code: "INVALID_CONTENT_TYPE",
521
+ requestId,
522
+ });
523
+ }
524
+ if (isJson) {
525
+ try {
526
+ json = JSON.parse(rawBody);
527
+ } catch {
528
+ const requestId = resolveRequestId(response.headers);
529
+ if (response.ok) {
530
+ throw new SentinelayerApiError("Invalid JSON returned by API.", {
531
+ status: response.status,
532
+ code: "INVALID_JSON",
533
+ requestId,
534
+ });
535
+ }
196
536
  }
197
537
  }
198
538
  }
199
539
 
200
540
  if (response.ok) {
201
- recordSuccessForCircuit();
541
+ recordSuccessForCircuit(circuitScope);
202
542
  return json;
203
543
  }
204
544
 
205
545
  const apiError = normalizeApiError(json && typeof json === "object" ? json.error : {});
546
+ const requestId = apiError.requestId || resolveRequestId(response.headers);
206
547
  const statusCode = Number(response.status || 500);
207
- const retryable = shouldRetryStatus(statusCode);
548
+ const retryable = retryableMethod && shouldRetryStatus(statusCode);
208
549
  const shouldRecordCircuitFailure = shouldRecordFailureForStatus(statusCode);
550
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
209
551
  const error = new SentinelayerApiError(apiError.message, {
210
552
  status: statusCode,
211
553
  code: apiError.code,
212
- requestId: apiError.requestId,
554
+ requestId,
213
555
  });
556
+ error.retryAfterMs = retryAfterMs;
214
557
 
215
558
  if (!retryable || attempt >= normalizedMaxRetries) {
216
559
  if (shouldRecordCircuitFailure) {
217
- recordFailureForCircuit();
560
+ recordFailureForCircuit(circuitScope);
218
561
  }
219
562
  throw error;
220
563
  }
@@ -233,16 +576,23 @@ export async function requestJson(
233
576
  }
234
577
 
235
578
  const isAbortError = Boolean(error && typeof error === "object" && error.name === "AbortError");
579
+ const abortMessage = error instanceof Error ? error.message : String(error || "");
580
+ const abortReason =
581
+ isAbortError && !timeoutTriggered && /cancel/i.test(abortMessage) ? "CANCELLED" : "TIMEOUT";
582
+ const abortCode = isAbortError ? abortReason : "NETWORK_ERROR";
583
+ const abortStatus = isAbortError ? (abortReason === "CANCELLED" ? 499 : 408) : 503;
236
584
  const normalizedError = new SentinelayerApiError(
237
- isAbortError ? "Request timed out." : (error instanceof Error ? error.message : String(error || "Request failed")),
585
+ isAbortError
586
+ ? (abortReason === "CANCELLED" ? "Request cancelled." : "Request timed out.")
587
+ : (error instanceof Error ? error.message : String(error || "Request failed")),
238
588
  {
239
- status: isAbortError ? 408 : 503,
240
- code: isAbortError ? "TIMEOUT" : "NETWORK_ERROR",
589
+ status: abortStatus,
590
+ code: abortCode,
241
591
  }
242
592
  );
243
593
 
244
- if (attempt >= normalizedMaxRetries) {
245
- recordFailureForCircuit();
594
+ if (!retryableMethod || attempt >= normalizedMaxRetries) {
595
+ recordFailureForCircuit(circuitScope);
246
596
  throw normalizedError;
247
597
  }
248
598
 
@@ -255,7 +605,9 @@ export async function requestJson(
255
605
  await sleep(delayMs);
256
606
  continue;
257
607
  } finally {
258
- clearTimeout(timeout);
608
+ if (timeoutHandle) {
609
+ clearTimeout(timeoutHandle);
610
+ }
259
611
  await sleep(0);
260
612
  }
261
613
  }
@@ -268,3 +620,62 @@ export async function requestJson(
268
620
  code: "NETWORK_ERROR",
269
621
  });
270
622
  }
623
+
624
+ /**
625
+ * Execute an HTTP mutation request and auto-derive an idempotency key when missing.
626
+ *
627
+ * @param {string} url
628
+ * @param {{
629
+ * method?: "POST" | "PUT" | "PATCH" | "DELETE",
630
+ * headers?: Record<string, string>,
631
+ * body?: unknown,
632
+ * idempotencyKey?: string | null,
633
+ * operationName: string,
634
+ * timeoutMs?: number
635
+ * maxRetries?: number,
636
+ * retryDelayMs?: number
637
+ * allowEmptyBody?: boolean
638
+ * }} options
639
+ * @returns {Promise<any>}
640
+ */
641
+ export async function requestJsonMutation(
642
+ url,
643
+ {
644
+ method = "POST",
645
+ headers = {},
646
+ body,
647
+ idempotencyKey = null,
648
+ operationName,
649
+ timeoutMs,
650
+ maxRetries,
651
+ retryDelayMs,
652
+ allowEmptyBody = false,
653
+ } = {}
654
+ ) {
655
+ const normalizedMethod = String(method || "POST").trim().toUpperCase();
656
+ if (!isMutationVerb(normalizedMethod)) {
657
+ throw new SentinelayerApiError("requestJsonMutation requires a mutation HTTP method.", {
658
+ status: 400,
659
+ code: "INVALID_MUTATION_METHOD",
660
+ });
661
+ }
662
+ const resolvedOperation = String(operationName || "").trim();
663
+ if (!resolvedOperation) {
664
+ throw new SentinelayerApiError("requestJsonMutation requires an operationName.", {
665
+ status: 400,
666
+ code: "OPERATION_NAME_REQUIRED",
667
+ });
668
+ }
669
+ const resolvedIdempotencyKey =
670
+ String(idempotencyKey || "").trim() || createIdempotencyKeyForOperation(resolvedOperation);
671
+ return requestJson(url, {
672
+ method: normalizedMethod,
673
+ headers,
674
+ body,
675
+ idempotencyKey: resolvedIdempotencyKey,
676
+ timeoutMs,
677
+ maxRetries,
678
+ retryDelayMs,
679
+ allowEmptyBody,
680
+ });
681
+ }