@awak-app/simy-cli 0.5.72 → 0.5.74
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 +97 -0
- package/package.json +2 -2
- package/src/agent.js +191 -11
- package/src/backend-executable.js +21 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +14 -10
- package/src/desktop-host.js +9 -6
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +72 -0
- package/src/doctor.js +55 -0
- package/src/index.js +7 -0
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +38 -41
- package/src/orchestrator/outcome-monitor.js +58 -38
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +224 -36
- package/src/session-supervision/analysis-manager.js +45 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +11 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +36 -4
- package/src/sqm/diagnostics.js +1 -1
- package/src/sqm/evidence-client.js +23 -7
- package/src/sqm/history-store.js +9 -0
- package/src/sqm/index.js +104 -14
- package/src/sqm/proof.js +3 -23
- package/src/sqm/provider-diagnostics.js +35 -0
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +218 -112
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
package/src/sqm/bundle-store.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
|
|
7
7
|
import { verifyBundleIntegrity } from "./canonical.js";
|
|
8
8
|
import { validateKnowledgeBundle } from "./validation.js";
|
|
9
|
+
import { fetchSqmRead, sqmDiagnostic } from "./request-recovery.js";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
11
12
|
export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -36,6 +37,9 @@ export async function loadKnowledgeBundle({
|
|
|
36
37
|
fetchImpl = globalThis.fetch,
|
|
37
38
|
now = Date.now(),
|
|
38
39
|
publicKeys = publicKeysFromEnvironment(),
|
|
40
|
+
signal,
|
|
41
|
+
recoveryOptions,
|
|
42
|
+
onRecoveryEvent,
|
|
39
43
|
}) {
|
|
40
44
|
if (!organizationId) {
|
|
41
45
|
return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
|
|
@@ -50,7 +54,7 @@ export async function loadKnowledgeBundle({
|
|
|
50
54
|
Date.parse(cached.bundle.expires_at) > now;
|
|
51
55
|
if (!offline && endpoint && (!fresh || refresh || cached?.bundle.system_scope)) {
|
|
52
56
|
try {
|
|
53
|
-
const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl });
|
|
57
|
+
const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent });
|
|
54
58
|
if (fetched.notModified) {
|
|
55
59
|
if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
|
|
56
60
|
if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
|
|
@@ -82,9 +86,10 @@ export async function loadKnowledgeBundle({
|
|
|
82
86
|
});
|
|
83
87
|
return result(bundle, [], "cloud");
|
|
84
88
|
} catch (error) {
|
|
89
|
+
if (signal?.aborted) throw signal.reason;
|
|
85
90
|
if (error?.code === "incompatible_cli") throw error;
|
|
86
91
|
if (error?.fallbackAllowed !== false && cached && !cached.bundle.system_scope && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
|
|
87
|
-
return { ...result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable"), diagnostic: safeSqmDiagnostic(error, "knowledge_bundle", "knowledge_bundle_unavailable") };
|
|
92
|
+
return { ...result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable"), diagnostic: { ...safeSqmDiagnostic(error, "knowledge_bundle", "knowledge_bundle_unavailable"), ...sqmDiagnostic(error, "knowledge_bundle") } };
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
if (cached) {
|
|
@@ -167,7 +172,7 @@ function trustedDiscoveryEndpoint(endpoint) {
|
|
|
167
172
|
(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
|
|
175
|
+
async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent }) {
|
|
171
176
|
const url = new URL(endpoint);
|
|
172
177
|
url.searchParams.set("repository", repository);
|
|
173
178
|
url.searchParams.set("cli_version", cliVersion);
|
|
@@ -176,10 +181,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
176
181
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
177
182
|
if (organizationId) headers["x-simy-org-id"] = organizationId;
|
|
178
183
|
if (etag) headers["If-None-Match"] = etag;
|
|
179
|
-
const response = await
|
|
180
|
-
|
|
181
|
-
signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
|
|
182
|
-
});
|
|
184
|
+
const response = await fetchSqmRead(url, { ...recoveryOptions, headers, signal, fetchImpl,
|
|
185
|
+
requestTimeoutMs: SQM_BUNDLE_FETCH_TIMEOUT_MS, onRecoveryEvent });
|
|
183
186
|
const responseEtag = response.headers?.get?.("etag") || null;
|
|
184
187
|
const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
|
|
185
188
|
const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
|
|
@@ -201,6 +204,7 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
201
204
|
);
|
|
202
205
|
}
|
|
203
206
|
const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
|
|
207
|
+
error.status = response.status;
|
|
204
208
|
Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
|
|
205
209
|
requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
|
|
206
210
|
error.fallbackAllowed = response.status >= 500 && response.status <= 599;
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -21,6 +21,8 @@ export async function claudePluginFiles() {
|
|
|
21
21
|
files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
|
|
22
22
|
}
|
|
23
23
|
files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
|
|
24
|
+
files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
|
|
25
|
+
files.set("hooks/continuation-policy.mjs", await readFile(path.join(SOURCE, "hooks/continuation-policy.mjs"), "utf8"));
|
|
24
26
|
files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));
|
|
25
27
|
files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
|
|
26
28
|
files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
|
package/src/sqm/command.js
CHANGED
|
@@ -11,8 +11,9 @@ import { uploadRunEvidence } from "./evidence-client.js";
|
|
|
11
11
|
import { evaluateKnowledgeBundle } from "./checkers.js";
|
|
12
12
|
import { runSqmCheck, runSqmMinCheck } from "./index.js";
|
|
13
13
|
import { lintKnowledgeModule, summarizeReplay } from "./reliability.js";
|
|
14
|
-
import { ensureSqmSession } from "./session.js";
|
|
14
|
+
import { ensureSqmSession, sqmSessionScope } from "./session.js";
|
|
15
15
|
import { formatSqmCoverage } from "./report.js";
|
|
16
|
+
import { sqmDiagnostic } from "./request-recovery.js";
|
|
16
17
|
|
|
17
18
|
export async function runSqmCommand(argv, { cliVersion }) {
|
|
18
19
|
if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
|
|
@@ -24,14 +25,17 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
24
25
|
if (argv[0] === "replay") return await runReplayCommand(argv);
|
|
25
26
|
if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|lint|replay> [options]");
|
|
26
27
|
const webOrigin = resolveWebOrigin(option(argv, "--host"));
|
|
28
|
+
const authorizationLogger = argv.includes("--json")
|
|
29
|
+
? { log: message => console.warn(message), error: message => console.error(message) }
|
|
30
|
+
: console;
|
|
27
31
|
let session = await readSession(webOrigin);
|
|
28
32
|
let validSession = isSessionValid(session, Date.now(), webOrigin) ? session : null;
|
|
29
33
|
if (!validSession && !explicitSqmEnvironment(argv)) {
|
|
30
|
-
session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open") });
|
|
34
|
+
session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), logger: authorizationLogger });
|
|
31
35
|
validSession = session;
|
|
32
36
|
}
|
|
33
37
|
const organizationId = process.env.SIMY_SQM_ORGANIZATION_ID || validSession?.organization_id || validSession?.org_id;
|
|
34
|
-
|
|
38
|
+
let token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
|
|
35
39
|
const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
|
|
36
40
|
const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
|
|
37
41
|
if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
@@ -46,10 +50,32 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
46
50
|
endpoint,
|
|
47
51
|
accountId,
|
|
48
52
|
organizationId,
|
|
53
|
+
deviceId,
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
52
57
|
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
58
|
+
const onRecoveryEvent = async diagnostic => createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
59
|
+
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
|
|
60
|
+
common.bundleDependencies = { onRecoveryEvent };
|
|
61
|
+
common.onRecoveryEvent = onRecoveryEvent;
|
|
62
|
+
let authenticationAttempted = false;
|
|
63
|
+
if (validSession && !process.env.SIMY_SQM_TOKEN && !process.env.SIMY_ACCESS_TOKEN) {
|
|
64
|
+
const expectedScope = sqmSessionScope(validSession, webOrigin);
|
|
65
|
+
common.onAuthenticationRequired = async ({ rejectedToken }) => {
|
|
66
|
+
if (authenticationAttempted) {
|
|
67
|
+
const error = new Error("SQM authorization was already refreshed once; resolve the sign-in error before another check.");
|
|
68
|
+
error.code = "authorization_refresh_failed"; throw error;
|
|
69
|
+
}
|
|
70
|
+
authenticationAttempted = true;
|
|
71
|
+
validSession = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), rejectedToken, expectedScope, logger: authorizationLogger });
|
|
72
|
+
token = validSession.token;
|
|
73
|
+
common.token = token;
|
|
74
|
+
return { token, accountId: validSession.account_id || validSession.auth_user_id || validSession.device_id,
|
|
75
|
+
organizationId: validSession.organization_id || validSession.org_id, deviceId: validSession.device_id,
|
|
76
|
+
endpoint: process.env.SIMY_SQM_BUNDLE_URL || new URL("sqm/knowledge-bundle", validSession.api_base_url).href };
|
|
77
|
+
};
|
|
78
|
+
}
|
|
53
79
|
let stage = "min_check";
|
|
54
80
|
try {
|
|
55
81
|
if (argv[0] === "min-check") {
|
|
@@ -77,6 +103,12 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
77
103
|
stage = "full_check";
|
|
78
104
|
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
79
105
|
if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
|
|
106
|
+
if (result.recovery?.code === "knowledge_bundle_changed") {
|
|
107
|
+
// Keep the earlier min as history; never rewrite it into an apparent
|
|
108
|
+
// success for the new bundle.
|
|
109
|
+
await atomicJson(`${minPath}.previous.${minCheck.result_digest.slice(-16)}.json`, minCheck);
|
|
110
|
+
await atomicJson(minPath, result.min_check);
|
|
111
|
+
}
|
|
80
112
|
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
81
113
|
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
82
114
|
const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
|
|
@@ -107,7 +139,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
107
139
|
else printFull(result);
|
|
108
140
|
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
109
141
|
} catch (error) {
|
|
110
|
-
await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, stage) });
|
|
142
|
+
await save({ status: "failed", diagnostic: { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) } });
|
|
111
143
|
throw error;
|
|
112
144
|
}
|
|
113
145
|
}
|
package/src/sqm/diagnostics.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export function safeSqmDiagnostic(error, stage = "check", code = "sqm_check_failed") {
|
|
4
4
|
const value = error?.diagnostic || (error?.error && typeof error.error === "object" ? error.error : error) || {};
|
|
5
5
|
const machine = (candidate, fallback) => typeof candidate === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,79}$/.test(candidate) ? candidate : fallback;
|
|
6
|
-
const status = [value.status, value.errorStatus, value.httpStatus, value.error_status].find(candidate => Number.isInteger(candidate) && candidate >= 400 && candidate <= 599);
|
|
6
|
+
const status = [value.status, value.http_status, value.errorStatus, value.httpStatus, value.error_status].find(candidate => Number.isInteger(candidate) && candidate >= 400 && candidate <= 599);
|
|
7
7
|
const requestId = value.requestId ?? value.request_id;
|
|
8
8
|
return {
|
|
9
9
|
stage: machine(value.stage ?? value.errorStage ?? value.error_stage, stage),
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
2
2
|
export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
|
|
3
3
|
if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
let response;
|
|
5
|
+
try {
|
|
6
|
+
response = await fetchImpl(endpoint, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
9
|
+
body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
|
|
10
|
+
signal: AbortSignal.timeout(10_000),
|
|
11
|
+
});
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
const error = cause instanceof Error ? cause : new Error("SQM run evidence upload failed.");
|
|
14
|
+
error.stage = "evidence_upload";
|
|
15
|
+
error.retryable = false;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
10
18
|
const payload = await response.json().catch(() => null);
|
|
11
|
-
if (!response.ok)
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const error = new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
|
|
21
|
+
error.status = response.status;
|
|
22
|
+
error.stage = "evidence_upload";
|
|
23
|
+
error.retryable = false;
|
|
24
|
+
Object.assign(error, safeSqmDiagnostic({ status: response.status, code: payload?.error?.code,
|
|
25
|
+
requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
12
28
|
return { uploaded: true, ...payload };
|
|
13
29
|
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { diagnosticLogger } from "../diagnostic-log.js";
|
|
2
|
+
import { safeProviderDiagnostic } from "./provider-diagnostics.js";
|
|
1
3
|
import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
|
|
2
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
5
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -45,6 +47,10 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
45
47
|
try {
|
|
46
48
|
await writeFile(temp, JSON.stringify(item), { mode: 0o600 });
|
|
47
49
|
await rename(temp, target);
|
|
50
|
+
if (["failed", "unavailable"].includes(item.state)) await diagnosticLogger({ root, scope }).record({
|
|
51
|
+
kind: item.kind, stage: item.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider,
|
|
52
|
+
task_id: item.id, provider_diagnostic: item.providerDiagnostic,
|
|
53
|
+
http_status: item.errorStatus ?? item.httpStatus, at: item.finishedAt || item.startedAt });
|
|
48
54
|
} finally { await rm(temp, { force: true }); cache.delete(dir); }
|
|
49
55
|
const items = await list(scope);
|
|
50
56
|
for (const kind of ["analysis", "check"]) {
|
|
@@ -74,6 +80,9 @@ function metadata(entry) {
|
|
|
74
80
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
75
81
|
}
|
|
76
82
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
83
|
+
if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
|
|
84
|
+
const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
|
|
85
|
+
if (Object.keys(providerDiagnostic).length) item.providerDiagnostic = providerDiagnostic;
|
|
77
86
|
return item;
|
|
78
87
|
}
|
|
79
88
|
|
package/src/sqm/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { loadKnowledgeBundle } from "./bundle-store.js";
|
|
|
6
6
|
import { uploadRunEvidence } from "./evidence-client.js";
|
|
7
7
|
import { buildProof, finalizeMinCheck, repositoryIdentity, sameRepositoryIdentity, signProof, verifyMinCheck, verifySignedEvidence } from "./proof.js";
|
|
8
8
|
import { inspectRepository } from "./repository.js";
|
|
9
|
+
import { sqmDiagnostic } from "./request-recovery.js";
|
|
10
|
+
import { canonicalize } from "./canonical.js";
|
|
9
11
|
|
|
10
12
|
export function createSqmChecker({
|
|
11
13
|
cwd = process.cwd(),
|
|
@@ -21,6 +23,7 @@ export function createSqmChecker({
|
|
|
21
23
|
webOrigin,
|
|
22
24
|
historyRoot,
|
|
23
25
|
evidenceEndpoint,
|
|
26
|
+
signingKeyPath,
|
|
24
27
|
uploadRunEvidenceImpl = uploadRunEvidence,
|
|
25
28
|
inspectRepositoryImpl = inspectRepository,
|
|
26
29
|
loadKnowledgeBundleImpl = loadKnowledgeBundle,
|
|
@@ -31,34 +34,59 @@ export function createSqmChecker({
|
|
|
31
34
|
let pinnedLoad = null;
|
|
32
35
|
let pinnedRepository = null;
|
|
33
36
|
let latestMinCheck = null;
|
|
37
|
+
let pinnedSystemScope;
|
|
38
|
+
const onRecoveryEvent = async (diagnostic) => {
|
|
39
|
+
await createSqmCheckRecorder({ root: historyRoot,
|
|
40
|
+
scope: historyRoot ? { webOrigin, accountId, organizationId } : null,
|
|
41
|
+
phase: diagnostic.stage === "evidence_upload" ? "full" : "min", cwd, baseBranch })({ status: "failed", diagnostic });
|
|
42
|
+
await dependencies.onRecoveryEvent?.(diagnostic);
|
|
43
|
+
};
|
|
34
44
|
|
|
35
45
|
const checkCurrentRepository = async function(_attempt = null, { phase = "min" } = {}) {
|
|
36
46
|
let repository;
|
|
37
47
|
try {
|
|
38
48
|
repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
39
49
|
} catch (error) {
|
|
40
|
-
return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: safeSqmDiagnostic(error, "repository", "repository_inspection_failed") };
|
|
50
|
+
return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: { ...safeSqmDiagnostic(error, "repository", "repository_inspection_failed"), ...sqmDiagnostic(error, "repository") } };
|
|
41
51
|
}
|
|
42
52
|
if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
|
|
43
53
|
if (!pinnedLoad) {
|
|
44
54
|
pinnedRepository = repository.repository;
|
|
45
|
-
pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId,
|
|
55
|
+
pinnedLoad = loadKnowledgeBundleImpl({ ...dependencies, repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, onRecoveryEvent });
|
|
46
56
|
}
|
|
47
57
|
const loaded = await pinnedLoad;
|
|
48
|
-
if (!loaded.bundle)
|
|
58
|
+
if (!loaded.bundle) {
|
|
59
|
+
// A failed read must not poison the run permanently. A later explicit
|
|
60
|
+
// same-scope check or refreshed sign-in may try loading again.
|
|
61
|
+
pinnedLoad = null;
|
|
62
|
+
return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
|
|
63
|
+
}
|
|
64
|
+
if (pinnedSystemScope === undefined) pinnedSystemScope = loaded.bundle.system_scope || null;
|
|
65
|
+
else if (!sameSystemScope(pinnedSystemScope, loaded.bundle.system_scope || null)) {
|
|
66
|
+
return { ...unavailable("SQM system scope changed during recovery; authorize the current scope before a new check.", repository.repository),
|
|
67
|
+
diagnostic: { stage: "knowledge_loading", code: "authorization_scope_changed", retryable: false } };
|
|
68
|
+
}
|
|
49
69
|
try {
|
|
50
70
|
const evaluate = executeKnowledgeBundleImpl
|
|
51
71
|
? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
|
|
52
72
|
: evaluateKnowledgeBundleImpl;
|
|
53
73
|
if (phase === "full") {
|
|
54
|
-
if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository))
|
|
74
|
+
if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository)) ||
|
|
75
|
+
latestMinCheck.knowledge_bundle.digest !== loaded.bundle.integrity.digest) {
|
|
55
76
|
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
56
77
|
}
|
|
57
|
-
const
|
|
78
|
+
const fullEvaluation = await evaluate(loaded.bundle, repository);
|
|
79
|
+
const current = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
80
|
+
if (!sameRepositoryIdentity(repositoryIdentity(repository), repositoryIdentity(current))) {
|
|
81
|
+
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
82
|
+
}
|
|
83
|
+
const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: fullEvaluation, organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId, signingKeyPath });
|
|
58
84
|
try {
|
|
59
85
|
full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
|
|
60
86
|
} catch (error) {
|
|
61
|
-
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}
|
|
87
|
+
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
|
|
88
|
+
full.upload.diagnostic = { ...safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed"), ...sqmDiagnostic(error, "evidence_upload") };
|
|
89
|
+
await onRecoveryEvent(full.upload.diagnostic);
|
|
62
90
|
full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
|
|
63
91
|
}
|
|
64
92
|
return full;
|
|
@@ -66,16 +94,32 @@ export function createSqmChecker({
|
|
|
66
94
|
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
67
95
|
return toLegacyCheck(latestMinCheck, loaded);
|
|
68
96
|
} catch (error) {
|
|
69
|
-
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle),
|
|
97
|
+
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`], diagnostic: { ...safeSqmDiagnostic(error, `${phase}_check`, "sqm_execution_failed"), ...sqmDiagnostic(error, `${phase}_check`) } };
|
|
70
98
|
}
|
|
71
99
|
};
|
|
72
|
-
|
|
100
|
+
const checker = async (attempt, options = {}) => {
|
|
73
101
|
const save = createSqmCheckRecorder({ root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
|
|
74
102
|
try {
|
|
75
103
|
const result = await checkCurrentRepository(attempt, options);
|
|
76
104
|
await save(result); return result;
|
|
77
|
-
} catch (error) {
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const stage = options.phase === "full" ? "full_check" : "min_check";
|
|
107
|
+
const diagnostic = { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) };
|
|
108
|
+
if (diagnostic.retryable) pinnedLoad = null;
|
|
109
|
+
await save({ status: "failed", diagnostic }); throw error;
|
|
110
|
+
}
|
|
78
111
|
};
|
|
112
|
+
checker.updateSession = (next) => {
|
|
113
|
+
for (const [field, current] of Object.entries({ accountId, organizationId, deviceId, endpoint, webOrigin })) {
|
|
114
|
+
if (current && next?.[field] !== current) {
|
|
115
|
+
const error = new Error(`SQM session changed its ${field}; the original scope cannot be resumed with it.`);
|
|
116
|
+
error.code = "authorization_scope_changed"; throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!next?.token) throw new Error("An authenticated same-scope SQM session is required.");
|
|
120
|
+
if (next.token !== token) { token = next.token; pinnedLoad = null; }
|
|
121
|
+
};
|
|
122
|
+
return checker;
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
export async function runSqmMinCheck(options) {
|
|
@@ -89,15 +133,52 @@ export async function runSqmCheck(options) {
|
|
|
89
133
|
const context = await loadContext(options);
|
|
90
134
|
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
|
|
91
135
|
verifyMinCheck(options.minCheck);
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
if (options.minCheck.organization_id !== options.organizationId || context.loaded.bundle.organization_id !== options.organizationId) {
|
|
137
|
+
throw scopeError("SQM organization changed after min-check; authorize the original organization before resuming.");
|
|
138
|
+
}
|
|
139
|
+
if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(candidateError("Repository identity changed after min-check; run `simy sqm min-check` again."), { stage: "identity_validation" });
|
|
140
|
+
if (!sameSystemScope(options.minCheck.knowledge_bundle.system_scope || null, context.loaded.bundle.system_scope || null)) {
|
|
141
|
+
throw scopeError("SQM system scope changed after min-check; authorize the current scope before a new check.");
|
|
142
|
+
}
|
|
143
|
+
const evaluate = options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle;
|
|
144
|
+
let minCheck = options.minCheck;
|
|
145
|
+
if (minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) {
|
|
146
|
+
await options.onRecoveryEvent?.({ stage: "full_check", code: "knowledge_bundle_changed", retryable: true });
|
|
147
|
+
minCheck = await buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle,
|
|
148
|
+
evaluation: await evaluate(context.loaded.bundle, context.repository), organizationId: options.organizationId, cliVersion: options.cliVersion });
|
|
149
|
+
const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
150
|
+
if (!sameRepositoryIdentity(minCheck.repository_identity, repositoryIdentity(current))) throw new Error("Repository identity changed during min-check recovery; run `simy sqm min-check` again.");
|
|
151
|
+
}
|
|
152
|
+
const evaluation = await evaluate(context.loaded.bundle, context.repository);
|
|
153
|
+
const verifiedRepository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
154
|
+
if (!sameRepositoryIdentity(repositoryIdentity(context.repository), repositoryIdentity(verifiedRepository))) {
|
|
155
|
+
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
156
|
+
}
|
|
157
|
+
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
|
|
158
|
+
if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
|
|
159
|
+
return result;
|
|
96
160
|
}
|
|
97
161
|
|
|
98
162
|
async function loadContext(options) {
|
|
99
163
|
const repository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
100
|
-
const
|
|
164
|
+
const rejectedToken = options.token;
|
|
165
|
+
const originalIdentity = repositoryIdentity(repository);
|
|
166
|
+
const scope = Object.fromEntries(["accountId", "organizationId", "deviceId", "endpoint"].map(field => [field, options[field]]));
|
|
167
|
+
const load = (token) => (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ ...(options.bundleDependencies || {}),
|
|
168
|
+
repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh,
|
|
169
|
+
token, endpoint: scope.endpoint, accountId: scope.accountId, organizationId: scope.organizationId });
|
|
170
|
+
let loaded = await load(rejectedToken);
|
|
171
|
+
if (!loaded.bundle && loaded.diagnostic?.http_status === 401 && options.onAuthenticationRequired && !options.offline) {
|
|
172
|
+
await options.onRecoveryEvent?.(loaded.diagnostic);
|
|
173
|
+
const next = await options.onAuthenticationRequired({ rejectedToken, ...scope });
|
|
174
|
+
for (const field of ["accountId", "organizationId", "deviceId", "endpoint"]) {
|
|
175
|
+
if (scope[field] && next?.[field] !== scope[field]) throw scopeError(`SQM authorization changed its ${field}; do not resume another scope.`);
|
|
176
|
+
}
|
|
177
|
+
if (!next?.token || next.token === rejectedToken) throw scopeError("SQM authorization did not replace the rejected token.");
|
|
178
|
+
const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
179
|
+
if (!sameRepositoryIdentity(originalIdentity, repositoryIdentity(current))) throw candidateError("Repository identity changed while waiting for SQM authorization; begin a new check against the current candidate.");
|
|
180
|
+
loaded = await load(next.token);
|
|
181
|
+
}
|
|
101
182
|
return { repository, loaded };
|
|
102
183
|
}
|
|
103
184
|
|
|
@@ -159,3 +240,12 @@ function unavailable(warning, repository = null) {
|
|
|
159
240
|
}
|
|
160
241
|
|
|
161
242
|
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
243
|
+
|
|
244
|
+
function sameSystemScope(left, right) {
|
|
245
|
+
const normalized = (scope) => scope ? { organization_id: scope.organization_id,
|
|
246
|
+
repository: scope.repository?.toLowerCase(), graph_revision: scope.graph_revision,
|
|
247
|
+
repositories: [...(scope.repositories || [])].map((repository) => repository.toLowerCase()).sort() } : null;
|
|
248
|
+
return canonicalize(normalized(left)) === canonicalize(normalized(right));
|
|
249
|
+
}
|
|
250
|
+
function scopeError(message) { const error = new Error(message); error.code = "authorization_scope_changed"; return error; }
|
|
251
|
+
function candidateError(message) { const error = new Error(message); error.code = "repository_identity_changed"; return error; }
|
package/src/sqm/proof.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash,
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createHash, createPublicKey, randomUUID, sign, verify } from "node:crypto";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
import { canonicalize } from "./canonical.js";
|
|
6
|
+
import { loadDeviceSigningIdentity } from "../device-signing-identity.js";
|
|
7
7
|
|
|
8
8
|
export function repositoryIdentity(repository) {
|
|
9
9
|
return {
|
|
@@ -98,7 +98,7 @@ export async function signProof(proof, {
|
|
|
98
98
|
issuedAt = new Date().toISOString(),
|
|
99
99
|
} = {}) {
|
|
100
100
|
if (!deviceId) throw new Error("A trusted SIMY CLI device identity is required to sign SQM proof.");
|
|
101
|
-
const identity = await
|
|
101
|
+
const identity = await loadDeviceSigningIdentity(keyPath, { create: true });
|
|
102
102
|
const proofDigest = verifyProof(proof);
|
|
103
103
|
const signature = sign(null, Buffer.from(proofDigest.slice("sha256:".length), "hex"), identity.privateKey).toString("base64");
|
|
104
104
|
return {
|
|
@@ -133,26 +133,6 @@ export function verifySignedEvidence(proof, evidence) {
|
|
|
133
133
|
return { proof_digest: proofDigest, key_id: keyId, valid: true };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function loadOrCreateSigningIdentity(location) {
|
|
137
|
-
try {
|
|
138
|
-
const parsed = JSON.parse(await readFile(location, "utf8"));
|
|
139
|
-
const privateKey = createPrivateKey({ key: Buffer.from(parsed.private_key, "base64"), format: "der", type: "pkcs8" });
|
|
140
|
-
const publicKeyDer = Buffer.from(parsed.public_key, "base64");
|
|
141
|
-
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
142
|
-
if (parsed.key_id !== keyId) throw new Error("stored key ID mismatch");
|
|
143
|
-
return { privateKey, publicKeyDer, keyId };
|
|
144
|
-
} catch (error) {
|
|
145
|
-
if (error?.code !== "ENOENT") throw new Error(`SQM signing identity is invalid: ${error.message}`);
|
|
146
|
-
}
|
|
147
|
-
const pair = generateKeyPairSync("ed25519");
|
|
148
|
-
const privateKeyDer = pair.privateKey.export({ format: "der", type: "pkcs8" });
|
|
149
|
-
const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
|
|
150
|
-
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
151
|
-
await mkdir(path.dirname(location), { recursive: true, mode: 0o700 });
|
|
152
|
-
await writeFile(location, `${JSON.stringify({ algorithm: "ed25519", key_id: keyId, private_key: privateKeyDer.toString("base64"), public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
153
|
-
return { privateKey: pair.privateKey, publicKeyDer, keyId };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
136
|
function taggedDigest(value) {
|
|
157
137
|
return `sha256:${createHash("sha256").update(canonicalize(value), "utf8").digest("hex")}`;
|
|
158
138
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SUBTYPES = new Set(["success", "error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries", "unknown"]);
|
|
2
|
+
const SIGNALS = new Set(["SIGTERM", "SIGKILL", "SIGABRT", "SIGSEGV", "SIGINT", "SIGHUP", "SIGPIPE"]);
|
|
3
|
+
|
|
4
|
+
// Reuse at every persistence boundary. Provider envelopes may contain prompts,
|
|
5
|
+
// paths, request IDs or credentials; none of their free-form text is retained.
|
|
6
|
+
export function safeProviderDiagnostic(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
8
|
+
const result = {};
|
|
9
|
+
if (typeof value.subtype === "string") result.subtype = SUBTYPES.has(value.subtype) ? value.subtype : "unknown";
|
|
10
|
+
if (Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255) result.exitCode = value.exitCode;
|
|
11
|
+
if (SIGNALS.has(value.signal)) result.signal = value.signal;
|
|
12
|
+
if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
|
|
13
|
+
if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
|
|
14
|
+
if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PROVIDER_FAILURE_HINTS = Object.freeze({
|
|
19
|
+
provider_turn_limit_reached: "The local AI reached its turn limit before completing structured output. Retry this analysis; if it repeats, update the local AI app or report this error code.",
|
|
20
|
+
provider_structured_output_exhausted: "The local AI exhausted its structured-output repair attempts. Retry the analysis; if it repeats, report this error code and update the local AI app.",
|
|
21
|
+
provider_budget_exceeded: "The local AI stopped at its configured budget. Check that provider's budget before retrying.",
|
|
22
|
+
provider_execution_error: "The local AI reported an execution error. Check its local status and retry the analysis.",
|
|
23
|
+
provider_invalid_json: "The local AI returned invalid JSON. Retry the analysis; if it repeats, update the local AI app.",
|
|
24
|
+
provider_invalid_envelope: "The local AI returned an unsupported response envelope. Update the local AI app and retry.",
|
|
25
|
+
provider_missing_structured_output: "The local AI returned no schema-constrained output. Update the local AI app and retry this analysis.",
|
|
26
|
+
provider_non_object_output: "The local AI returned a structured value that was not an object. Retry this analysis; if it repeats, report this error code.",
|
|
27
|
+
provider_process_failed: "The local AI process exited before completing the review. Check the recorded exit code and the local AI app, then retry.",
|
|
28
|
+
provider_spawn_failed: "The local AI process could not start. Check its installation and local execution permissions.",
|
|
29
|
+
provider_output_too_large: "The local AI response exceeded the output-size limit. Reduce the evidence size before retrying.",
|
|
30
|
+
provider_network_unavailable: "The local AI could not reach its provider. Check its network or proxy connection, then retry.",
|
|
31
|
+
provider_schema_rejected: "The local AI rejected the output schema. Update the local AI app; if it repeats, report this error code.",
|
|
32
|
+
provider_rate_limited: "The local AI provider is rate limited. Wait for its limit to reset before retrying.",
|
|
33
|
+
provider_tool_use_blocked: "The local AI attempted to use a tool during a read-only analysis. The analysis was stopped.",
|
|
34
|
+
analysis_cancelled: "The local AI analysis was cancelled.",
|
|
35
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const TRANSIENT_STATUSES = new Set([502, 503, 504]);
|
|
2
|
+
const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENETUNREACH", "EAI_AGAIN", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
|
|
3
|
+
|
|
4
|
+
export function sqmDiagnostic(error, stage = "unknown") {
|
|
5
|
+
const status = error?.http_status ?? error?.status;
|
|
6
|
+
const timeout = error?.name === "TimeoutError" || error?.code === "ETIMEDOUT";
|
|
7
|
+
const network = (error instanceof TypeError && /fetch failed|failed to fetch|network request failed/i.test(error.message)) ||
|
|
8
|
+
NETWORK_CODES.has(error?.code) || NETWORK_CODES.has(error?.cause?.code);
|
|
9
|
+
const safeCode = typeof error?.code === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.code) ? error.code : null;
|
|
10
|
+
return {
|
|
11
|
+
stage: typeof error?.stage === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.stage) ? error.stage : stage,
|
|
12
|
+
code: safeCode || (status === 401 ? "sign_in_required" : status === 403 ? "access_denied" :
|
|
13
|
+
status === 409 ? "scope_unresolved" : TRANSIENT_STATUSES.has(status) ? "gateway_unavailable" :
|
|
14
|
+
timeout ? "request_timeout" : network ? "network_unavailable" : "check_failed"),
|
|
15
|
+
...(Number.isInteger(status) && status >= 400 && status <= 599 ? { http_status: status } : {}),
|
|
16
|
+
retryable: error?.retryable !== false && (TRANSIENT_STATUSES.has(status) || timeout || network),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// This deliberately exposes only GET recovery. Uncertain POST success must be
|
|
21
|
+
// reconciled with a server idempotency contract before it may be replayed.
|
|
22
|
+
export async function fetchSqmRead(url, {
|
|
23
|
+
headers, signal, fetchImpl = globalThis.fetch,
|
|
24
|
+
timeoutMs = 45_000, requestTimeoutMs = 15_000, maxAttempts = 3,
|
|
25
|
+
sleep = abortableSleep, now = Date.now, onRecoveryEvent = async () => {},
|
|
26
|
+
} = {}) {
|
|
27
|
+
const totalLimit = boundedInteger(timeoutMs, 45_000);
|
|
28
|
+
const requestLimit = boundedInteger(requestTimeoutMs, 15_000);
|
|
29
|
+
const deadline = now() + totalLimit;
|
|
30
|
+
const deadlineSignal = AbortSignal.timeout(totalLimit);
|
|
31
|
+
const attempts = boundedInteger(maxAttempts, 3);
|
|
32
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
deadlineSignal.throwIfAborted();
|
|
35
|
+
const remaining = deadline - now();
|
|
36
|
+
if (remaining <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
|
|
37
|
+
const attemptSignal = AbortSignal.any([
|
|
38
|
+
deadlineSignal, AbortSignal.timeout(Math.min(requestLimit, remaining)),
|
|
39
|
+
...(signal ? [signal] : []),
|
|
40
|
+
]);
|
|
41
|
+
try {
|
|
42
|
+
const response = await withAbort(fetchImpl(url, { method: "GET", headers, signal: attemptSignal }), attemptSignal);
|
|
43
|
+
if (!TRANSIENT_STATUSES.has(response.status)) return response;
|
|
44
|
+
const error = new Error(`SQM Cloud returned HTTP ${response.status}.`);
|
|
45
|
+
error.status = response.status;
|
|
46
|
+
if (attempt === attempts) return response;
|
|
47
|
+
await response.body?.cancel().catch(() => {});
|
|
48
|
+
throw error;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (signal?.aborted) throw signal.reason;
|
|
51
|
+
const diagnostic = sqmDiagnostic(error, "knowledge_loading");
|
|
52
|
+
if (!diagnostic.retryable || attempt === attempts || deadlineSignal.aborted) throw error;
|
|
53
|
+
await onRecoveryEvent({ ...diagnostic, attempt, recovery: "retrying" });
|
|
54
|
+
const delay = Math.min(250 * 2 ** (attempt - 1), Math.max(0, deadline - now()));
|
|
55
|
+
if (delay <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
|
|
56
|
+
await sleep(delay, AbortSignal.any([deadlineSignal, ...(signal ? [signal] : [])]));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedInteger(value, maximum) {
|
|
62
|
+
return Number.isFinite(value) && value > 0 ? Math.min(maximum, Math.max(1, Math.floor(value))) : maximum;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function withAbort(promise, signal) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
68
|
+
if (signal.aborted) abort();
|
|
69
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
70
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function abortableSleep(milliseconds, signal) {
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const finish = () => { signal.removeEventListener("abort", abort); resolve(); };
|
|
78
|
+
const timer = setTimeout(finish, milliseconds);
|
|
79
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
80
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
81
|
+
});
|
|
82
|
+
}
|