@awak-app/simy-cli 0.5.71 → 0.5.73
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 +101 -3
- package/package.json +2 -2
- package/src/agent.js +205 -12
- package/src/backend-executable.js +45 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +17 -12
- package/src/desktop-host.js +19 -8
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +68 -0
- package/src/doctor.js +55 -0
- package/src/index.js +18 -1
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/evidence.js +20 -19
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +66 -41
- package/src/orchestrator/outcome-monitor.js +95 -44
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/orchestrator/recovery.js +3 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +227 -19
- package/src/runtime-diagnostics.js +77 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +14 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +42 -4
- package/src/sqm/diagnostics.js +23 -0
- package/src/sqm/evidence-client.js +24 -7
- package/src/sqm/history-store.js +21 -5
- package/src/sqm/index.js +116 -17
- package/src/sqm/proof.js +4 -23
- package/src/sqm/report.js +9 -2
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +97 -15
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
package/src/runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { EventEmitter, once } from "node:events";
|
|
3
4
|
import { access } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
} from "./orchestrator/execution-io.js";
|
|
21
22
|
import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
|
|
22
23
|
import { PR_PREPARATION_CHECKS } from "./orchestrator/pr-preparation.js";
|
|
23
|
-
import { increaseProviderTokenBudget, refreshBudgetState } from "./orchestrator/budget.js";
|
|
24
|
+
import { budgetState, extendProgressCeilings, increaseProviderTokenBudget, providerBudgetTokens, refreshBudgetState } from "./orchestrator/budget.js";
|
|
24
25
|
import { appendEvent } from "./orchestrator/shared.js";
|
|
25
26
|
import {
|
|
26
27
|
createProviderStreamDecoder,
|
|
@@ -42,7 +43,7 @@ import { LocalRunRegistry } from "./run-registry.js";
|
|
|
42
43
|
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
43
44
|
import { normalizeGitHubRemote } from "./workspace-context.js";
|
|
44
45
|
import { CLI_VERSION } from "./cli-contract.js";
|
|
45
|
-
import { reviseObjective, verifyObjective } from "./orchestrator/outcome-monitor.js";
|
|
46
|
+
import { freezeObjective, reviseObjective, verifyObjective } from "./orchestrator/outcome-monitor.js";
|
|
46
47
|
import { hasUserOutcomeProof } from "./orchestrator/completion-contract.js";
|
|
47
48
|
import { createSqmChecker } from "./sqm/index.js";
|
|
48
49
|
import {
|
|
@@ -51,6 +52,10 @@ import {
|
|
|
51
52
|
normalizeWorkflowStartIntent,
|
|
52
53
|
} from "./workflow-observation-sender.js";
|
|
53
54
|
import { saveCodingRunSnapshot } from "./coding-run-store.js";
|
|
55
|
+
import { acquireDesktopProcessLock } from "./desktop-process-lock.js";
|
|
56
|
+
import {
|
|
57
|
+
assertRecoveryActor, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
|
|
58
|
+
} from "./coding-run-recovery.js";
|
|
54
59
|
|
|
55
60
|
const execFileAsync = promisify(execFile);
|
|
56
61
|
const MAX_LOCAL_LOG_LINES = 1_000;
|
|
@@ -133,6 +138,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
|
|
|
133
138
|
metadata: {
|
|
134
139
|
local_orchestrator_version: 1,
|
|
135
140
|
local_output_persisted: false,
|
|
141
|
+
recovery_actor: recoveryActorBinding(session, apiOrigin, normalizedRequest),
|
|
136
142
|
...(normalizedRequest.workflow_observation_binding
|
|
137
143
|
? {
|
|
138
144
|
workflow_observation: {
|
|
@@ -188,6 +194,10 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
188
194
|
max_attempts: charter.max_attempts,
|
|
189
195
|
retry_budget: charter.retry_budget,
|
|
190
196
|
token_budget: charter.token_budget,
|
|
197
|
+
continuation_policy: charter.continuation_policy,
|
|
198
|
+
provider_token_ceiling: charter.provider_token_ceiling,
|
|
199
|
+
attempt_ceiling: charter.attempt_ceiling,
|
|
200
|
+
max_no_progress_attempts: charter.max_no_progress_attempts,
|
|
191
201
|
ui_evidence_root: String(charter.ui_evidence_root || ""),
|
|
192
202
|
acceptance_criteria: Array.isArray(charter.acceptance_criteria)
|
|
193
203
|
? charter.acceptance_criteria
|
|
@@ -245,6 +255,15 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
245
255
|
const objectiveUnverified = !objectiveCheck.valid || objectiveCheck.legacy || savedGoalUnproven;
|
|
246
256
|
const restoredState = restoredFromState || objectiveUnverified ? "waiting_human" : persistedState;
|
|
247
257
|
if (restoredFromState) {
|
|
258
|
+
restoredSnapshot.metadata ??= {};
|
|
259
|
+
restoredSnapshot.metadata.recovery = {
|
|
260
|
+
version: 1,
|
|
261
|
+
status: ["dispatching", "coding"].includes(restoredFromState)
|
|
262
|
+
? "needs_reconciliation" : "ready",
|
|
263
|
+
interrupted_state: restoredFromState,
|
|
264
|
+
interrupted_attempt: restoredSnapshot.events?.findLast((item) =>
|
|
265
|
+
Number.isInteger(item?.detail?.attempt_number))?.detail.attempt_number ?? null,
|
|
266
|
+
};
|
|
248
267
|
restoredSnapshot.events = Array.isArray(restoredSnapshot.events)
|
|
249
268
|
? restoredSnapshot.events
|
|
250
269
|
: [];
|
|
@@ -302,6 +321,63 @@ export async function publishRestoredLocalCodingRun(run) {
|
|
|
302
321
|
return run.snapshot;
|
|
303
322
|
}
|
|
304
323
|
|
|
324
|
+
export async function prepareLocalCodingRunRecovery(run) {
|
|
325
|
+
assertRecoveryActor(run);
|
|
326
|
+
if (run.operation || run.child) throw new Error("The selected run is still active.");
|
|
327
|
+
const repositoryPath = await resolveRepositoryPath(run.request);
|
|
328
|
+
const git = async (args) => (await execFileAsync("git", args, {
|
|
329
|
+
cwd: repositoryPath, maxBuffer: 16 * 1024 * 1024,
|
|
330
|
+
})).stdout;
|
|
331
|
+
const head = (await git(["rev-parse", "HEAD"])).trim();
|
|
332
|
+
const status = await git(["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
333
|
+
const diff = await git(["diff", "HEAD", "--binary"]);
|
|
334
|
+
return {
|
|
335
|
+
version: 1, run_id: run.id,
|
|
336
|
+
objective: verifyObjective(run.snapshot.charter),
|
|
337
|
+
recovery: structuredClone(run.snapshot.metadata?.recovery || { status: "ready" }),
|
|
338
|
+
checkout: { head_sha: head, status_sha256: executionTextSha256(status), diff_sha256: executionTextSha256(diff) },
|
|
339
|
+
attempts_recorded: run.snapshot.attempts.length,
|
|
340
|
+
last_pr_url: run.snapshot.attempts.at(-1)?.pr_url || null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Only the controller's direct human decision or a trusted reconciler may
|
|
345
|
+
// provide this result. A refreshed checkout alone cannot prove an external
|
|
346
|
+
// publication failed or authorize replaying it.
|
|
347
|
+
export async function reconcileLocalCodingRun(run, { operationStatus, observation, evidenceRefs, authorize } = {}) {
|
|
348
|
+
assertRecoveryActor(run);
|
|
349
|
+
if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
|
|
350
|
+
throw new Error("Interrupted operation reconciliation is available only while the run is waiting.");
|
|
351
|
+
}
|
|
352
|
+
if (run.snapshot.metadata?.recovery?.status !== "needs_reconciliation") {
|
|
353
|
+
throw new Error("This run has no interrupted operation requiring reconciliation.");
|
|
354
|
+
}
|
|
355
|
+
if (!["completed", "not_started", "failed", "none"].includes(operationStatus) ||
|
|
356
|
+
!String(observation || "").trim() || !Array.isArray(evidenceRefs) ||
|
|
357
|
+
!evidenceRefs.some((item) => typeof item === "string" && item.trim())) {
|
|
358
|
+
throw new Error("An authoritative operation status, observation and evidence references are required; unknown success cannot be replayed.");
|
|
359
|
+
}
|
|
360
|
+
const descriptor = await prepareLocalCodingRunRecovery(run);
|
|
361
|
+
await authorize?.();
|
|
362
|
+
assertRecoveryActor(run);
|
|
363
|
+
if (run.operation || run.child || !RESUMABLE_STATES.has(run.status) || run.snapshot.metadata?.recovery?.status !== 'needs_reconciliation') {
|
|
364
|
+
throw new Error('The interrupted run changed while reconciliation evidence was inspected.');
|
|
365
|
+
}
|
|
366
|
+
run.snapshot.metadata.recovery = {
|
|
367
|
+
...descriptor.recovery, status: "ready", operation_status: operationStatus,
|
|
368
|
+
reconciled_at: new Date().toISOString(), checkout: descriptor.checkout,
|
|
369
|
+
observation_sha256: executionTextSha256(String(observation).trim()),
|
|
370
|
+
evidence_refs: evidenceRefs.filter((item) => typeof item === "string" && item.trim()),
|
|
371
|
+
actor: recoveryActorBinding(run.session, run.apiOrigin, run.request),
|
|
372
|
+
};
|
|
373
|
+
appendEvent(run.snapshot, "waiting_human", "The interrupted operation was reconciled before continuation.", {
|
|
374
|
+
code: "interrupted_operation_reconciled", operation_status: operationStatus,
|
|
375
|
+
interrupted_attempt: descriptor.recovery.interrupted_attempt,
|
|
376
|
+
});
|
|
377
|
+
await updateRun(run, "waiting_human");
|
|
378
|
+
return run.snapshot.metadata.recovery;
|
|
379
|
+
}
|
|
380
|
+
|
|
305
381
|
export async function startLocalCodingRun(
|
|
306
382
|
run,
|
|
307
383
|
dependencies = {},
|
|
@@ -329,18 +405,29 @@ function assertRunCanContinue(run) {
|
|
|
329
405
|
if (!RESUMABLE_STATES.has(run.status)) {
|
|
330
406
|
throw new Error("Human guidance is available only when the selected run is waiting.");
|
|
331
407
|
}
|
|
408
|
+
assertRecoveryActor(run);
|
|
409
|
+
if (run.snapshot.metadata?.recovery?.status === "needs_reconciliation") {
|
|
410
|
+
const error = new Error("Reconcile the interrupted operation before continuing the same run; its side effects may already exist.");
|
|
411
|
+
error.code = "interrupted_operation_unverified";
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
if (verifyObjective(run.snapshot.charter).legacy) {
|
|
415
|
+
const error = new Error("Explicitly confirm the saved objective before continuing this legacy run.");
|
|
416
|
+
error.code = "objective_baseline_unverified";
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
332
419
|
|
|
333
|
-
const nextAttempt = run.snapshot.attempts.length + 1;
|
|
334
420
|
const budget = refreshBudgetState(run.snapshot);
|
|
335
421
|
if (budget.token_exhausted) {
|
|
336
422
|
throw new Error("The Agentic Loop run used its token budget. Start a new run to continue.");
|
|
337
423
|
}
|
|
338
|
-
if (
|
|
424
|
+
if (budget.retry_exhausted) {
|
|
339
425
|
throw new Error("The Agentic Loop run used its retry budget. Start a new run to continue.");
|
|
340
426
|
}
|
|
341
427
|
}
|
|
342
428
|
|
|
343
429
|
export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
430
|
+
assertRecoveryActor(run);
|
|
344
431
|
if (run.operation || run.child) {
|
|
345
432
|
throw new Error("Provider token budget can change only while the run is waiting.");
|
|
346
433
|
}
|
|
@@ -352,7 +439,12 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
352
439
|
throw new Error("The Agentic Loop run also used its retry budget. Start a new run to continue.");
|
|
353
440
|
}
|
|
354
441
|
const previousBudget = currentBudget.token_budget;
|
|
442
|
+
const previousAudit = run.snapshot.final_audit;
|
|
443
|
+
const resolvedBudgetAudit = previousAudit?.findings?.some(
|
|
444
|
+
(finding) => finding.code === "TOKEN_BUDGET_EXHAUSTED",
|
|
445
|
+
) ? structuredClone(previousAudit) : null;
|
|
355
446
|
const budget = increaseProviderTokenBudget(run.snapshot, value);
|
|
447
|
+
refreshRecoveryBudgetAudit(run.snapshot, budget);
|
|
356
448
|
run.request.token_budget = budget.token_budget;
|
|
357
449
|
appendEvent(
|
|
358
450
|
run.snapshot,
|
|
@@ -361,6 +453,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
361
453
|
{
|
|
362
454
|
code: "provider_token_budget_increased",
|
|
363
455
|
previous_provider_token_budget: previousBudget,
|
|
456
|
+
...(resolvedBudgetAudit ? { previous_final_audit: resolvedBudgetAudit } : {}),
|
|
364
457
|
provider_token_budget: budget.token_budget,
|
|
365
458
|
budget,
|
|
366
459
|
},
|
|
@@ -369,6 +462,29 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
369
462
|
return budget;
|
|
370
463
|
}
|
|
371
464
|
|
|
465
|
+
export async function extendLocalCodingRunCeilings(run, changes) {
|
|
466
|
+
assertRecoveryActor(run);
|
|
467
|
+
if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
|
|
468
|
+
throw new Error("Hard ceilings can change only while the selected run is waiting and idle.");
|
|
469
|
+
}
|
|
470
|
+
const previous = budgetState(run.snapshot);
|
|
471
|
+
const budget = extendProgressCeilings(run.snapshot, changes);
|
|
472
|
+
refreshRecoveryBudgetAudit(run.snapshot, budget);
|
|
473
|
+
run.request.provider_token_ceiling = run.snapshot.charter.provider_token_ceiling;
|
|
474
|
+
run.request.attempt_ceiling = run.snapshot.charter.attempt_ceiling;
|
|
475
|
+
appendEvent(run.snapshot, run.status, "The human operator explicitly increased this saved run's hard ceilings.", {
|
|
476
|
+
code: "continuation_ceilings_extended",
|
|
477
|
+
previous_provider_token_ceiling: previous.hard_token_ceiling,
|
|
478
|
+
previous_attempt_ceiling: previous.hard_attempt_ceiling,
|
|
479
|
+
provider_token_ceiling: budget.hard_token_ceiling,
|
|
480
|
+
attempt_ceiling: budget.hard_attempt_ceiling,
|
|
481
|
+
budget,
|
|
482
|
+
});
|
|
483
|
+
// Extending consent is separate from continuing work, including a stopped run.
|
|
484
|
+
await updateRun(run, run.status);
|
|
485
|
+
return budget;
|
|
486
|
+
}
|
|
487
|
+
|
|
372
488
|
export async function continueLocalCodingRunAfterRepositoryApproval(
|
|
373
489
|
run,
|
|
374
490
|
{ repository, localPath } = {},
|
|
@@ -437,11 +553,39 @@ export function queueLocalCodingGuidance(run, guidance) {
|
|
|
437
553
|
|
|
438
554
|
export async function applyLocalHumanDecision(
|
|
439
555
|
run,
|
|
440
|
-
{ message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
|
|
556
|
+
{ message, designApproval = false, requiredChecks = [], acceptanceCriteria = [], confirmObjective = false, confirmedObjective = null } = {},
|
|
441
557
|
dependencies = {},
|
|
442
558
|
) {
|
|
443
559
|
const guidance = String(message || "").trim();
|
|
444
560
|
if (!guidance) throw new Error("A human decision note is required.");
|
|
561
|
+
if (confirmObjective) {
|
|
562
|
+
assertRecoveryActor(run);
|
|
563
|
+
if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
|
|
564
|
+
throw new Error("Objective confirmation is available only while the saved run is waiting.");
|
|
565
|
+
}
|
|
566
|
+
const objective = verifyObjective(run.snapshot.charter);
|
|
567
|
+
if (!objective.valid) throw new Error("Restore the saved objective or explicitly revise its changed criteria; an invalid integrity chain cannot be rebaselined.");
|
|
568
|
+
if (objective.legacy) {
|
|
569
|
+
const charter = run.snapshot.charter;
|
|
570
|
+
if (confirmedObjective?.originalRequest !== charter.thread_state.original_request ||
|
|
571
|
+
confirmedObjective?.userGoal !== charter.thread_state.user_goal ||
|
|
572
|
+
JSON.stringify(confirmedObjective?.acceptanceCriteria) !== JSON.stringify(charter.acceptance_criteria)) {
|
|
573
|
+
throw new Error("Confirm the exact saved original request, user goal and acceptance criteria to establish a legacy baseline.");
|
|
574
|
+
}
|
|
575
|
+
freezeObjective(run.snapshot.charter);
|
|
576
|
+
refreshRecoveryAudit(run.snapshot, {
|
|
577
|
+
reason: "objective_baseline_confirmed",
|
|
578
|
+
stale: (finding) => finding.code === "OBJECTIVE_BASELINE_UNVERIFIED",
|
|
579
|
+
summary: "The saved objective was confirmed; remaining verification or approval is still required.",
|
|
580
|
+
});
|
|
581
|
+
run.snapshot.done_gate_passed = false;
|
|
582
|
+
run.snapshot.implementation_gate_passed = false;
|
|
583
|
+
appendEvent(run.snapshot, "waiting_human", "The original saved objective was explicitly confirmed.", {
|
|
584
|
+
code: "objective_baseline_confirmed", note_sha256: executionTextSha256(guidance),
|
|
585
|
+
});
|
|
586
|
+
await updateRun(run, "waiting_human");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
445
589
|
assertRunCanContinue(run);
|
|
446
590
|
const charter = run.snapshot.charter;
|
|
447
591
|
if (acceptanceCriteria.length > 0) {
|
|
@@ -540,10 +684,12 @@ export async function stopLocalCodingRun(run) {
|
|
|
540
684
|
async function signalAndWaitForClose(child, signal, timeoutMs) {
|
|
541
685
|
const close = once(child, "close").then(() => "closed");
|
|
542
686
|
signalExecutorProcess(child, signal);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
687
|
+
let timer;
|
|
688
|
+
try {
|
|
689
|
+
return await Promise.race([close,
|
|
690
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutMs); }),
|
|
691
|
+
]);
|
|
692
|
+
} finally { clearTimeout(timer); }
|
|
547
693
|
}
|
|
548
694
|
|
|
549
695
|
async function waitForLocalStop(run) {
|
|
@@ -598,13 +744,17 @@ async function executeLocalCodingRun(
|
|
|
598
744
|
{ executeAttempt, executeIndependentAudit, collectEvidence } = {},
|
|
599
745
|
{ humanGuidance = "", resume = false } = {},
|
|
600
746
|
) {
|
|
601
|
-
const operation =
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
747
|
+
const operation = (async () => {
|
|
748
|
+
assertRecoveryActor(run);
|
|
749
|
+
const lease = run.localStateRoot ? await acquireDesktopProcessLock(
|
|
750
|
+
path.join(run.localStateRoot, ".controllers", executionTextSha256(run.id)),
|
|
751
|
+
) : null;
|
|
752
|
+
try {
|
|
753
|
+
return await runLocalCodingRun(run, {
|
|
754
|
+
executeAttempt, executeIndependentAudit, collectEvidence, humanGuidance, resume,
|
|
755
|
+
});
|
|
756
|
+
} finally { await lease?.release(); }
|
|
757
|
+
})();
|
|
608
758
|
run.operation = operation;
|
|
609
759
|
try {
|
|
610
760
|
return await operation;
|
|
@@ -646,6 +796,16 @@ async function runLocalCodingRun(
|
|
|
646
796
|
await updateRun(run, run.status);
|
|
647
797
|
}
|
|
648
798
|
|
|
799
|
+
const recovery = run.snapshot.metadata?.recovery;
|
|
800
|
+
if (resume && recovery?.checkout) {
|
|
801
|
+
const recoveredGuidance = [
|
|
802
|
+
"Resume the same saved objective and approvals. Reconcile existing work before taking another action.",
|
|
803
|
+
`Interrupted operation status: ${recovery.operation_status}. Checkout at reconciliation: ${JSON.stringify(recovery.checkout)}.`,
|
|
804
|
+
"Never duplicate a completed external operation; inspect its existing identity and evidence. If its current outcome is unknown, stop for reconciliation instead of replaying it.",
|
|
805
|
+
].join("\n");
|
|
806
|
+
humanGuidance = [recoveredGuidance, humanGuidance].filter(Boolean).join("\n\n");
|
|
807
|
+
}
|
|
808
|
+
|
|
649
809
|
const executor =
|
|
650
810
|
executeAttempt ||
|
|
651
811
|
((context) =>
|
|
@@ -687,6 +847,7 @@ async function runLocalCodingRun(
|
|
|
687
847
|
deviceId: run.session?.device_id,
|
|
688
848
|
});
|
|
689
849
|
run.sqmChecker = sqmChecker;
|
|
850
|
+
refreshSqmCheckerSession(run, sqmChecker);
|
|
690
851
|
|
|
691
852
|
try {
|
|
692
853
|
await runCodingLoop({
|
|
@@ -762,6 +923,7 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
762
923
|
deviceId: run.session?.device_id,
|
|
763
924
|
});
|
|
764
925
|
run.sqmChecker = sqmChecker;
|
|
926
|
+
refreshSqmCheckerSession(run, sqmChecker);
|
|
765
927
|
await recheckPrReadiness({
|
|
766
928
|
snapshot: run.snapshot,
|
|
767
929
|
collectEvidence: evidenceCollector,
|
|
@@ -800,6 +962,16 @@ function sqmBundleEndpoint(session) {
|
|
|
800
962
|
return new URL("sqm/knowledge-bundle", session.api_base_url).href;
|
|
801
963
|
}
|
|
802
964
|
|
|
965
|
+
function refreshSqmCheckerSession(run, checker) {
|
|
966
|
+
assertRecoveryActor(run);
|
|
967
|
+
if (!run.session?.token || !checker.updateSession) return;
|
|
968
|
+
checker.updateSession({ token: run.session.token,
|
|
969
|
+
accountId: run.session.account_id || run.session.auth_user_id || run.session.device_id,
|
|
970
|
+
organizationId: run.session.organization_id || run.session.org_id,
|
|
971
|
+
deviceId: run.session.device_id, endpoint: sqmBundleEndpoint(run.session), webOrigin: run.apiOrigin,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
803
975
|
function sqmEvidenceEndpoint(session) {
|
|
804
976
|
if (process.env.SIMY_SQM_EVIDENCE_URL?.trim()) return process.env.SIMY_SQM_EVIDENCE_URL.trim();
|
|
805
977
|
if (!session?.api_base_url) return undefined;
|
|
@@ -813,8 +985,9 @@ export async function executeProcessAttempt({
|
|
|
813
985
|
run,
|
|
814
986
|
marker = "SIMY_RESULT_JSON:",
|
|
815
987
|
attemptNumber = null,
|
|
988
|
+
resolveCommandImpl = resolveDesktopExecutorCommand,
|
|
816
989
|
}) {
|
|
817
|
-
const command = await
|
|
990
|
+
const command = await resolveCommandImpl({
|
|
818
991
|
backend,
|
|
819
992
|
instruction,
|
|
820
993
|
repositoryPath,
|
|
@@ -824,6 +997,8 @@ export async function executeProcessAttempt({
|
|
|
824
997
|
const stderr = [];
|
|
825
998
|
const tokenUsageRecords = [];
|
|
826
999
|
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1000
|
+
const initialBudget = budgetState(run.snapshot);
|
|
1001
|
+
const invocationId = randomUUID();
|
|
827
1002
|
const observationSender =
|
|
828
1003
|
phase === "executor" &&
|
|
829
1004
|
!["accepted", "already_recorded"].includes(run.snapshot.metadata?.workflow_observation?.status)
|
|
@@ -839,6 +1014,8 @@ export async function executeProcessAttempt({
|
|
|
839
1014
|
return new Promise((resolve) => {
|
|
840
1015
|
let settled = false;
|
|
841
1016
|
let terminalError = null;
|
|
1017
|
+
let terminalErrorCode = null;
|
|
1018
|
+
const checkpointWrites = [];
|
|
842
1019
|
const child = command.spawn(command.bin, command.args, {
|
|
843
1020
|
cwd: repositoryPath,
|
|
844
1021
|
env: { ...process.env, ...command.env },
|
|
@@ -853,13 +1030,42 @@ export async function executeProcessAttempt({
|
|
|
853
1030
|
onLine: (line) => emitOutput(run, line, { backend }),
|
|
854
1031
|
onEvent: (event) => observationSender?.observe(event),
|
|
855
1032
|
onTerminalError: (message) => { terminalError ||= message; },
|
|
856
|
-
onUsage: (usage) =>
|
|
1033
|
+
onUsage: (usage) => {
|
|
857
1034
|
tokenUsageRecords.push({
|
|
858
1035
|
...usage,
|
|
859
1036
|
phase,
|
|
860
1037
|
...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
|
|
861
1038
|
backend,
|
|
862
|
-
|
|
1039
|
+
...(initialBudget.continuation_policy === "progress" ? { invocation_id: invocationId } : {}),
|
|
1040
|
+
});
|
|
1041
|
+
if (initialBudget.continuation_policy !== "progress") return;
|
|
1042
|
+
const currentUsed = initialBudget.tokens_used + tokenUsageRecords.reduce((sum, record) => sum + providerBudgetTokens(record), 0);
|
|
1043
|
+
run.snapshot.metadata.provider_usage_checkpoints ??= {};
|
|
1044
|
+
run.snapshot.metadata.provider_usage_checkpoints[invocationId] = {
|
|
1045
|
+
invocation_id: invocationId, phase, attempt_number: attemptNumber, started_at: startedAt,
|
|
1046
|
+
token_usage: { records: structuredClone(tokenUsageRecords) },
|
|
1047
|
+
total_provider_tokens: currentUsed,
|
|
1048
|
+
hard_token_ceiling: initialBudget.hard_token_ceiling,
|
|
1049
|
+
};
|
|
1050
|
+
refreshBudgetState(run.snapshot);
|
|
1051
|
+
if (run.localStateRoot) checkpointWrites.push(saveCodingRunSnapshot(run.localStateRoot, run.snapshot).catch(() => {
|
|
1052
|
+
emitOutput(run, "The Provider usage checkpoint could not be persisted; recovery usage remains unverified.", { backend });
|
|
1053
|
+
}));
|
|
1054
|
+
if (currentUsed < initialBudget.hard_token_ceiling || terminalErrorCode || run.stopRequested) return;
|
|
1055
|
+
terminalErrorCode = "TOKEN_BUDGET_EXHAUSTED";
|
|
1056
|
+
terminalError = `Provider token hard ceiling reached (${currentUsed} / ${initialBudget.hard_token_ceiling}). Extend the same run's ceiling explicitly before continuing.`;
|
|
1057
|
+
appendEvent(run.snapshot, run.status, terminalError, {
|
|
1058
|
+
code: "provider_token_ceiling_reached", phase, attempt_number: attemptNumber,
|
|
1059
|
+
provider_tokens_used: currentUsed, provider_token_ceiling: initialBudget.hard_token_ceiling,
|
|
1060
|
+
});
|
|
1061
|
+
if (!settled) {
|
|
1062
|
+
run.controlState = "stopping";
|
|
1063
|
+
void signalAndWaitForClose(child, "SIGINT", 1_500).then(async result => {
|
|
1064
|
+
if (result === "timeout" && !settled && run.child === child) result = await signalAndWaitForClose(child, "SIGTERM", 1_500);
|
|
1065
|
+
if (result === "timeout" && !settled && run.child === child) await signalAndWaitForClose(child, "SIGKILL", 750);
|
|
1066
|
+
}).catch(() => {});
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
863
1069
|
});
|
|
864
1070
|
const stderrDecoder = createProviderStreamDecoder({
|
|
865
1071
|
backend,
|
|
@@ -884,6 +1090,7 @@ export async function executeProcessAttempt({
|
|
|
884
1090
|
stdoutDecoder.flush();
|
|
885
1091
|
stderrDecoder.flush();
|
|
886
1092
|
run.child = null;
|
|
1093
|
+
await Promise.all(checkpointWrites);
|
|
887
1094
|
// The run must always resolve: a WorkFlow observation failure (or a
|
|
888
1095
|
// sender bug) never changes or blocks the Provider result. flush() is
|
|
889
1096
|
// bounded by per-request timeouts and is designed not to reject.
|
|
@@ -920,6 +1127,7 @@ export async function executeProcessAttempt({
|
|
|
920
1127
|
resolve({
|
|
921
1128
|
exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
|
|
922
1129
|
error: error || terminalError,
|
|
1130
|
+
...(terminalErrorCode ? { errorCode: terminalErrorCode } : {}),
|
|
923
1131
|
result: Object.keys(result).length > 0 ? result : parse(rawStdout),
|
|
924
1132
|
tokenUsage:
|
|
925
1133
|
tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@ import { associationContract } from '../codex-plugins/simy/hooks/action-associat
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
4
|
import { inspectBackendExecutable } from '../backend-executable.js';
|
|
5
|
-
import { assessedCheckpointContract, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { assessedCheckpointContract, deliveryBudgetState, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
6
|
import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
|
|
7
7
|
import { JsonDataUrlFilter } from './json-data-url-filter.js';
|
|
8
8
|
|
|
@@ -422,7 +422,8 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
422
422
|
enrolledScopes: run.managed.approvalScopes,
|
|
423
423
|
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
424
424
|
...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
|
|
425
|
-
budgets: {
|
|
425
|
+
budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
|
|
426
|
+
softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
|
|
426
427
|
const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
|
|
427
428
|
const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
|
|
428
429
|
const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
|
|
@@ -141,7 +141,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// Explicit execution authority: legacy monitor/tracked records stay read-only.
|
|
144
|
-
export async function setSessionAutoContinue({ root, provider, session, enabled, ...options }) {
|
|
144
|
+
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, ...options }) {
|
|
145
145
|
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
146
146
|
if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
|
|
147
147
|
return updateRecord(root, provider, session, async previous => {
|
|
@@ -152,8 +152,20 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
152
152
|
if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
|
|
153
153
|
{ session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
|
|
154
154
|
const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
|
|
155
|
+
const settings = { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3,
|
|
156
|
+
...previous.autoContinueSettings,
|
|
157
|
+
...(continuationPolicy === undefined ? {} : { continuationPolicy }),
|
|
158
|
+
...(hardMaxMinutes === undefined ? {} : { hardMaxMinutes }),
|
|
159
|
+
...(hardMaxContinuations === undefined ? {} : { hardMaxContinuations }),
|
|
160
|
+
...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
|
|
161
|
+
if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
|
|
162
|
+
for (const [key, maximum] of [['hardMaxMinutes', 1440], ['hardMaxContinuations', 50], ['providerTokenCeiling', 15_000_000]]) {
|
|
163
|
+
if (settings[key] !== undefined && (!Number.isSafeInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)) throw new Error('Invalid continuation ceiling.');
|
|
164
|
+
}
|
|
165
|
+
if (providerTokenCeiling !== undefined && provider !== 'codex') throw new Error('Token ceilings require host-observed Codex usage.');
|
|
166
|
+
if (previous.run && [continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling].some(value => value !== undefined)) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
155
167
|
const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
|
|
156
|
-
...(enabled ? { tracked: true, monitor: true, autoContinueSettings:
|
|
168
|
+
...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
|
|
157
169
|
// Adopt existing supervision without restarting it or renewing its budgets.
|
|
158
170
|
if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
|
|
159
171
|
return next;
|
|
@@ -209,7 +221,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
212
|
-
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, autoContinueEpoch, openClient = openCodexAppServer,
|
|
224
|
+
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, autoContinueEpoch, openClient = openCodexAppServer,
|
|
213
225
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
214
226
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
215
227
|
if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
|
|
@@ -235,7 +247,7 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
235
247
|
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
236
248
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
237
249
|
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
238
|
-
maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
|
|
250
|
+
maxMinutes, maxContinuations, maxFailures, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, now, admission, inspectProcess, autoContinueEpoch,
|
|
239
251
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
240
252
|
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
241
253
|
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|
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";
|
|
@@ -5,6 +6,7 @@ import path from "node:path";
|
|
|
5
6
|
|
|
6
7
|
import { verifyBundleIntegrity } from "./canonical.js";
|
|
7
8
|
import { validateKnowledgeBundle } from "./validation.js";
|
|
9
|
+
import { fetchSqmRead, sqmDiagnostic } from "./request-recovery.js";
|
|
8
10
|
|
|
9
11
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
10
12
|
export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -35,6 +37,9 @@ export async function loadKnowledgeBundle({
|
|
|
35
37
|
fetchImpl = globalThis.fetch,
|
|
36
38
|
now = Date.now(),
|
|
37
39
|
publicKeys = publicKeysFromEnvironment(),
|
|
40
|
+
signal,
|
|
41
|
+
recoveryOptions,
|
|
42
|
+
onRecoveryEvent,
|
|
38
43
|
}) {
|
|
39
44
|
if (!organizationId) {
|
|
40
45
|
return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
|
|
@@ -49,7 +54,7 @@ export async function loadKnowledgeBundle({
|
|
|
49
54
|
Date.parse(cached.bundle.expires_at) > now;
|
|
50
55
|
if (!offline && endpoint && (!fresh || refresh || cached?.bundle.system_scope)) {
|
|
51
56
|
try {
|
|
52
|
-
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 });
|
|
53
58
|
if (fetched.notModified) {
|
|
54
59
|
if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
|
|
55
60
|
if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
|
|
@@ -81,9 +86,10 @@ export async function loadKnowledgeBundle({
|
|
|
81
86
|
});
|
|
82
87
|
return result(bundle, [], "cloud");
|
|
83
88
|
} catch (error) {
|
|
89
|
+
if (signal?.aborted) throw signal.reason;
|
|
84
90
|
if (error?.code === "incompatible_cli") throw error;
|
|
85
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");
|
|
86
|
-
return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "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") } };
|
|
87
93
|
}
|
|
88
94
|
}
|
|
89
95
|
if (cached) {
|
|
@@ -166,7 +172,7 @@ function trustedDiscoveryEndpoint(endpoint) {
|
|
|
166
172
|
(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
|
|
167
173
|
}
|
|
168
174
|
|
|
169
|
-
async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
|
|
175
|
+
async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent }) {
|
|
170
176
|
const url = new URL(endpoint);
|
|
171
177
|
url.searchParams.set("repository", repository);
|
|
172
178
|
url.searchParams.set("cli_version", cliVersion);
|
|
@@ -175,10 +181,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
175
181
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
176
182
|
if (organizationId) headers["x-simy-org-id"] = organizationId;
|
|
177
183
|
if (etag) headers["If-None-Match"] = etag;
|
|
178
|
-
const response = await
|
|
179
|
-
|
|
180
|
-
signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
|
|
181
|
-
});
|
|
184
|
+
const response = await fetchSqmRead(url, { ...recoveryOptions, headers, signal, fetchImpl,
|
|
185
|
+
requestTimeoutMs: SQM_BUNDLE_FETCH_TIMEOUT_MS, onRecoveryEvent });
|
|
182
186
|
const responseEtag = response.headers?.get?.("etag") || null;
|
|
183
187
|
const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
|
|
184
188
|
const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
|
|
@@ -200,6 +204,9 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
|
|
|
200
204
|
);
|
|
201
205
|
}
|
|
202
206
|
const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
|
|
207
|
+
error.status = response.status;
|
|
208
|
+
Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
|
|
209
|
+
requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
|
|
203
210
|
error.fallbackAllowed = response.status >= 500 && response.status <= 599;
|
|
204
211
|
throw error;
|
|
205
212
|
}
|