@awak-app/simy-cli 0.5.71 → 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 +41 -6
package/README.md
CHANGED
|
@@ -30,9 +30,20 @@ current analysis finishes and submits its result. The toggle does not grant SQM
|
|
|
30
30
|
subscription or organization permissions. First-time repository history discovery
|
|
31
31
|
still starts from the web workspace.
|
|
32
32
|
|
|
33
|
-
**
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
**Quality checks** shows local analysis and check history separately from ordinary
|
|
34
|
+
**Tasks**. Recent records survive restart and are scoped to the Web environment,
|
|
35
|
+
account, and organization. Failed checks retain safe diagnostic codes, stages,
|
|
36
|
+
HTTP status, and request identifiers when available. The web SQM workspace
|
|
37
|
+
retains the authoritative analysis history. Persistent provider login, model or
|
|
38
|
+
access errors pause new local claims with a visible recovery hint; refreshing
|
|
39
|
+
after reconnecting permits another probe, with bounded automatic cooldowns.
|
|
40
|
+
|
|
41
|
+
Detached daemon and desktop-host diagnostics are saved to
|
|
42
|
+
`$SIMY_HOME/logs/daemon.log` and `$SIMY_HOME/logs/desktop-host.log`
|
|
43
|
+
(`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
|
|
44
|
+
backups; credentials, Cookie headers and URL queries are redacted. Installer
|
|
45
|
+
handoff diagnostics remain in `~/.simy/installer/desktop-update.log`.
|
|
46
|
+
`simy --version` or `simy -v` prints the package version without starting work.
|
|
36
47
|
|
|
37
48
|
Closing the window keeps the agent running. Open the app again or choose
|
|
38
49
|
**Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
|
|
@@ -411,6 +422,9 @@ SQM deliberately has two identity-bound stages:
|
|
|
411
422
|
The full-check summary reports applicable modules, evaluated rules, scenario
|
|
412
423
|
statuses and blocking/advisory scope. `passed` with `no_applicable_rules` or zero
|
|
413
424
|
scenario evaluations does not establish feature or historical incident coverage.
|
|
425
|
+
An empty system/repository bundle adds `no_system_knowledge` and an explicit
|
|
426
|
+
zero-case warning; knowledge that exists but does not match this change uses a
|
|
427
|
+
separate warning. Both retain the existing `passed` outcome.
|
|
414
428
|
Scenario statuses come from deterministic rule evaluation, not browser/provider
|
|
415
429
|
execution. Keep concrete regression and delivery acceptance evidence separately.
|
|
416
430
|
If knowledge is unavailable, the command returns exit 2 with the service cause
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -246,6 +246,7 @@ export async function startAgent({
|
|
|
246
246
|
);
|
|
247
247
|
let availableCapabilities;
|
|
248
248
|
let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
|
|
249
|
+
const providerRecoveryRevisions = { codex: 0, claude: 0 };
|
|
249
250
|
const refreshAvailableCapabilities = () => {
|
|
250
251
|
const features = contractSourceCapabilities.features;
|
|
251
252
|
availableCapabilities = withCliContract({
|
|
@@ -423,6 +424,7 @@ export async function startAgent({
|
|
|
423
424
|
providerStatuses = { ...providerStatuses, [provider]: next };
|
|
424
425
|
refreshAvailableCapabilities();
|
|
425
426
|
if (previous[provider]?.version !== next.version || previous[provider]?.state !== next.state) {
|
|
427
|
+
providerRecoveryRevisions[provider] += 1;
|
|
426
428
|
notifyDesktopStatusChange();
|
|
427
429
|
}
|
|
428
430
|
}));
|
|
@@ -883,6 +885,7 @@ export async function startAgent({
|
|
|
883
885
|
return slug ? new URL(`/org/${encodeURIComponent(slug)}/quality-monitor/`, apiOrigin).toString() : null;
|
|
884
886
|
},
|
|
885
887
|
getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
|
|
888
|
+
getRecoveryKey: (provider) => providerRecoveryRevisions[provider] ?? 0,
|
|
886
889
|
recordHistory: (scope, item) => sqmHistory.record(scope, item),
|
|
887
890
|
onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
|
|
888
891
|
getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
@@ -2330,6 +2333,7 @@ export async function startAgent({
|
|
|
2330
2333
|
sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
|
|
2331
2334
|
activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
|
|
2332
2335
|
issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
|
|
2336
|
+
providerPause: sqmLocalReviewWorker?.snapshot?.().provider_pause ?? null,
|
|
2333
2337
|
providerReady: [codex, claude].some(provider => provider?.state === "ready") },
|
|
2334
2338
|
...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
|
|
2335
2339
|
update: updateManager?.snapshot?.() ?? null,
|
|
@@ -2400,7 +2404,16 @@ export async function startAgent({
|
|
|
2400
2404
|
return getDesktopStatus();
|
|
2401
2405
|
},
|
|
2402
2406
|
refreshCodexStatus: () => codexStatus({ force: true }),
|
|
2403
|
-
refreshProviderStatus: () =>
|
|
2407
|
+
refreshProviderStatus: async () => {
|
|
2408
|
+
const statuses = await readProviderStatuses({ force: true });
|
|
2409
|
+
// Explicit reconnect/refresh permits one recovery attempt. Routine status
|
|
2410
|
+
// polling must not defeat the worker's persistent-failure cooldown.
|
|
2411
|
+
for (const provider of ["codex", "claude"]) {
|
|
2412
|
+
if (statuses[provider]?.state === "ready") providerRecoveryRevisions[provider] += 1;
|
|
2413
|
+
}
|
|
2414
|
+
notifyDesktopStatusChange();
|
|
2415
|
+
return statuses;
|
|
2416
|
+
},
|
|
2404
2417
|
workspace,
|
|
2405
2418
|
repositoryScanRoot,
|
|
2406
2419
|
get capabilities() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
2
|
import os from "node:os";
|
|
3
|
+
import { readdirSync, statSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
|
|
5
6
|
const MACOS_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
|
|
@@ -85,7 +86,7 @@ export function backendCandidates(backend, {
|
|
|
85
86
|
"claude",
|
|
86
87
|
// Desktop launch environments may predate the installer's PATH update.
|
|
87
88
|
paths.join(userHome, ".local", "bin", platform === "win32" ? "claude.exe" : "claude"),
|
|
88
|
-
...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude"] : []),
|
|
89
|
+
...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude", ...claudeDesktopCandidates(userHome)] : []),
|
|
89
90
|
];
|
|
90
91
|
}
|
|
91
92
|
return [
|
|
@@ -95,6 +96,28 @@ export function backendCandidates(backend, {
|
|
|
95
96
|
];
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// Desktop installs use either <version>/claude.app or <version>/<hash>/claude.app.
|
|
100
|
+
// Only inspect these two known levels; never recurse through arbitrary home paths
|
|
101
|
+
// or descend through version/hash directory symlinks. Existing explicit/PATH installs retain priority.
|
|
102
|
+
function claudeDesktopCandidates(home) {
|
|
103
|
+
const root = path.join(home, "Library", "Application Support", "Claude", "claude-code");
|
|
104
|
+
const directories = directory => {
|
|
105
|
+
try { return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name); }
|
|
106
|
+
catch { return []; }
|
|
107
|
+
};
|
|
108
|
+
return directories(root).filter(name => /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(name))
|
|
109
|
+
.sort((left, right) => compareVersions(right, left)).slice(0, 32)
|
|
110
|
+
.flatMap(version => {
|
|
111
|
+
const versionRoot = path.join(root, version);
|
|
112
|
+
const bundle = directory => path.join(directory, "claude.app", "Contents", "MacOS", "claude");
|
|
113
|
+
return [bundle(versionRoot), ...directories(versionRoot)
|
|
114
|
+
.filter(name => /^[a-zA-Z0-9_-]{8,128}$/.test(name)).sort().slice(0, 8)
|
|
115
|
+
.map(hash => bundle(path.join(versionRoot, hash)))];
|
|
116
|
+
}).filter(candidate => {
|
|
117
|
+
try { return statSync(candidate).isFile(); } catch { return false; }
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
export function parseBackendVersion(output) {
|
|
99
122
|
const match = String(output || "").match(/(?:^|\s|v)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$|\))/);
|
|
100
123
|
return match?.[1] ?? null;
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.72";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -743,9 +743,13 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
|
|
|
743
743
|
return decision;
|
|
744
744
|
}
|
|
745
745
|
export function managedBudgetReason(run, now = Date.now()) {
|
|
746
|
-
|
|
746
|
+
const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
|
|
747
|
+
if (now >= run.deadline) return `Delivery time limit exhausted.${checkpointDetail}`;
|
|
748
|
+
if (run.continuations >= run.maxContinuations) return `Delivery continuation limit exhausted (${run.continuations}/${run.maxContinuations}).${checkpointDetail}`;
|
|
749
|
+
if (run.failures >= run.maxFailures) return `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
|
|
747
750
|
const managed = run.managed;
|
|
748
|
-
if (managed?.usageBlocked
|
|
751
|
+
if (managed?.usageBlocked) return `Reliable provider token usage is unavailable; budgeted continuation is blocked.${checkpointDetail}`;
|
|
752
|
+
if (managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return `Provider token budget exhausted (${managed.tokensUsed}/${managed.maxTokens}).${checkpointDetail}`;
|
|
749
753
|
if (managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) return 'No verified acceptance progress within the configured time limit.';
|
|
750
754
|
if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
|
|
751
755
|
return null;
|
|
@@ -193,8 +193,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
193
193
|
const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
|
|
194
194
|
live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
|
|
195
195
|
if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
|
|
196
|
-
|
|
197
|
-
|
|
196
|
+
const budgetReason = managedBudgetReason(live, currentTime());
|
|
197
|
+
if (budgetReason) {
|
|
198
|
+
live.state = 'budget_exhausted'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
|
|
198
199
|
}
|
|
199
200
|
live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
|
|
200
201
|
owner: { pid: process.pid, identity: ownerIdentity } };
|
package/src/desktop-host.js
CHANGED
|
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
|
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
20
20
|
import { startAgent } from "./agent.js";
|
|
21
|
+
import { createRuntimeDiagnostics } from "./runtime-diagnostics.js";
|
|
21
22
|
import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
|
|
22
23
|
import { createAgentShutdown } from "./shutdown.js";
|
|
23
24
|
import { resolveWebOrigin } from "./web-origin.js";
|
|
@@ -74,6 +75,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
74
75
|
output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
|
|
75
76
|
};
|
|
76
77
|
const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
78
|
+
const diagnostics = createRuntimeDiagnostics(root, { name: "desktop-host" });
|
|
77
79
|
const webOrigin = resolveWebOrigin(null, env);
|
|
78
80
|
const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
|
|
79
81
|
if (candidate && !await validateDesktopSupervisor(root, env.SIMY_DESKTOP_SUPERVISOR_TOKEN)) {
|
|
@@ -189,6 +191,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
189
191
|
} catch { pluginSetup = { state: "error" }; }
|
|
190
192
|
};
|
|
191
193
|
const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
|
|
194
|
+
diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
|
|
192
195
|
const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
|
|
193
196
|
root: join(root, 'session-analysis'), analyze: async (...args) => {
|
|
194
197
|
const scope = await authorizeWork();
|
|
@@ -291,7 +294,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
291
294
|
updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
|
|
292
295
|
daemon: true, interactive: true, webOrigin,
|
|
293
296
|
disabled: env.SIMY_AUTO_UPDATE === "0",
|
|
294
|
-
dependencies: { packageInfo, logger(
|
|
297
|
+
dependencies: { packageInfo, logger: message => diagnostics.write(`update: ${message}`) },
|
|
295
298
|
});
|
|
296
299
|
agent = await (dependencies.startAgent || startAgent)({
|
|
297
300
|
daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
|
|
@@ -317,12 +320,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
317
320
|
let child;
|
|
318
321
|
try { child = (dependencies.spawn || spawn)(installation.executablePath,
|
|
319
322
|
[join(dirname(installation.entryPath), "desktop-host.js")], {
|
|
320
|
-
stdio: ["pipe", "pipe", "
|
|
323
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
321
324
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
322
325
|
}); } catch (error) {
|
|
323
326
|
actionAssociations = makeActionAssociations();
|
|
324
327
|
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
|
|
325
328
|
}
|
|
329
|
+
const stderr = diagnostics.stream("updated-runtime stderr");
|
|
330
|
+
child.stderr?.on("data", stderr.write);
|
|
331
|
+
child.stderr?.on("end", stderr.flush);
|
|
332
|
+
child.once("error", error => diagnostics.write(`updated-runtime error: ${error.code || "unknown"} ${error.message}`));
|
|
333
|
+
child.once("exit", (code, signal) => { stderr.flush(); diagnostics.write(`updated-runtime exit: code=${code} signal=${signal || "none"}`); });
|
|
326
334
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
327
335
|
let accepted = false;
|
|
328
336
|
child.stdin.on("error", () => {
|
|
@@ -668,6 +676,7 @@ if (isDesktopHostEntry()) {
|
|
|
668
676
|
const stop = () => { void host?.close().finally(() => process.exit(0)); };
|
|
669
677
|
process.once("SIGTERM", stop); process.once("SIGINT", stop);
|
|
670
678
|
startDesktopHost().then((value) => { host = value; }).catch((error) => {
|
|
679
|
+
createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" }).write(`startup failed: ${error?.stack || error}`);
|
|
671
680
|
const conflict = error?.code === PROCESS_CONFLICT_CODE;
|
|
672
681
|
process.stdout.write(`${JSON.stringify({
|
|
673
682
|
type: "error",
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
9
|
import { startAgent } from "./agent.js";
|
|
10
|
+
import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
|
|
10
11
|
import {
|
|
11
12
|
autoUpdateHelpText,
|
|
12
13
|
createCliAutoUpdater,
|
|
@@ -34,8 +35,16 @@ const entryPath = fileURLToPath(import.meta.url);
|
|
|
34
35
|
const packageInfo = await readCliPackageInfo();
|
|
35
36
|
const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
|
|
36
37
|
let configuredWebOrigin;
|
|
38
|
+
const versionOnly = dispatchArgv.length === 1 && ["--version", "-v"].includes(dispatchArgv[0]);
|
|
39
|
+
if (versionOnly) {
|
|
40
|
+
console.log(packageInfo.version);
|
|
41
|
+
} else if (process.env.SIMY_DAEMON_CHILD === "1" && (args.has("--daemon") || args.has("--deamon"))) {
|
|
42
|
+
const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"));
|
|
43
|
+
captureRuntimeOutput(diagnostics);
|
|
44
|
+
diagnostics.write(`daemon starting version=${packageInfo.version} pid=${process.pid}`);
|
|
45
|
+
}
|
|
37
46
|
try {
|
|
38
|
-
configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
47
|
+
if (!versionOnly) configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
39
48
|
} catch (error) {
|
|
40
49
|
console.error(error instanceof Error ? error.message : String(error));
|
|
41
50
|
process.exitCode = 2;
|
|
@@ -121,6 +130,7 @@ async function runCommand(installMode) {
|
|
|
121
130
|
simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
|
|
122
131
|
|
|
123
132
|
Options:
|
|
133
|
+
--version, -v Print the installed CLI version and exit
|
|
124
134
|
--daemon, --deamon Run detached in the background
|
|
125
135
|
--no-tui Disable the interactive Agentic Loop console
|
|
126
136
|
--no-open Do not open the browser when authorization is required
|
|
@@ -26,7 +26,7 @@ export async function collectPrEvidence({
|
|
|
26
26
|
const baseRefresh = await command(
|
|
27
27
|
runCommand,
|
|
28
28
|
"git",
|
|
29
|
-
["fetch", "--no-tags", "origin", charter.base_branch],
|
|
29
|
+
["fetch", "--no-tags", "origin", charter.base_branch.replace(/^(?:refs\/remotes\/)?origin\//, "")],
|
|
30
30
|
repositoryPath,
|
|
31
31
|
);
|
|
32
32
|
const local = await collectLocalGitEvidence({
|
|
@@ -42,7 +42,8 @@ export async function collectPrEvidence({
|
|
|
42
42
|
const latest = await collectLocalGitEvidence({ baseBranch: charter.base_branch, repositoryPath, runCommand });
|
|
43
43
|
latest.base_ref_refreshed = baseRefresh.ok;
|
|
44
44
|
latest.base_ref_error = baseRefresh.ok ? null : baseRefresh.error;
|
|
45
|
-
if (!latest.available || latest.head_sha !== local.head_sha ||
|
|
45
|
+
if (!latest.available || latest.head_sha !== local.head_sha || latest.base_sha !== local.base_sha ||
|
|
46
|
+
latest.base_ref !== local.base_ref || !latest.working_tree_clean) {
|
|
46
47
|
minimumCompletion.status = "evidence_incomplete";
|
|
47
48
|
minimumCompletion.errors = ["Checkout changed while collecting minimum evidence; verify again."];
|
|
48
49
|
}
|
|
@@ -90,16 +91,17 @@ function observedEvidenceRef(ref, local, github) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand }) {
|
|
93
|
-
const
|
|
94
|
-
|
|
94
|
+
const base = await resolveBaseRef(runCommand, baseBranch, repositoryPath);
|
|
95
|
+
// Pin all comparisons to the same commit, even if another process moves the ref.
|
|
96
|
+
const baseCommit = base.ok ? base.stdout : null;
|
|
97
|
+
const [head, branch, headline, status, changed, mergeBase, baseAncestor] = await Promise.all([
|
|
95
98
|
command(runCommand, "git", ["rev-parse", "HEAD"], repositoryPath),
|
|
96
99
|
command(runCommand, "git", ["branch", "--show-current"], repositoryPath),
|
|
97
100
|
command(runCommand, "git", ["log", "-1", "--pretty=%s"], repositoryPath),
|
|
98
101
|
command(runCommand, "git", ["status", "--porcelain"], repositoryPath),
|
|
99
|
-
|
|
100
|
-
command(runCommand, "git", ["
|
|
101
|
-
command(runCommand, "git", ["merge-base",
|
|
102
|
-
command(runCommand, "git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], repositoryPath),
|
|
102
|
+
base.ok ? command(runCommand, "git", ["diff", "--name-only", "--diff-filter=ACMR", `${baseCommit}...HEAD`], repositoryPath) : base,
|
|
103
|
+
base.ok ? command(runCommand, "git", ["merge-base", baseCommit, "HEAD"], repositoryPath) : base,
|
|
104
|
+
base.ok ? command(runCommand, "git", ["merge-base", "--is-ancestor", baseCommit, "HEAD"], repositoryPath) : base,
|
|
103
105
|
]);
|
|
104
106
|
const statusLines = lines(status.stdout);
|
|
105
107
|
return {
|
|
@@ -110,7 +112,8 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
|
|
|
110
112
|
working_tree_clean: status.ok && statusLines.length === 0,
|
|
111
113
|
status_lines: statusLines,
|
|
112
114
|
changed_files: lines(changed.stdout),
|
|
113
|
-
|
|
115
|
+
base_ref: base.ref || null,
|
|
116
|
+
base_sha: baseCommit,
|
|
114
117
|
merge_base_sha: cleanString(mergeBase.stdout) || null,
|
|
115
118
|
base_is_ancestor: baseAncestor.ok,
|
|
116
119
|
errors: [head, branch, headline, status, changed, base, mergeBase]
|
|
@@ -119,18 +122,16 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
|
|
|
119
122
|
};
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
async function
|
|
123
|
-
const refs =
|
|
125
|
+
async function resolveBaseRef(runCommand, baseBranch, repositoryPath) {
|
|
126
|
+
const refs = baseBranch.startsWith("refs/") ? [baseBranch] :
|
|
127
|
+
baseBranch.startsWith("origin/") ? [`refs/remotes/${baseBranch}`] :
|
|
128
|
+
[`refs/remotes/origin/${baseBranch}`, `refs/heads/${baseBranch}`];
|
|
124
129
|
for (const ref of refs) {
|
|
125
|
-
const result = await command(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
["diff", "--name-only", "--diff-filter=ACMR", `${ref}...HEAD`],
|
|
129
|
-
repositoryPath,
|
|
130
|
-
);
|
|
131
|
-
if (result.ok) return result;
|
|
130
|
+
const result = await command(runCommand, "git",
|
|
131
|
+
["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], repositoryPath);
|
|
132
|
+
if (result.ok) return { ...result, ref };
|
|
132
133
|
}
|
|
133
|
-
return { ok: false, stdout: "", error: `Could not
|
|
134
|
+
return { ok: false, stdout: "", error: `Could not resolve base branch ${baseBranch}.` };
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
async function collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand }) {
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -183,7 +183,7 @@ async function runCodingLoopOwned({
|
|
|
183
183
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
184
184
|
attempt.implementation_gate = attempt.audit.implementation_gate;
|
|
185
185
|
snapshot.attempts.push(attempt);
|
|
186
|
-
assessOutcomeProgress(snapshot.charter, attempt, previousAttempt);
|
|
186
|
+
assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
|
|
187
187
|
attempt.budget = refreshBudgetState(snapshot);
|
|
188
188
|
|
|
189
189
|
if (snapshot.budget.token_exhausted) {
|
|
@@ -265,7 +265,7 @@ async function runCodingLoopOwned({
|
|
|
265
265
|
});
|
|
266
266
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
267
267
|
attempt.implementation_gate = attempt.audit.implementation_gate;
|
|
268
|
-
assessOutcomeProgress(snapshot.charter, attempt, previousAttempt);
|
|
268
|
+
assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
|
|
269
269
|
|
|
270
270
|
const independentFindings = attempt.independent_audit.findings || [];
|
|
271
271
|
if (!attempt.audit.passed || !attempt.independent_audit.passed) {
|
|
@@ -555,6 +555,34 @@ async function handleFailedAudit({
|
|
|
555
555
|
});
|
|
556
556
|
return true;
|
|
557
557
|
}
|
|
558
|
+
if (attempt.outcome_progress?.assessment_unavailable) {
|
|
559
|
+
const progress = attempt.outcome_progress;
|
|
560
|
+
snapshot.final_audit = {
|
|
561
|
+
...(snapshot.final_audit || {}),
|
|
562
|
+
passed: false,
|
|
563
|
+
summary: "Outcome progress could not be assessed across consecutive attempts.",
|
|
564
|
+
findings: [...(snapshot.final_audit?.findings || []), {
|
|
565
|
+
passed: false,
|
|
566
|
+
code: "OUTCOME_ASSESSMENT_UNAVAILABLE",
|
|
567
|
+
severity: "blocker",
|
|
568
|
+
target: "outcome_monitor",
|
|
569
|
+
explanation: "Two consecutive attempts could not establish an independent outcome assessment. Resolve the recorded verification failures and continue this run; this does not prove the work has stalled.",
|
|
570
|
+
repairability: "manual",
|
|
571
|
+
}],
|
|
572
|
+
auto_repairable: false,
|
|
573
|
+
requires_human: true,
|
|
574
|
+
};
|
|
575
|
+
appendEvent(snapshot, "waiting_human", "Outcome assessment needs recovery before further automatic work.", {
|
|
576
|
+
code: "outcome_assessment_unavailable",
|
|
577
|
+
finding_codes: ["OUTCOME_ASSESSMENT_UNAVAILABLE"],
|
|
578
|
+
attempt_number: attemptNumber,
|
|
579
|
+
consecutive_unassessed_attempts: progress.consecutive_unassessed_attempts,
|
|
580
|
+
unavailable_reason: progress.unavailable_reason,
|
|
581
|
+
unproven_ids: progress.unproven_ids,
|
|
582
|
+
});
|
|
583
|
+
await publish(snapshot, "waiting_human", onUpdate);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
558
586
|
if (requiresHuman) {
|
|
559
587
|
appendEvent(snapshot, "waiting_human", "PR audit requires human input.", {
|
|
560
588
|
finding_codes: findings.map((finding) => finding.code),
|
|
@@ -17,17 +17,30 @@ function objective(charter) {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const CANONICAL_DIGEST = "sha256-canonical-json-v1";
|
|
21
|
+
|
|
22
|
+
function canonicalValue(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
24
|
+
if (value && typeof value === "object") {
|
|
25
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function digest(value, algorithm) {
|
|
31
|
+
return createHash("sha256").update(JSON.stringify(
|
|
32
|
+
algorithm === CANONICAL_DIGEST ? canonicalValue(value) : value,
|
|
33
|
+
)).digest("hex");
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
export function freezeObjective(charter) {
|
|
25
37
|
const value = objective(charter);
|
|
26
38
|
charter.thread_state.objective_contract = {
|
|
27
39
|
version: 1,
|
|
40
|
+
digest_algorithm: CANONICAL_DIGEST,
|
|
28
41
|
baseline: structuredClone(value),
|
|
29
42
|
current: structuredClone(value),
|
|
30
|
-
current_digest: digest(value),
|
|
43
|
+
current_digest: digest(value, CANONICAL_DIGEST),
|
|
31
44
|
revisions: [],
|
|
32
45
|
};
|
|
33
46
|
}
|
|
@@ -36,7 +49,7 @@ export function reviseObjective(charter, source) {
|
|
|
36
49
|
const contract = charter.thread_state.objective_contract;
|
|
37
50
|
if (!contract) return;
|
|
38
51
|
const next = objective(charter);
|
|
39
|
-
const nextDigest = digest(next);
|
|
52
|
+
const nextDigest = digest(next, contract.digest_algorithm);
|
|
40
53
|
if (nextDigest === contract.current_digest) return;
|
|
41
54
|
contract.revisions.push({
|
|
42
55
|
version: contract.version + 1,
|
|
@@ -55,6 +68,10 @@ export function verifyObjective(charter) {
|
|
|
55
68
|
const contract = charter.thread_state?.objective_contract;
|
|
56
69
|
if (!contract) return { valid: true, legacy: true };
|
|
57
70
|
try {
|
|
71
|
+
if (contract.digest_algorithm && contract.digest_algorithm !== CANONICAL_DIGEST) {
|
|
72
|
+
return { valid: false, legacy: false, digest: null };
|
|
73
|
+
}
|
|
74
|
+
const contractDigest = (value) => digest(value, contract.digest_algorithm);
|
|
58
75
|
const current = objective(charter);
|
|
59
76
|
const revisions = contract.revisions;
|
|
60
77
|
const valid = Boolean(
|
|
@@ -62,15 +79,15 @@ export function verifyObjective(charter) {
|
|
|
62
79
|
contract.baseline &&
|
|
63
80
|
Array.isArray(revisions) &&
|
|
64
81
|
revisions.length === contract.version - 1 &&
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
contractDigest(contract.current) === contract.current_digest &&
|
|
83
|
+
contractDigest(current) === contract.current_digest &&
|
|
67
84
|
revisions.every((revision, index) =>
|
|
68
85
|
revision.version === index + 2 &&
|
|
69
|
-
revision.previous_digest ===
|
|
70
|
-
revision.next_digest ===
|
|
71
|
-
(index === 0 ? revision.previous_digest ===
|
|
86
|
+
revision.previous_digest === contractDigest(revision.previous) &&
|
|
87
|
+
revision.next_digest === contractDigest(revision.next) &&
|
|
88
|
+
(index === 0 ? revision.previous_digest === contractDigest(contract.baseline) :
|
|
72
89
|
revision.previous_digest === revisions[index - 1].next_digest)) &&
|
|
73
|
-
(revisions.length === 0 ?
|
|
90
|
+
(revisions.length === 0 ? contractDigest(contract.baseline) === contract.current_digest :
|
|
74
91
|
revisions.at(-1).next_digest === contract.current_digest)
|
|
75
92
|
);
|
|
76
93
|
return { valid, legacy: false, digest: contract.current_digest };
|
|
@@ -81,7 +98,7 @@ export function verifyObjective(charter) {
|
|
|
81
98
|
|
|
82
99
|
// The monitor reads independent criterion assessments, not executor test counts.
|
|
83
100
|
// It records insufficient evidence when the independent review was not reached.
|
|
84
|
-
export function assessOutcomeProgress(charter, attempt, previousAttempt = null) {
|
|
101
|
+
export function assessOutcomeProgress(charter, attempt, previousAttempt = null, { resetUnavailableCount = false } = {}) {
|
|
85
102
|
const criteria = acceptanceCriterionRows(charter);
|
|
86
103
|
const assessment = attempt.independent_audit;
|
|
87
104
|
const results = Array.isArray(assessment?.criterion_results)
|
|
@@ -111,6 +128,20 @@ export function assessOutcomeProgress(charter, attempt, previousAttempt = null)
|
|
|
111
128
|
stalled: false,
|
|
112
129
|
};
|
|
113
130
|
const previous = previousAttempt?.outcome_progress;
|
|
131
|
+
const sameObjective = previous?.objective_digest === result.objective_digest;
|
|
132
|
+
const previousUnavailableCount = !resetUnavailableCount && sameObjective && previous?.status === "insufficient_evidence"
|
|
133
|
+
? previous.consecutive_unassessed_attempts || 1 : 0;
|
|
134
|
+
result.consecutive_unassessed_attempts = completeAssessment ? 0 : previousUnavailableCount + 1;
|
|
135
|
+
result.unavailable_reason = completeAssessment ? null : attempt.audit?.passed !== true
|
|
136
|
+
? "local_verification_incomplete" : "independent_assessment_incomplete";
|
|
137
|
+
const previousMissingAssessments = !resetUnavailableCount && sameObjective &&
|
|
138
|
+
previous?.unavailable_reason === "independent_assessment_incomplete"
|
|
139
|
+
? previous.consecutive_unavailable_assessments || 1 : 0;
|
|
140
|
+
result.consecutive_unavailable_assessments = result.unavailable_reason === "independent_assessment_incomplete"
|
|
141
|
+
? previousMissingAssessments + 1 : 0;
|
|
142
|
+
// Local repair can improve without reaching independent review. Only a repeatedly
|
|
143
|
+
// missing independent assessment after local readiness opens this recovery gate.
|
|
144
|
+
result.assessment_unavailable = result.consecutive_unavailable_assessments >= 2;
|
|
114
145
|
result.stalled = Boolean(
|
|
115
146
|
completeAssessment && previous?.status === "assessed" &&
|
|
116
147
|
previous.objective_digest === result.objective_digest &&
|
|
@@ -114,6 +114,7 @@ function recoveryReasonCode(state, message, detail) {
|
|
|
114
114
|
if (code === "retry_circuit_open" || detail.reason === "identical_commit_and_findings") {
|
|
115
115
|
return "retry_strategy_unchanged";
|
|
116
116
|
}
|
|
117
|
+
if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
|
|
117
118
|
if (code === "token_budget_exhausted") return "token_budget_exhausted";
|
|
118
119
|
if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
|
|
119
120
|
const findings = strings(detail.finding_codes);
|
|
@@ -148,6 +149,8 @@ function recoveryMessage(reasonCode) {
|
|
|
148
149
|
requirements_incomplete:
|
|
149
150
|
"SIMY needs the missing requirement or approval before implementation can start.",
|
|
150
151
|
human_input_required: "SIMY needs your decision or clarification before it can continue.",
|
|
152
|
+
outcome_assessment_unavailable:
|
|
153
|
+
"SIMY could not assess outcome progress across consecutive attempts. Repair the recorded verification failure and continue this run.",
|
|
151
154
|
token_budget_exhausted:
|
|
152
155
|
"This run reached its Provider token safety limit. Provider tokens are not billed by SIMY. Increase the limit to continue this run.",
|
|
153
156
|
retry_budget_exhausted:
|
package/src/runner.js
CHANGED
|
@@ -354,6 +354,25 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
354
354
|
const previousBudget = currentBudget.token_budget;
|
|
355
355
|
const budget = increaseProviderTokenBudget(run.snapshot, value);
|
|
356
356
|
run.request.token_budget = budget.token_budget;
|
|
357
|
+
// A budget change resolves only this blocker; retain the previous audit as evidence.
|
|
358
|
+
const previousAudit = run.snapshot.final_audit;
|
|
359
|
+
const resolvedBudgetAudit = previousAudit?.findings?.some(
|
|
360
|
+
(finding) => finding.code === "TOKEN_BUDGET_EXHAUSTED",
|
|
361
|
+
) ? structuredClone(previousAudit) : null;
|
|
362
|
+
if (resolvedBudgetAudit) {
|
|
363
|
+
const findings = previousAudit.findings.filter((finding) => finding.code !== "TOKEN_BUDGET_EXHAUSTED");
|
|
364
|
+
run.snapshot.final_audit = {
|
|
365
|
+
...previousAudit,
|
|
366
|
+
passed: false,
|
|
367
|
+
summary: findings.length > 0
|
|
368
|
+
? "Provider token budget increased; other verification findings remain."
|
|
369
|
+
: "Provider token budget increased; continue this run to verify completion.",
|
|
370
|
+
findings,
|
|
371
|
+
budget,
|
|
372
|
+
auto_repairable: findings.length > 0 && findings.every((finding) => finding.repairability === "auto"),
|
|
373
|
+
requires_human: findings.some((finding) => finding.repairability !== "auto"),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
357
376
|
appendEvent(
|
|
358
377
|
run.snapshot,
|
|
359
378
|
"waiting_human",
|
|
@@ -361,6 +380,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
361
380
|
{
|
|
362
381
|
code: "provider_token_budget_increased",
|
|
363
382
|
previous_provider_token_budget: previousBudget,
|
|
383
|
+
...(resolvedBudgetAudit ? { previous_final_audit: resolvedBudgetAudit } : {}),
|
|
364
384
|
provider_token_budget: budget.token_budget,
|
|
365
385
|
budget,
|
|
366
386
|
},
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
import { redactExecutionText } from './orchestrator/execution-io.js';
|
|
5
|
+
|
|
6
|
+
// Local diagnostics are deliberately bounded and private. Use the same credential
|
|
7
|
+
// policy as execution history, with URL queries removed (login nonces live there).
|
|
8
|
+
export function createRuntimeDiagnostics(root, { name = 'daemon', maxBytes = 1024 * 1024, backups = 2 } = {}) {
|
|
9
|
+
const directory = join(root, 'logs');
|
|
10
|
+
const file = join(directory, `${name}.log`);
|
|
11
|
+
const write = value => {
|
|
12
|
+
try {
|
|
13
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
const text = redactExecutionText(String(value)
|
|
15
|
+
.replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1[REDACTED]')
|
|
16
|
+
.replace(/(https?:\/\/[^\s?#]+)[?#][^\s]*/g, '$1?[REDACTED]'), { maxChars: 8192 }).text;
|
|
17
|
+
// Do not retain fragments of PEM keys, whose boundaries may span writes.
|
|
18
|
+
const safe = /PRIVATE KEY|^[A-Za-z0-9+/=]{64,}$/.test(text.trim()) ? '[REDACTED KEY MATERIAL]' : text;
|
|
19
|
+
const record = Buffer.from(`${new Date().toISOString()} ${safe.replace(/[\r\n]+/g, ' ')}\n`);
|
|
20
|
+
const bounded = record.subarray(0, maxBytes);
|
|
21
|
+
let size = 0;
|
|
22
|
+
try { size = statSync(file).size; } catch {}
|
|
23
|
+
if (size + bounded.length > maxBytes) {
|
|
24
|
+
rmSync(`${file}.${backups}`, { force: true });
|
|
25
|
+
for (let index = backups - 1; index >= 1; index--) {
|
|
26
|
+
try { renameSync(`${file}.${index}`, `${file}.${index + 1}`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
27
|
+
}
|
|
28
|
+
try { renameSync(file, `${file}.1`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
29
|
+
}
|
|
30
|
+
appendFileSync(file, bounded, { mode: 0o600 });
|
|
31
|
+
chmodSync(file, 0o600);
|
|
32
|
+
} catch {
|
|
33
|
+
// Diagnostics must never prevent startup/recovery or recurse via stderr.
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const stream = label => {
|
|
37
|
+
const decoder = new StringDecoder('utf8');
|
|
38
|
+
let pending = '', dropping = false, privateKey = false;
|
|
39
|
+
const writeChunk = chunk => {
|
|
40
|
+
const text = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
41
|
+
for (const part of text.split(/(?<=\n)/)) {
|
|
42
|
+
const end = part.endsWith('\n');
|
|
43
|
+
if (!dropping) {
|
|
44
|
+
pending += part;
|
|
45
|
+
if (pending.length > 8192) {
|
|
46
|
+
// Drop the whole oversized line, rather than persisting an unredacted
|
|
47
|
+
// partial credential split across chunks or the truncation boundary.
|
|
48
|
+
write(`${label}: [oversized diagnostic line omitted]`);
|
|
49
|
+
pending = ''; dropping = !end;
|
|
50
|
+
} else if (end) {
|
|
51
|
+
if (/-----BEGIN .*PRIVATE KEY-----/.test(pending)) privateKey = true;
|
|
52
|
+
write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending.trimEnd()}`);
|
|
53
|
+
if (/-----END .*PRIVATE KEY-----/.test(pending)) privateKey = false;
|
|
54
|
+
pending = '';
|
|
55
|
+
}
|
|
56
|
+
} else if (end) dropping = false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return { write: writeChunk, flush() { if (pending && !dropping) write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending}`); pending = ''; dropping = false; } };
|
|
60
|
+
};
|
|
61
|
+
return { file, write, stream };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function captureRuntimeOutput(diagnostics, streams = { stdout: process.stdout, stderr: process.stderr }) {
|
|
65
|
+
const restores = [];
|
|
66
|
+
for (const [label, target] of Object.entries(streams)) {
|
|
67
|
+
const sink = diagnostics.stream(label);
|
|
68
|
+
const original = target.write;
|
|
69
|
+
target.write = function (chunk, ...args) { sink.write(chunk); return original.call(this, chunk, ...args); };
|
|
70
|
+
restores.push(() => { target.write = original; sink.flush(); });
|
|
71
|
+
}
|
|
72
|
+
const fatal = error => diagnostics.write(`uncaught: ${error?.stack || error}`);
|
|
73
|
+
process.on('uncaughtExceptionMonitor', fatal);
|
|
74
|
+
const restore = () => { for (const callback of restores) callback(); process.removeListener('uncaughtExceptionMonitor', fatal); process.removeListener('exit', restore); };
|
|
75
|
+
process.once('exit', restore);
|
|
76
|
+
return restore;
|
|
77
|
+
}
|
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
|
@@ -96,12 +96,30 @@ export function createSqmLocalReviewWorker({
|
|
|
96
96
|
getWorkspaceUrl = () => null,
|
|
97
97
|
recordHistory = async () => {},
|
|
98
98
|
onHistoryError = () => {},
|
|
99
|
+
now = () => Date.now(),
|
|
100
|
+
providerFailureCooldownMs = 5 * 60_000,
|
|
101
|
+
getRecoveryKey = () => null,
|
|
99
102
|
} = {}) {
|
|
100
103
|
if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
|
|
101
104
|
let activeJobId = null;
|
|
102
105
|
let pollPromise = null;
|
|
103
106
|
let closed = false;
|
|
104
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
|
+
};
|
|
105
123
|
const history = [];
|
|
106
124
|
const historyScopes = new WeakMap();
|
|
107
125
|
const visibleHistory = () => history.filter(item => historyScopes.get(item) === JSON.stringify(getHistoryScope()));
|
|
@@ -168,7 +186,7 @@ export function createSqmLocalReviewWorker({
|
|
|
168
186
|
...leaseBody(claim, provider),
|
|
169
187
|
analysis_output: analysis.output,
|
|
170
188
|
executor_model: analysis.model,
|
|
171
|
-
}), submitting => { stage = submitting ? "advance" : "analysis"; });
|
|
189
|
+
}), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
|
|
172
190
|
if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
|
|
173
191
|
stage = "adjudication";
|
|
174
192
|
const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
|
|
@@ -177,11 +195,12 @@ export function createSqmLocalReviewWorker({
|
|
|
177
195
|
analysis_output: analysis.output,
|
|
178
196
|
adjudication_output: adjudication.output,
|
|
179
197
|
executor_model: adjudication.model || analysis.model,
|
|
180
|
-
}), submitting => { stage = submitting ? "complete" : "adjudication"; });
|
|
198
|
+
}), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
|
|
181
199
|
await updateTask(claim.job_id, "completed", historyScope);
|
|
182
200
|
return completed;
|
|
183
201
|
} catch (error) {
|
|
184
202
|
const failure = reviewFailure(error, stage, provider);
|
|
203
|
+
if (error && typeof error === "object") failureDetails.set(error, failure);
|
|
185
204
|
let failureReported = error.failureRecorded === true;
|
|
186
205
|
if (!failureReported) {
|
|
187
206
|
try {
|
|
@@ -201,7 +220,9 @@ export function createSqmLocalReviewWorker({
|
|
|
201
220
|
|
|
202
221
|
return {
|
|
203
222
|
snapshot() {
|
|
204
|
-
|
|
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 } } : {}) };
|
|
205
226
|
},
|
|
206
227
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
|
|
207
228
|
desktopTaskDetail(key) {
|
|
@@ -214,9 +235,14 @@ export function createSqmLocalReviewWorker({
|
|
|
214
235
|
const provider = getProvider();
|
|
215
236
|
if (provider == null) return Promise.resolve(null);
|
|
216
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);
|
|
217
244
|
const releaseWork = beginWork?.();
|
|
218
245
|
if (!releaseWork) return Promise.resolve(null);
|
|
219
|
-
const historyScope = getHistoryScope();
|
|
220
246
|
const workspaceUrl = getWorkspaceUrl();
|
|
221
247
|
pollPromise = (async () => {
|
|
222
248
|
const claim = await api.request({
|
|
@@ -226,10 +252,18 @@ export function createSqmLocalReviewWorker({
|
|
|
226
252
|
lease_seconds: leaseSeconds,
|
|
227
253
|
});
|
|
228
254
|
if (issue) { issue = null; onChange(); }
|
|
229
|
-
if (!claim?.claimed)
|
|
230
|
-
|
|
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;
|
|
231
264
|
})()
|
|
232
265
|
.catch(async (error) => {
|
|
266
|
+
pauseForFailure(error, provider, historyScope, recoveryKey);
|
|
233
267
|
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
234
268
|
const scope = historyScope;
|
|
235
269
|
const failure = reviewFailure(error, "claim", provider);
|
|
@@ -239,6 +273,7 @@ export function createSqmLocalReviewWorker({
|
|
|
239
273
|
history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
|
|
240
274
|
await persist(scope, item);
|
|
241
275
|
}
|
|
276
|
+
issueContext = key;
|
|
242
277
|
issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
|
|
243
278
|
onError(error);
|
|
244
279
|
return null;
|