@awak-app/simy-cli 0.5.72 → 0.5.74

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