@awak-app/simy-cli 0.5.70 → 0.5.72
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 +17 -3
- package/package.json +1 -1
- package/src/agent.js +14 -1
- package/src/backend-executable.js +24 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +6 -2
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-host.js +11 -2
- package/src/index.js +11 -1
- package/src/orchestrator/evidence.js +20 -19
- package/src/orchestrator/loop.js +30 -2
- package/src/orchestrator/outcome-monitor.js +42 -11
- package/src/orchestrator/recovery.js +3 -0
- package/src/runner.js +20 -0
- package/src/runtime-diagnostics.js +77 -0
- package/src/sqm/bundle-store.js +4 -1
- package/src/sqm/command.js +7 -1
- package/src/sqm/diagnostics.js +23 -0
- package/src/sqm/evidence-client.js +2 -1
- package/src/sqm/history-store.js +17 -5
- package/src/sqm/index.js +20 -11
- package/src/sqm/proof.js +1 -0
- package/src/sqm/report.js +9 -2
- package/src/sqm/review-worker.js +142 -45
package/src/sqm/bundle-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
1
2
|
import { createHash, createPublicKey } from "node:crypto";
|
|
2
3
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
@@ -83,7 +84,7 @@ export async function loadKnowledgeBundle({
|
|
|
83
84
|
} catch (error) {
|
|
84
85
|
if (error?.code === "incompatible_cli") throw error;
|
|
85
86
|
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");
|
|
86
|
-
return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable");
|
|
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") };
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
if (cached) {
|
|
@@ -200,6 +201,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
200
201
|
);
|
|
201
202
|
}
|
|
202
203
|
const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
|
|
204
|
+
Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
|
|
205
|
+
requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
|
|
203
206
|
error.fallbackAllowed = response.status >= 500 && response.status <= 599;
|
|
204
207
|
throw error;
|
|
205
208
|
}
|
package/src/sqm/command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
1
2
|
import { createSqmCheckRecorder } from "./history-store.js";
|
|
2
3
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
@@ -49,6 +50,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
49
50
|
|
|
50
51
|
const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
51
52
|
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
53
|
+
let stage = "min_check";
|
|
52
54
|
try {
|
|
53
55
|
if (argv[0] === "min-check") {
|
|
54
56
|
const result = await runSqmMinCheck(common);
|
|
@@ -60,6 +62,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
60
62
|
return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
stage = "min_result_read";
|
|
63
66
|
const minPath = option(argv, "--min-result") || defaultMinResultPath();
|
|
64
67
|
let minCheck;
|
|
65
68
|
try {
|
|
@@ -71,11 +74,13 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
71
74
|
await atomicJson(minPath, minCheck);
|
|
72
75
|
}
|
|
73
76
|
if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
|
|
77
|
+
stage = "full_check";
|
|
74
78
|
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
75
79
|
if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
|
|
76
80
|
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
77
81
|
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
78
82
|
const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
|
|
83
|
+
stage = "artifact_write";
|
|
79
84
|
await Promise.all([
|
|
80
85
|
atomicJson(proofPath, result.proof),
|
|
81
86
|
atomicJson(evidencePath, result.signed_evidence),
|
|
@@ -87,6 +92,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
87
92
|
let upload = { uploaded: false, reason: "disabled" };
|
|
88
93
|
if (!argv.includes("--no-upload")) {
|
|
89
94
|
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
95
|
+
stage = "evidence_upload";
|
|
90
96
|
upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
|
|
91
97
|
}
|
|
92
98
|
result.upload = upload;
|
|
@@ -101,7 +107,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
101
107
|
else printFull(result);
|
|
102
108
|
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
103
109
|
} catch (error) {
|
|
104
|
-
await save({ status: "failed" });
|
|
110
|
+
await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, stage) });
|
|
105
111
|
throw error;
|
|
106
112
|
}
|
|
107
113
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Persist machine diagnostics only. Provider/server response text can contain
|
|
2
|
+
// repository content or credentials and must never enter durable check history.
|
|
3
|
+
export function safeSqmDiagnostic(error, stage = "check", code = "sqm_check_failed") {
|
|
4
|
+
const value = error?.diagnostic || (error?.error && typeof error.error === "object" ? error.error : error) || {};
|
|
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);
|
|
7
|
+
const requestId = value.requestId ?? value.request_id;
|
|
8
|
+
return {
|
|
9
|
+
stage: machine(value.stage ?? value.errorStage ?? value.error_stage, stage),
|
|
10
|
+
code: machine(value.code ?? value.errorCode ?? value.error_code, code),
|
|
11
|
+
...(Number.isInteger(status) && status >= 400 && status <= 599 ? { status } : {}),
|
|
12
|
+
...(typeof requestId === "string" && /^(?:[a-f0-9]{16,64}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(requestId) ? { requestId } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatSqmDiagnostic(value) {
|
|
17
|
+
const hint = value.status === 401 ? "Reconnect your SIMY session."
|
|
18
|
+
: value.status === 403 ? "Check access to SQM in the selected organization."
|
|
19
|
+
: value.code === "repository_scope_unresolved" ? "Connect this repository to a system in SIMY."
|
|
20
|
+
: value.code === "knowledge_bundle_changed" || value.code === "repository_identity_changed" ? "Run min-check again for the current repository and knowledge version."
|
|
21
|
+
: "Inspect the command diagnostics for this stage before retrying.";
|
|
22
|
+
return `${value.stage} · ${value.code}${value.status ? ` · HTTP ${value.status}` : ""}${value.requestId ? ` · Request ${value.requestId}` : ""}\n${hint}`;
|
|
23
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
1
2
|
export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
|
|
2
3
|
if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
|
|
3
4
|
const response = await fetchImpl(endpoint, {
|
|
@@ -7,6 +8,6 @@ export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence
|
|
|
7
8
|
signal: AbortSignal.timeout(10_000),
|
|
8
9
|
});
|
|
9
10
|
const payload = await response.json().catch(() => null);
|
|
10
|
-
if (!response.ok) throw new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
|
|
11
|
+
if (!response.ok) throw Object.assign(new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`), safeSqmDiagnostic({ status: response.status, code: payload?.error?.code, requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
|
|
11
12
|
return { uploaded: true, ...payload };
|
|
12
13
|
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { basename, join } from "node:path";
|
|
4
|
-
import { formatSqmCoverage } from "./report.js";
|
|
5
|
+
import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
|
|
5
6
|
import { defaultSessionRoot } from "../session-store.js";
|
|
6
7
|
|
|
7
8
|
const LIMIT = 100;
|
|
@@ -69,9 +70,10 @@ function metadata(entry) {
|
|
|
69
70
|
if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
|
|
70
71
|
if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
|
|
71
72
|
const item = {};
|
|
72
|
-
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "webUrl"]) {
|
|
73
|
+
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl"]) {
|
|
73
74
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
74
75
|
}
|
|
76
|
+
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
75
77
|
return item;
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -94,15 +96,25 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, on
|
|
|
94
96
|
const repository = identity?.repository || result.repository;
|
|
95
97
|
const outcome = proof?.outcome.status || result.min_check?.status || result.status;
|
|
96
98
|
const state = outcome === "unavailable" ? "unavailable" : ["failed", "preliminary_failed"].includes(outcome) ? "failed" : ["requires_human", "requires_post_deploy"].includes(outcome) ? "verification_pending" : "completed";
|
|
99
|
+
const failure = result.upload?.diagnostic ? safeSqmDiagnostic(result.upload.diagnostic, "evidence_upload", "evidence_upload_failed") : ["failed", "unavailable"].includes(state) && (result.diagnostic || result.error || result.errorCode || !identity)
|
|
100
|
+
? safeSqmDiagnostic(result, phase, state === "unavailable" ? "sqm_check_unavailable" : "sqm_check_failed") : null;
|
|
101
|
+
const minCheck = result.min_check || result;
|
|
102
|
+
const minWarnings = !proof && Array.isArray(minCheck.matched_modules)
|
|
103
|
+
? sqmCoverageWarnings({ moduleCount: minCheck.matched_modules.length,
|
|
104
|
+
scenarioCount: Array.isArray(minCheck.full_check_plan) ? minCheck.full_check_plan.length :
|
|
105
|
+
minCheck.matched_modules.reduce((total, module) => total + (module.scenario_ids?.length || 0), 0),
|
|
106
|
+
noSystemKnowledge: minCheck.matched_modules.length === 0 && minCheck.coverage_reasons?.includes("no_system_knowledge") === true,
|
|
107
|
+
outcomeStatus: minCheck.status }) : [];
|
|
97
108
|
const summary = proof ? [`Full check: ${proof.outcome.status}`, formatSqmCoverage(proof), `Evidence upload: ${result.upload?.uploaded ? "uploaded" : "kept locally"}`].join("\n")
|
|
98
|
-
: state === "unavailable" ? "SQM could not obtain a verified result.
|
|
99
|
-
: state === "failed" && !identity ? "The command did not finish.
|
|
109
|
+
: state === "unavailable" ? "SQM could not obtain a verified result. See the recorded diagnostics below."
|
|
110
|
+
: state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
|
|
100
111
|
: `Min-check: ${result.min_check?.status || result.status} · ${(result.preliminary_findings || result.min_check?.preliminary_findings)?.length || 0} preliminary finding(s) · ${(result.matched_modules || result.min_check?.matched_modules)?.length || 0} matched module(s).\nThis is a preflight; run the full check for signed verification.`;
|
|
101
112
|
try {
|
|
102
113
|
await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, finishedAt: new Date().toISOString(), repository,
|
|
103
114
|
label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
|
|
104
115
|
instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
|
|
105
|
-
result: summary
|
|
116
|
+
result: [summary, ...minWarnings].join("\n"),
|
|
117
|
+
...(failure ? { error: formatSqmDiagnostic(failure), errorCode: failure.code, errorStage: failure.stage, errorStatus: failure.status, requestId: failure.requestId } : {}) });
|
|
106
118
|
} catch { onError(); }
|
|
107
119
|
};
|
|
108
120
|
}
|
package/src/sqm/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sqmCoverageWarnings } from "./report.js";
|
|
2
|
+
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
1
3
|
import { createSqmCheckRecorder } from "./history-store.js";
|
|
2
4
|
import { evaluateKnowledgeBundle } from "./checkers.js";
|
|
3
5
|
import { loadKnowledgeBundle } from "./bundle-store.js";
|
|
@@ -35,7 +37,7 @@ export function createSqmChecker({
|
|
|
35
37
|
try {
|
|
36
38
|
repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
37
39
|
} catch (error) {
|
|
38
|
-
return unavailable(`SQM repository inspection failed: ${message(error)}`);
|
|
40
|
+
return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: safeSqmDiagnostic(error, "repository", "repository_inspection_failed") };
|
|
39
41
|
}
|
|
40
42
|
if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
|
|
41
43
|
if (!pinnedLoad) {
|
|
@@ -43,7 +45,7 @@ export function createSqmChecker({
|
|
|
43
45
|
pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, ...dependencies });
|
|
44
46
|
}
|
|
45
47
|
const loaded = await pinnedLoad;
|
|
46
|
-
if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings };
|
|
48
|
+
if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
|
|
47
49
|
try {
|
|
48
50
|
const evaluate = executeKnowledgeBundleImpl
|
|
49
51
|
? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
|
|
@@ -56,7 +58,7 @@ export function createSqmChecker({
|
|
|
56
58
|
try {
|
|
57
59
|
full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
|
|
58
60
|
} catch (error) {
|
|
59
|
-
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}
|
|
61
|
+
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}`, diagnostic: safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed") };
|
|
60
62
|
full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
|
|
61
63
|
}
|
|
62
64
|
return full;
|
|
@@ -64,7 +66,7 @@ export function createSqmChecker({
|
|
|
64
66
|
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
65
67
|
return toLegacyCheck(latestMinCheck, loaded);
|
|
66
68
|
} catch (error) {
|
|
67
|
-
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
|
|
69
|
+
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), diagnostic: safeSqmDiagnostic(error, phase, "sqm_execution_failed"), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
|
|
68
70
|
}
|
|
69
71
|
};
|
|
70
72
|
return async (attempt, options = {}) => {
|
|
@@ -72,23 +74,23 @@ export function createSqmChecker({
|
|
|
72
74
|
try {
|
|
73
75
|
const result = await checkCurrentRepository(attempt, options);
|
|
74
76
|
await save(result); return result;
|
|
75
|
-
} catch (error) { await save({ status: "failed" }); throw error; }
|
|
77
|
+
} catch (error) { await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, options.phase || "min") }); throw error; }
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export async function runSqmMinCheck(options) {
|
|
80
82
|
const context = await loadContext(options);
|
|
81
|
-
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
|
|
83
|
+
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
|
|
82
84
|
const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
|
|
83
85
|
return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export async function runSqmCheck(options) {
|
|
87
89
|
const context = await loadContext(options);
|
|
88
|
-
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
|
|
90
|
+
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
|
|
89
91
|
verifyMinCheck(options.minCheck);
|
|
90
|
-
if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw new Error("Repository identity changed after min-check; run `simy sqm min-check` again.");
|
|
91
|
-
if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle.");
|
|
92
|
+
if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(new Error("Repository identity changed after min-check; run `simy sqm min-check` again."), { code: "repository_identity_changed", stage: "identity_validation" });
|
|
93
|
+
if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw Object.assign(new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle."), { code: "knowledge_bundle_changed", stage: "identity_validation" });
|
|
92
94
|
const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
|
|
93
95
|
return buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck: options.minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
|
|
94
96
|
}
|
|
@@ -113,6 +115,13 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
|
|
|
113
115
|
preliminary_findings: evaluation.findings,
|
|
114
116
|
matched_modules: evaluation.modules.map((module) => ({ module_id: module.module_id, module_version: module.module_version, state_model_ids: module.state_models.map((model) => model.id), transition_ids: module.state_models.flatMap((model) => model.transitions.map((transition) => transition.id)), stressor_ids: module.stressors.map((stressor) => stressor.id), scenario_ids: module.scenarios.map((scenario) => scenario.id) })),
|
|
115
117
|
full_check_plan: plan,
|
|
118
|
+
coverage_reasons: [
|
|
119
|
+
...(evaluation.modules.length === 0 ? ["no_applicable_rules"] : []),
|
|
120
|
+
...(bundle.modules?.length === 0 ? ["no_system_knowledge"] : []),
|
|
121
|
+
],
|
|
122
|
+
warnings: sqmCoverageWarnings({ moduleCount: evaluation.modules.length, scenarioCount: plan.length,
|
|
123
|
+
noSystemKnowledge: bundle.modules?.length === 0,
|
|
124
|
+
outcomeStatus: evaluation.findings.some(finding => finding.enforcement === "blocking") ? "preliminary_failed" : "passed" }),
|
|
116
125
|
});
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -133,12 +142,12 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
|
|
|
133
142
|
proof,
|
|
134
143
|
signed_evidence: signedEvidence,
|
|
135
144
|
signature_verification: verification,
|
|
136
|
-
warnings:
|
|
145
|
+
warnings: sqmCoverageWarnings({ moduleCount: evaluation.modules.length, scenarioCount: evaluation.modules.reduce((count, module) => count + module.scenarios.length, 0), noSystemKnowledge: bundle.modules?.length === 0, outcomeStatus: proof.outcome.status }),
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
function toLegacyCheck(minCheck, loaded) {
|
|
141
|
-
return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: loaded.warnings, bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
|
|
150
|
+
return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: [...loaded.warnings, ...(minCheck.warnings || [])], bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
function bundleIdentity(bundle) {
|
package/src/sqm/proof.js
CHANGED
|
@@ -73,6 +73,7 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
|
|
|
73
73
|
...(hasFailure ? ["one_or_more_blocking_checks_failed"] : []),
|
|
74
74
|
...(advisoryFindingCount > 0 ? ["advisory_findings_observed"] : []),
|
|
75
75
|
...(evaluation.modules.length === 0 ? ["no_applicable_rules"] : []),
|
|
76
|
+
...(bundle.modules?.length === 0 ? ["no_system_knowledge"] : []),
|
|
76
77
|
...(requiresHuman ? ["human_evidence_required"] : []),
|
|
77
78
|
...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
|
|
78
79
|
],
|
package/src/sqm/report.js
CHANGED
|
@@ -15,9 +15,16 @@ export function formatSqmCoverage(proof) {
|
|
|
15
15
|
];
|
|
16
16
|
if (scenarios.length) lines.push(`Scenario evaluations: ${[...statusCounts].map(([status, count]) => `${status}=${count}`).join(" · ")}`);
|
|
17
17
|
if (proof.outcome.reasons.length) lines.push(`Outcome reasons: ${proof.outcome.reasons.join(", ")}`);
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
lines.push(...sqmCoverageWarnings({ moduleCount: modules.length, scenarioCount: scenarios.length,
|
|
19
|
+
noSystemKnowledge: proof.outcome.reasons.includes("no_system_knowledge"), outcomeStatus: proof.outcome.status }));
|
|
20
20
|
if (modules.length && !blocking) lines.push("Advisory-only scope: findings do not block this outcome.");
|
|
21
21
|
lines.push("Scenario evaluations are deterministic checks, not browser/provider/deployed journey evidence. Record incident coverage and delivery acceptance separately.");
|
|
22
22
|
return lines.join("\n");
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
export function sqmCoverageWarnings({ moduleCount, scenarioCount, noSystemKnowledge, outcomeStatus = "passed" }) {
|
|
26
|
+
if (noSystemKnowledge) return [`Warning: the selected system/repository's downloaded SQM knowledge bundle has no published knowledge. ${outcomeStatus === "passed" ? "This is a pass with 0 evaluated cases" : "This result has 0 evaluated cases"}; it does not establish feature or historical incident coverage.`];
|
|
27
|
+
if (!moduleCount) return ["Warning: no applicable rules matched this change (0 evaluated cases). This result does not establish feature or historical incident coverage; it does not mean the system has no past knowledge."];
|
|
28
|
+
if (!scenarioCount) return ["Warning: No scenario evaluations: rule results alone do not establish scenario coverage."];
|
|
29
|
+
return [];
|
|
30
|
+
}
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -14,8 +14,10 @@ const DEFAULT_LEASE_SECONDS = 300;
|
|
|
14
14
|
// Claim can retrieve and freeze organization-scoped Slack evidence (45s budget).
|
|
15
15
|
const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
|
|
16
16
|
const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// Keep analysis independent of interactive config. Availability varies by
|
|
18
|
+
// account and CLI; only an explicit model rejection may try the next model.
|
|
19
|
+
export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
|
|
20
|
+
export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
|
|
19
21
|
|
|
20
22
|
export class SqmLocalReviewError extends Error {
|
|
21
23
|
constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "" } = {}) {
|
|
@@ -94,12 +96,30 @@ export function createSqmLocalReviewWorker({
|
|
|
94
96
|
getWorkspaceUrl = () => null,
|
|
95
97
|
recordHistory = async () => {},
|
|
96
98
|
onHistoryError = () => {},
|
|
99
|
+
now = () => Date.now(),
|
|
100
|
+
providerFailureCooldownMs = 5 * 60_000,
|
|
101
|
+
getRecoveryKey = () => null,
|
|
97
102
|
} = {}) {
|
|
98
103
|
if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
|
|
99
104
|
let activeJobId = null;
|
|
100
105
|
let pollPromise = null;
|
|
101
106
|
let closed = false;
|
|
102
107
|
let issue = null;
|
|
108
|
+
let issueContext = null;
|
|
109
|
+
const failureDetails = new WeakMap();
|
|
110
|
+
const pauses = new Map();
|
|
111
|
+
const contextKey = (provider, scope) => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId, provider]);
|
|
112
|
+
const currentPause = () => pauses.get(contextKey(getProvider(), getHistoryScope()));
|
|
113
|
+
const pauseForFailure = (error, provider, scope, recoveryKey) => {
|
|
114
|
+
const failure = failureDetails.get(error) || reviewFailure(error, "claim", provider);
|
|
115
|
+
if (!["provider_sign_in_required", "provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "desktop_access_required"].includes(failure.code) && ![401, 403].includes(failure.status)) return;
|
|
116
|
+
const key = contextKey(provider, scope);
|
|
117
|
+
const failures = Math.min((pauses.get(key)?.failures || 0) + 1, 4);
|
|
118
|
+
const delay = Math.min(30 * 60_000, Math.max(1000, providerFailureCooldownMs) * 2 ** (failures - 1));
|
|
119
|
+
pauses.set(key, { code: failure.code, stage: failure.stage, status: failure.status, failures, recoveryKey,
|
|
120
|
+
retryAt: now() + delay, message: `${formatFailure(failure)}\nNew SQM claims are paused. After fixing this cause, refresh the local AI connection or wait for the next recovery attempt.` });
|
|
121
|
+
if (pauses.size > 32) pauses.delete(pauses.keys().next().value);
|
|
122
|
+
};
|
|
103
123
|
const history = [];
|
|
104
124
|
const historyScopes = new WeakMap();
|
|
105
125
|
const visibleHistory = () => history.filter(item => historyScopes.get(item) === JSON.stringify(getHistoryScope()));
|
|
@@ -166,7 +186,7 @@ export function createSqmLocalReviewWorker({
|
|
|
166
186
|
...leaseBody(claim, provider),
|
|
167
187
|
analysis_output: analysis.output,
|
|
168
188
|
executor_model: analysis.model,
|
|
169
|
-
}), submitting => { stage = submitting ? "advance" : "analysis"; });
|
|
189
|
+
}), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
|
|
170
190
|
if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
|
|
171
191
|
stage = "adjudication";
|
|
172
192
|
const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
|
|
@@ -175,11 +195,12 @@ export function createSqmLocalReviewWorker({
|
|
|
175
195
|
analysis_output: analysis.output,
|
|
176
196
|
adjudication_output: adjudication.output,
|
|
177
197
|
executor_model: adjudication.model || analysis.model,
|
|
178
|
-
}), submitting => { stage = submitting ? "complete" : "adjudication"; });
|
|
198
|
+
}), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
|
|
179
199
|
await updateTask(claim.job_id, "completed", historyScope);
|
|
180
200
|
return completed;
|
|
181
201
|
} catch (error) {
|
|
182
202
|
const failure = reviewFailure(error, stage, provider);
|
|
203
|
+
if (error && typeof error === "object") failureDetails.set(error, failure);
|
|
183
204
|
let failureReported = error.failureRecorded === true;
|
|
184
205
|
if (!failureReported) {
|
|
185
206
|
try {
|
|
@@ -199,7 +220,9 @@ export function createSqmLocalReviewWorker({
|
|
|
199
220
|
|
|
200
221
|
return {
|
|
201
222
|
snapshot() {
|
|
202
|
-
|
|
223
|
+
const pause = currentPause();
|
|
224
|
+
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
225
|
+
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
203
226
|
},
|
|
204
227
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
|
|
205
228
|
desktopTaskDetail(key) {
|
|
@@ -212,9 +235,14 @@ export function createSqmLocalReviewWorker({
|
|
|
212
235
|
const provider = getProvider();
|
|
213
236
|
if (provider == null) return Promise.resolve(null);
|
|
214
237
|
assertProvider(provider);
|
|
238
|
+
const historyScope = getHistoryScope();
|
|
239
|
+
const key = contextKey(provider, historyScope);
|
|
240
|
+
const recoveryKey = getRecoveryKey(provider);
|
|
241
|
+
const pause = pauses.get(key);
|
|
242
|
+
if (pause && pause.recoveryKey === recoveryKey && now() < pause.retryAt) return Promise.resolve(null);
|
|
243
|
+
if (pause && pause.recoveryKey !== recoveryKey) pauses.delete(key);
|
|
215
244
|
const releaseWork = beginWork?.();
|
|
216
245
|
if (!releaseWork) return Promise.resolve(null);
|
|
217
|
-
const historyScope = getHistoryScope();
|
|
218
246
|
const workspaceUrl = getWorkspaceUrl();
|
|
219
247
|
pollPromise = (async () => {
|
|
220
248
|
const claim = await api.request({
|
|
@@ -224,10 +252,18 @@ export function createSqmLocalReviewWorker({
|
|
|
224
252
|
lease_seconds: leaseSeconds,
|
|
225
253
|
});
|
|
226
254
|
if (issue) { issue = null; onChange(); }
|
|
227
|
-
if (!claim?.claimed)
|
|
228
|
-
|
|
255
|
+
if (!claim?.claimed) {
|
|
256
|
+
// A successful empty claim proves SIMY access recovered, but it says
|
|
257
|
+
// nothing about a local provider that has not executed again yet.
|
|
258
|
+
if (pause?.stage === "claim") pauses.delete(key);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const result = await processClaim(claim, provider, historyScope, workspaceUrl);
|
|
262
|
+
pauses.delete(key);
|
|
263
|
+
return result;
|
|
229
264
|
})()
|
|
230
265
|
.catch(async (error) => {
|
|
266
|
+
pauseForFailure(error, provider, historyScope, recoveryKey);
|
|
231
267
|
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
232
268
|
const scope = historyScope;
|
|
233
269
|
const failure = reviewFailure(error, "claim", provider);
|
|
@@ -237,6 +273,7 @@ export function createSqmLocalReviewWorker({
|
|
|
237
273
|
history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
|
|
238
274
|
await persist(scope, item);
|
|
239
275
|
}
|
|
276
|
+
issueContext = key;
|
|
240
277
|
issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
|
|
241
278
|
onError(error);
|
|
242
279
|
return null;
|
|
@@ -293,38 +330,54 @@ export async function runStructuredProviderReview(
|
|
|
293
330
|
"Return only JSON that strictly matches the provided output schema.",
|
|
294
331
|
request.prompt,
|
|
295
332
|
].join("\n\n");
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
333
|
+
const deadline = Date.now() + executionTimeoutMs;
|
|
334
|
+
const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
|
|
335
|
+
let stdout;
|
|
336
|
+
let selectedModel;
|
|
337
|
+
for (const model of models) {
|
|
338
|
+
await beforeDispatch();
|
|
339
|
+
if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
|
|
340
|
+
const remaining = deadline - Date.now();
|
|
341
|
+
if (remaining <= 0) throw new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' });
|
|
342
|
+
// Never consume a partial/stale output file left by a rejected model.
|
|
343
|
+
await rm(outputPath, { force: true });
|
|
344
|
+
try {
|
|
345
|
+
stdout = await spawnReview(
|
|
346
|
+
inspection.executable,
|
|
347
|
+
provider === "claude" ? [
|
|
348
|
+
"--print", "--output-format", "json",
|
|
349
|
+
"--json-schema", JSON.stringify(request.output_schema),
|
|
350
|
+
"--no-session-persistence", "--tools", "",
|
|
351
|
+
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
352
|
+
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
353
|
+
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
|
|
354
|
+
] : [
|
|
355
|
+
"exec",
|
|
356
|
+
"--model",
|
|
357
|
+
model,
|
|
358
|
+
"--skip-git-repo-check",
|
|
359
|
+
"--sandbox",
|
|
360
|
+
"read-only",
|
|
361
|
+
"--ephemeral",
|
|
362
|
+
"--ignore-user-config",
|
|
363
|
+
"--ignore-rules",
|
|
364
|
+
...(readOnlyAnalysis ? codexAnalysisArguments(request.system) : []),
|
|
365
|
+
"--output-schema",
|
|
366
|
+
schemaPath,
|
|
367
|
+
"--output-last-message",
|
|
368
|
+
outputPath,
|
|
369
|
+
"-",
|
|
370
|
+
],
|
|
371
|
+
prompt,
|
|
372
|
+
{ spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
|
|
373
|
+
terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
|
|
374
|
+
);
|
|
375
|
+
selectedModel = model;
|
|
376
|
+
break;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
328
381
|
const envelope = provider === "claude" ? JSON.parse(stdout) : null;
|
|
329
382
|
if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
|
|
330
383
|
if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
|
|
@@ -340,7 +393,7 @@ export async function runStructuredProviderReview(
|
|
|
340
393
|
}
|
|
341
394
|
return {
|
|
342
395
|
output,
|
|
343
|
-
model: provider === "codex" ?
|
|
396
|
+
model: provider === "codex" ? selectedModel : `${provider}-local/${inspection.installed_version || "unknown"}`,
|
|
344
397
|
};
|
|
345
398
|
} catch (error) {
|
|
346
399
|
if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
|
|
@@ -365,7 +418,7 @@ function codexAnalysisArguments(system) {
|
|
|
365
418
|
'-c', `developer_instructions=${JSON.stringify(system)}`];
|
|
366
419
|
}
|
|
367
420
|
|
|
368
|
-
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs }) {
|
|
421
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model }) {
|
|
369
422
|
return new Promise((resolve, reject) => {
|
|
370
423
|
if (signal?.aborted) return reject(new Error('Local analysis was cancelled'));
|
|
371
424
|
const child = spawnImpl(command, args, {
|
|
@@ -376,6 +429,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
376
429
|
});
|
|
377
430
|
let stdout = "";
|
|
378
431
|
let stderr = "";
|
|
432
|
+
let stderrPending = "";
|
|
379
433
|
let settled = false;
|
|
380
434
|
let terminalError = null;
|
|
381
435
|
let killTimer = null;
|
|
@@ -411,9 +465,16 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
411
465
|
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
412
466
|
let event;
|
|
413
467
|
try { event = JSON.parse(line); } catch { continue; }
|
|
414
|
-
if (['error', 'turn.failed'].includes(event.type)
|
|
415
|
-
|
|
416
|
-
|
|
468
|
+
if (['error', 'turn.failed'].includes(event.type)) {
|
|
469
|
+
const message = event.message ?? event.error?.message;
|
|
470
|
+
if (providerSignInRequired(message)) {
|
|
471
|
+
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (providerModelUnsupported(message, model)) {
|
|
475
|
+
halt(modelUnsupportedError(model));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
417
478
|
}
|
|
418
479
|
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
|
|
419
480
|
halt(new Error('Read-only analysis attempted to use a tool'));
|
|
@@ -429,9 +490,32 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
429
490
|
child.stderr?.setEncoding?.("utf8");
|
|
430
491
|
child.stderr?.on?.("data", (chunk) => {
|
|
431
492
|
if (!readOnlyAnalysis) stderr = `${stderr}${chunk}`.slice(-4000);
|
|
493
|
+
if (provider === "codex") {
|
|
494
|
+
stderrPending = `${stderrPending}${chunk}`.slice(-8000);
|
|
495
|
+
const lines = stderrPending.split(/\r?\n/);
|
|
496
|
+
stderrPending = lines.pop() || "";
|
|
497
|
+
for (const line of lines) checkStderr(line);
|
|
498
|
+
}
|
|
432
499
|
});
|
|
500
|
+
const checkStderr = line => {
|
|
501
|
+
// Native CLI diagnostics only; never match warnings or assistant text.
|
|
502
|
+
const match = line.match(/^\s*ERROR:\s*(.*)$/);
|
|
503
|
+
if (!match) return;
|
|
504
|
+
let message = match[1];
|
|
505
|
+
try {
|
|
506
|
+
const event = JSON.parse(message);
|
|
507
|
+
if (event.type !== "error") return;
|
|
508
|
+
message = event.error?.message ?? event.message;
|
|
509
|
+
} catch { /* Older CLIs print a plain native ERROR line. */ }
|
|
510
|
+
if (providerSignInRequired(message)) {
|
|
511
|
+
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
512
|
+
} else if (providerModelUnsupported(message, model)) {
|
|
513
|
+
halt(modelUnsupportedError(model));
|
|
514
|
+
}
|
|
515
|
+
};
|
|
433
516
|
child.on("error", finish);
|
|
434
517
|
child.on("close", (code) => {
|
|
518
|
+
if (provider === "codex") checkStderr(stderrPending);
|
|
435
519
|
if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
|
|
436
520
|
let envelope;
|
|
437
521
|
try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
|
|
@@ -448,6 +532,18 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
448
532
|
});
|
|
449
533
|
}
|
|
450
534
|
|
|
535
|
+
function providerModelUnsupported(message, model) {
|
|
536
|
+
if (typeof message !== "string" || typeof model !== "string") return false;
|
|
537
|
+
const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
|
|
538
|
+
return rejection?.[1] === model;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function modelUnsupportedError(model) {
|
|
542
|
+
return new SqmLocalReviewError(`The local Codex account does not support ${model}. Update Codex or check the models available to this account.`, {
|
|
543
|
+
code: "provider_model_unsupported", status: 400,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
451
547
|
function providerSignInRequired(message) {
|
|
452
548
|
// Match explicit provider authentication failures only, never general 403s
|
|
453
549
|
// (which can be permissions/quotas) or model-generated prose/tool output.
|
|
@@ -511,6 +607,7 @@ function reviewFailure(error, stage, provider) {
|
|
|
511
607
|
function formatFailure(failure, failureReported = null) {
|
|
512
608
|
if (!failure) return "SQM analysis failed. No failure diagnostics were recorded.";
|
|
513
609
|
const hint = failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
|
|
610
|
+
: failure.code === "provider_model_unsupported" ? "The local Codex account rejected the configured SQM models. Update Codex or check this account's available models."
|
|
514
611
|
: failure.code === "analysis_timeout" ? "The local AI analysis exceeded its time limit."
|
|
515
612
|
: failure.code === "sqm_source_loading_failed" ? "SIMY could not load evidence for this analysis."
|
|
516
613
|
: failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
|