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.
- package/README.md +16 -18
- package/package.json +7 -6
- package/src/agents/jules/config/definition.js +13 -62
- package/src/agents/jules/config/system-prompt.js +8 -1
- package/src/agents/jules/fix-cycle.js +12 -372
- package/src/agents/jules/loop.js +116 -26
- package/src/agents/jules/pulse.js +10 -327
- package/src/agents/jules/stream.js +13 -12
- package/src/agents/jules/swarm/orchestrator.js +3 -3
- package/src/agents/jules/swarm/sub-agent.js +6 -3
- package/src/agents/jules/tools/aidenid-email.js +189 -0
- package/src/agents/jules/tools/auth-audit.js +1187 -45
- package/src/agents/jules/tools/dispatch.js +25 -12
- package/src/agents/jules/tools/file-edit.js +2 -180
- package/src/agents/jules/tools/file-read.js +2 -100
- package/src/agents/jules/tools/glob.js +2 -168
- package/src/agents/jules/tools/grep.js +2 -228
- package/src/agents/jules/tools/path-guards.js +2 -161
- package/src/agents/jules/tools/runtime-audit.js +6 -2
- package/src/agents/jules/tools/shell.js +2 -383
- package/src/agents/persona-visuals.js +64 -0
- package/src/agents/shared-tools/dispatch-core.js +320 -0
- package/src/agents/shared-tools/file-edit.js +180 -0
- package/src/agents/shared-tools/file-read.js +100 -0
- package/src/agents/shared-tools/glob.js +168 -0
- package/src/agents/shared-tools/grep.js +228 -0
- package/src/agents/shared-tools/index.js +46 -0
- package/src/agents/shared-tools/path-guards.js +161 -0
- package/src/agents/shared-tools/shell.js +383 -0
- package/src/ai/aidenid.js +56 -7
- package/src/ai/client.js +45 -0
- package/src/ai/proxy.js +137 -0
- package/src/auth/gate.js +290 -16
- package/src/auth/http.js +450 -39
- package/src/auth/service.js +262 -47
- package/src/auth/session-store.js +475 -21
- package/src/cli.js +5 -0
- package/src/commands/audit.js +13 -8
- package/src/commands/auth.js +53 -9
- package/src/commands/omargate.js +10 -2
- package/src/commands/scan.js +10 -4
- package/src/commands/session.js +590 -0
- package/src/commands/spec.js +62 -0
- package/src/commands/watch.js +3 -2
- package/src/daemon/assignment-ledger.js +196 -0
- package/src/daemon/error-worker.js +599 -16
- package/src/daemon/fix-cycle.js +384 -0
- package/src/daemon/ingest-refresh.js +10 -9
- package/src/daemon/jira-lifecycle.js +135 -0
- package/src/daemon/pulse.js +327 -0
- package/src/daemon/scope-engine.js +1068 -0
- package/src/events/schema.js +190 -0
- package/src/interactive/index.js +18 -16
- package/src/legacy-cli.js +606 -37
- package/src/prompt/generator.js +19 -1
- package/src/review/ai-review.js +11 -1
- package/src/review/local-review.js +75 -19
- package/src/review/omargate-interactive.js +68 -0
- package/src/review/omargate-orchestrator.js +404 -0
- package/src/review/persona-prompts.js +296 -0
- package/src/review/scan-modes.js +48 -0
- package/src/scan/generator.js +1 -1
- package/src/session/agent-registry.js +352 -0
- package/src/session/daemon.js +801 -0
- package/src/session/paths.js +33 -0
- package/src/session/runtime-bridge.js +739 -0
- package/src/session/store.js +388 -0
- package/src/session/stream.js +325 -0
- package/src/spec/generator.js +100 -0
- package/src/telemetry/session-tracker.js +148 -32
- package/src/telemetry/sync.js +6 -2
- 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
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
585
|
+
isAbortError
|
|
586
|
+
? (abortReason === "CANCELLED" ? "Request cancelled." : "Request timed out.")
|
|
587
|
+
: (error instanceof Error ? error.message : String(error || "Request failed")),
|
|
238
588
|
{
|
|
239
|
-
status:
|
|
240
|
-
code:
|
|
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
|
-
|
|
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
|
+
}
|