audit-tools 0.42.0 → 0.43.0
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/dispatch/merge-results.mjs +229 -140
- package/dispatch/validate-result.mjs +98 -40
- package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
- package/dist/audit/cli/conceptualDispatch.js +35 -3
- package/dist/audit/cli/conceptualDispatch.js.map +1 -1
- package/dist/audit/cli/dispatch/hostHandoff.d.ts +9 -1
- package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
- package/dist/audit/cli/dispatch/hostHandoff.js +94 -38
- package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
- package/dist/audit/cli/nextStepCommand.d.ts +64 -0
- package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
- package/dist/audit/cli/nextStepCommand.js +877 -783
- package/dist/audit/cli/nextStepCommand.js.map +1 -1
- package/dist/audit/cli/nextStepHelpers.d.ts +12 -0
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +34 -1
- package/dist/audit/cli/nextStepHelpers.js.map +1 -1
- package/dist/audit/cli/steps.d.ts +2 -2
- package/dist/audit/extractors/graph.d.ts +28 -2
- package/dist/audit/extractors/graph.d.ts.map +1 -1
- package/dist/audit/extractors/graph.js +122 -11
- package/dist/audit/extractors/graph.js.map +1 -1
- package/dist/audit/extractors/graphRoutes.d.ts +8 -0
- package/dist/audit/extractors/graphRoutes.d.ts.map +1 -1
- package/dist/audit/extractors/graphRoutes.js +71 -4
- package/dist/audit/extractors/graphRoutes.js.map +1 -1
- package/dist/audit/io/artifacts.d.ts +55 -0
- package/dist/audit/io/artifacts.d.ts.map +1 -1
- package/dist/audit/io/artifacts.js +189 -12
- package/dist/audit/io/artifacts.js.map +1 -1
- package/dist/audit/io/toolingManifest.d.ts +11 -0
- package/dist/audit/io/toolingManifest.d.ts.map +1 -1
- package/dist/audit/io/toolingManifest.js +13 -3
- package/dist/audit/io/toolingManifest.js.map +1 -1
- package/dist/audit/orchestrator/advance.d.ts +64 -0
- package/dist/audit/orchestrator/advance.d.ts.map +1 -1
- package/dist/audit/orchestrator/advance.js +191 -57
- package/dist/audit/orchestrator/advance.js.map +1 -1
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -1
- package/dist/audit/orchestrator/charterClarificationExecutor.js +55 -21
- package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -1
- package/dist/audit/orchestrator/flowCoverage.d.ts.map +1 -1
- package/dist/audit/orchestrator/flowCoverage.js +7 -10
- package/dist/audit/orchestrator/flowCoverage.js.map +1 -1
- package/dist/audit/orchestrator/flowPlanning.d.ts +54 -1
- package/dist/audit/orchestrator/flowPlanning.d.ts.map +1 -1
- package/dist/audit/orchestrator/flowPlanning.js +77 -18
- package/dist/audit/orchestrator/flowPlanning.js.map +1 -1
- package/dist/audit/orchestrator/flowRequeue.d.ts.map +1 -1
- package/dist/audit/orchestrator/flowRequeue.js +6 -10
- package/dist/audit/orchestrator/flowRequeue.js.map +1 -1
- package/dist/audit/orchestrator/partitionTaskGraph.d.ts.map +1 -1
- package/dist/audit/orchestrator/partitionTaskGraph.js +2 -2
- package/dist/audit/orchestrator/partitionTaskGraph.js.map +1 -1
- package/dist/audit/orchestrator/requeueFold.d.ts.map +1 -1
- package/dist/audit/orchestrator/requeueFold.js +18 -5
- package/dist/audit/orchestrator/requeueFold.js.map +1 -1
- package/dist/audit/orchestrator/staleness.d.ts +24 -1
- package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
- package/dist/audit/orchestrator/staleness.js +147 -16
- package/dist/audit/orchestrator/staleness.js.map +1 -1
- package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts +26 -0
- package/dist/audit/orchestrator/syntaxResolutionExecutor.d.ts.map +1 -1
- package/dist/audit/orchestrator/syntaxResolutionExecutor.js +97 -12
- package/dist/audit/orchestrator/syntaxResolutionExecutor.js.map +1 -1
- package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
- package/dist/audit/orchestrator/taskBuilder.js +111 -21
- package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
- package/dist/audit/orchestrator/trivialAudit.d.ts +17 -1
- package/dist/audit/orchestrator/trivialAudit.d.ts.map +1 -1
- package/dist/audit/orchestrator/trivialAudit.js +31 -4
- package/dist/audit/orchestrator/trivialAudit.js.map +1 -1
- package/dist/audit/reporting/synthesis.d.ts.map +1 -1
- package/dist/audit/reporting/synthesis.js +2 -3
- package/dist/audit/reporting/synthesis.js.map +1 -1
- package/dist/audit/reporting/workBlocks.d.ts.map +1 -1
- package/dist/audit/reporting/workBlocks.js +4 -38
- package/dist/audit/reporting/workBlocks.js.map +1 -1
- package/dist/audit/types/analyzerCapability.d.ts +6 -6
- package/dist/audit/types/charterRegister.d.ts +30 -12
- package/dist/audit/types/charterRegister.d.ts.map +1 -1
- package/dist/audit/types/charterRegister.js +22 -9
- package/dist/audit/types/charterRegister.js.map +1 -1
- package/dist/audit/validation/anchorGrounding.d.ts.map +1 -1
- package/dist/audit/validation/anchorGrounding.js +17 -0
- package/dist/audit/validation/anchorGrounding.js.map +1 -1
- package/dist/audit/validation/auditResults.d.ts +17 -0
- package/dist/audit/validation/auditResults.d.ts.map +1 -1
- package/dist/audit/validation/auditResults.js +50 -17
- package/dist/audit/validation/auditResults.js.map +1 -1
- package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts +48 -0
- package/dist/remediate/contractPipeline/cyclicSeamResolution.d.ts.map +1 -1
- package/dist/remediate/contractPipeline/cyclicSeamResolution.js +128 -0
- package/dist/remediate/contractPipeline/cyclicSeamResolution.js.map +1 -1
- package/dist/remediate/contractPipeline/derive.d.ts +8 -5
- package/dist/remediate/contractPipeline/derive.d.ts.map +1 -1
- package/dist/remediate/contractPipeline/derive.js +8 -16
- package/dist/remediate/contractPipeline/derive.js.map +1 -1
- package/dist/remediate/index.d.ts.map +1 -1
- package/dist/remediate/index.js +43 -2
- package/dist/remediate/index.js.map +1 -1
- package/dist/remediate/intent/intentOrdering.d.ts +13 -5
- package/dist/remediate/intent/intentOrdering.d.ts.map +1 -1
- package/dist/remediate/intent/intentOrdering.js +56 -25
- package/dist/remediate/intent/intentOrdering.js.map +1 -1
- package/dist/remediate/phases/close.d.ts +57 -2
- package/dist/remediate/phases/close.d.ts.map +1 -1
- package/dist/remediate/phases/close.js +260 -36
- package/dist/remediate/phases/close.js.map +1 -1
- package/dist/remediate/state/disposition.d.ts +26 -4
- package/dist/remediate/state/disposition.d.ts.map +1 -1
- package/dist/remediate/state/itemStatus.d.ts +33 -6
- package/dist/remediate/state/itemStatus.d.ts.map +1 -1
- package/dist/remediate/state/itemStatus.js +154 -39
- package/dist/remediate/state/itemStatus.js.map +1 -1
- package/dist/remediate/state/types.d.ts +41 -2
- package/dist/remediate/state/types.d.ts.map +1 -1
- package/dist/remediate/state/types.js.map +1 -1
- package/dist/remediate/steps/contractPipeline.d.ts +287 -15
- package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
- package/dist/remediate/steps/contractPipeline.js +1664 -987
- package/dist/remediate/steps/contractPipeline.js.map +1 -1
- package/dist/remediate/steps/contractPipelinePrompts.js +1 -1
- package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
- package/dist/remediate/steps/dispatch/hostHandoff.d.ts +137 -2
- package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch/hostHandoff.js +713 -59
- package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
- package/dist/remediate/steps/finalGate.d.ts +136 -37
- package/dist/remediate/steps/finalGate.d.ts.map +1 -1
- package/dist/remediate/steps/finalGate.js +120 -86
- package/dist/remediate/steps/finalGate.js.map +1 -1
- package/dist/remediate/steps/gateCommands.d.ts +8 -0
- package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
- package/dist/remediate/steps/gateCommands.js +40 -11
- package/dist/remediate/steps/gateCommands.js.map +1 -1
- package/dist/remediate/steps/nextStep.d.ts +96 -5
- package/dist/remediate/steps/nextStep.d.ts.map +1 -1
- package/dist/remediate/steps/nextStep.js +668 -147
- package/dist/remediate/steps/nextStep.js.map +1 -1
- package/dist/remediate/steps/types.d.ts +4 -1
- package/dist/remediate/steps/types.d.ts.map +1 -1
- package/dist/remediate/steps/types.js +10 -0
- package/dist/remediate/steps/types.js.map +1 -1
- package/dist/remediate/validation/artifacts.d.ts +20 -0
- package/dist/remediate/validation/artifacts.d.ts.map +1 -1
- package/dist/remediate/validation/artifacts.js +239 -13
- package/dist/remediate/validation/artifacts.js.map +1 -1
- package/dist/remediate/validation/contractPipelineGates.d.ts +58 -0
- package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
- package/dist/remediate/validation/contractPipelineGates.js +167 -43
- package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
- package/dist/shared/analyzers/acquisitionEngine.d.ts +72 -25
- package/dist/shared/analyzers/acquisitionEngine.d.ts.map +1 -1
- package/dist/shared/analyzers/acquisitionEngine.js +149 -15
- package/dist/shared/analyzers/acquisitionEngine.js.map +1 -1
- package/dist/shared/analyzers/binaryAcquisition.d.ts +27 -2
- package/dist/shared/analyzers/binaryAcquisition.d.ts.map +1 -1
- package/dist/shared/analyzers/binaryAcquisition.js +149 -43
- package/dist/shared/analyzers/binaryAcquisition.js.map +1 -1
- package/dist/shared/analyzers/candidates.d.ts +9 -8
- package/dist/shared/analyzers/candidates.d.ts.map +1 -1
- package/dist/shared/analyzers/candidates.js +96 -64
- package/dist/shared/analyzers/candidates.js.map +1 -1
- package/dist/shared/analyzers/normalizeExternal.d.ts +40 -12
- package/dist/shared/analyzers/normalizeExternal.d.ts.map +1 -1
- package/dist/shared/analyzers/normalizeExternal.js +74 -18
- package/dist/shared/analyzers/normalizeExternal.js.map +1 -1
- package/dist/shared/analyzers/rubocop.d.ts +14 -13
- package/dist/shared/analyzers/rubocop.d.ts.map +1 -1
- package/dist/shared/analyzers/rubocop.js +29 -6
- package/dist/shared/analyzers/rubocop.js.map +1 -1
- package/dist/shared/analyzers/types.d.ts +196 -13
- package/dist/shared/analyzers/types.d.ts.map +1 -1
- package/dist/shared/analyzers/types.js +122 -9
- package/dist/shared/analyzers/types.js.map +1 -1
- package/dist/shared/decompose/charterClarification.d.ts +12 -1
- package/dist/shared/decompose/charterClarification.d.ts.map +1 -1
- package/dist/shared/decompose/charterClarification.js +11 -4
- package/dist/shared/decompose/charterClarification.js.map +1 -1
- package/dist/shared/decompose/charterExtraction.d.ts +20 -10
- package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
- package/dist/shared/decompose/charterExtraction.js +52 -23
- package/dist/shared/decompose/charterExtraction.js.map +1 -1
- package/dist/shared/decompose/contentCoherence.d.ts +43 -2
- package/dist/shared/decompose/contentCoherence.d.ts.map +1 -1
- package/dist/shared/decompose/contentCoherence.js +109 -5
- package/dist/shared/decompose/contentCoherence.js.map +1 -1
- package/dist/shared/decompose/modularity.d.ts +17 -0
- package/dist/shared/decompose/modularity.d.ts.map +1 -1
- package/dist/shared/decompose/modularity.js +78 -3
- package/dist/shared/decompose/modularity.js.map +1 -1
- package/dist/shared/decompose/workBlockSeams.d.ts +45 -0
- package/dist/shared/decompose/workBlockSeams.d.ts.map +1 -0
- package/dist/shared/decompose/workBlockSeams.js +82 -0
- package/dist/shared/decompose/workBlockSeams.js.map +1 -0
- package/dist/shared/index.d.ts +4 -3
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +4 -2
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/intent/freeFormIntentInterpreter.d.ts +12 -2
- package/dist/shared/intent/freeFormIntentInterpreter.d.ts.map +1 -1
- package/dist/shared/intent/freeFormIntentInterpreter.js +27 -22
- package/dist/shared/intent/freeFormIntentInterpreter.js.map +1 -1
- package/dist/shared/intent/pathScope.d.ts +12 -1
- package/dist/shared/intent/pathScope.d.ts.map +1 -1
- package/dist/shared/intent/pathScope.js +56 -19
- package/dist/shared/intent/pathScope.js.map +1 -1
- package/dist/shared/intent/sharedIntentData.d.ts +19 -1
- package/dist/shared/intent/sharedIntentData.d.ts.map +1 -1
- package/dist/shared/intent/sharedIntentData.js +33 -8
- package/dist/shared/intent/sharedIntentData.js.map +1 -1
- package/dist/shared/io/fileLock.d.ts +12 -0
- package/dist/shared/io/fileLock.d.ts.map +1 -1
- package/dist/shared/io/fileLock.js +48 -5
- package/dist/shared/io/fileLock.js.map +1 -1
- package/dist/shared/io/json.d.ts +13 -0
- package/dist/shared/io/json.d.ts.map +1 -1
- package/dist/shared/io/json.js +19 -1
- package/dist/shared/io/json.js.map +1 -1
- package/dist/shared/reporting/auditDeliverable.d.ts.map +1 -1
- package/dist/shared/reporting/auditDeliverable.js +8 -3
- package/dist/shared/reporting/auditDeliverable.js.map +1 -1
- package/dist/shared/steps/stepEmissionScaffold.d.ts +69 -0
- package/dist/shared/steps/stepEmissionScaffold.d.ts.map +1 -0
- package/dist/shared/steps/stepEmissionScaffold.js +83 -0
- package/dist/shared/steps/stepEmissionScaffold.js.map +1 -0
- package/dist/shared/submission/handRecovery.d.ts.map +1 -1
- package/dist/shared/submission/handRecovery.js +69 -4
- package/dist/shared/submission/handRecovery.js.map +1 -1
- package/dist/shared/submission/submissionIdentity.d.ts +10 -1
- package/dist/shared/submission/submissionIdentity.d.ts.map +1 -1
- package/dist/shared/submission/submissionIdentity.js +12 -3
- package/dist/shared/submission/submissionIdentity.js.map +1 -1
- package/dist/shared/submission/submissionLedger.d.ts +43 -2
- package/dist/shared/submission/submissionLedger.d.ts.map +1 -1
- package/dist/shared/submission/submissionLedger.js +50 -7
- package/dist/shared/submission/submissionLedger.js.map +1 -1
- package/dist/shared/tooling/allowlistedExec.d.ts +9 -0
- package/dist/shared/tooling/allowlistedExec.d.ts.map +1 -1
- package/dist/shared/tooling/allowlistedExec.js +19 -2
- package/dist/shared/tooling/allowlistedExec.js.map +1 -1
- package/dist/shared/types/charter.d.ts +24 -0
- package/dist/shared/types/charter.d.ts.map +1 -1
- package/dist/shared/types/charter.js +9 -0
- package/dist/shared/types/charter.js.map +1 -1
- package/dist/shared/types/finding.d.ts +105 -53
- package/dist/shared/types/finding.d.ts.map +1 -1
- package/dist/shared/types/finding.js +38 -11
- package/dist/shared/types/finding.js.map +1 -1
- package/dist/shared/types/remediationOutcome.d.ts +282 -9
- package/dist/shared/types/remediationOutcome.d.ts.map +1 -1
- package/dist/shared/types/remediationOutcome.js +174 -1
- package/dist/shared/types/remediationOutcome.js.map +1 -1
- package/dist/shared/validation/findingsReport.d.ts.map +1 -1
- package/dist/shared/validation/findingsReport.js +19 -1
- package/dist/shared/validation/findingsReport.js.map +1 -1
- package/package.json +2 -1
- package/remediate-code.mjs +15 -0
- package/wrapper/remediate-code-wrapper-install-hosts.mjs +45 -4
- package/wrapper/remediate-code-wrapper-install-renderers.mjs +12 -2
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { headCommit, FindingSchema, SUBMISSION_ISSUE_CODES, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
|
|
4
|
+
import { headCommit, FindingSchema, SUBMISSION_ISSUE_CODES, SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION, appendSubmissionEvent, assertSubmissionRunId, hashContent, isGitRepo, normalizeRepoPath, readSubmissionDocument, readSubmissionLedger, repoRelativePath, resolveContainedPath, spawnSyncHidden, stableStringify, submissionPathFor, writeJsonFile, } from "audit-tools/shared";
|
|
5
5
|
import { RemediationHostHandoffRecordSchema, RemediationPlanSchema, isClarificationCategory, } from "../../state/types.js";
|
|
6
|
-
import { ITEM_STATUSES, isVerifiedCompleteStatus } from "../../state/itemStatus.js";
|
|
6
|
+
import { ITEM_STATUSES, isTerminalStatus, isVerifiedCompleteStatus, } from "../../state/itemStatus.js";
|
|
7
|
+
import { REMEDIATION_HOST_DECISION_CONTRACT_VERSION as DECISION_CONTRACT_VERSION, REMEDIATION_HOST_RESULT_CONTRACT_VERSION as RESULT_CONTRACT_VERSION, REMEDIATION_HOST_WORKLOAD_CONTRACT_VERSION as WORKLOAD_CONTRACT_VERSION, } from "../types.js";
|
|
7
8
|
const STATE_CONTRACT_VERSION = "remediate-code-state/v1alpha1";
|
|
8
|
-
const WORKLOAD_CONTRACT_VERSION = "remediation-host-workload/v1alpha1";
|
|
9
|
-
const RESULT_CONTRACT_VERSION = "remediation-host-result/v1alpha1";
|
|
10
|
-
const DECISION_CONTRACT_VERSION = "remediation-host-decision/v1alpha1";
|
|
11
9
|
const HANDOFF_RECORD_CONTRACT_VERSION = "remediation-host-handoff-record/v1alpha1";
|
|
12
10
|
/**
|
|
13
11
|
* Remediation's issue vocabulary: the SHARED submission codes plus this draw's
|
|
@@ -31,6 +29,47 @@ export const REMEDIATION_ISSUE_CODES = [
|
|
|
31
29
|
"changed_files_mismatch",
|
|
32
30
|
"run_start_dirty_overlap",
|
|
33
31
|
"required_test_failed",
|
|
32
|
+
/**
|
|
33
|
+
* A required test exceeded its deadline. DISTINCT from `required_test_failed`
|
|
34
|
+
* by code alone: a hung suite and a genuine red are different facts about the
|
|
35
|
+
* work, and telling them apart must not require parsing a joined message.
|
|
36
|
+
*/
|
|
37
|
+
"required_test_timed_out",
|
|
38
|
+
/**
|
|
39
|
+
* A required test produced more output than the capture buffer holds, so the
|
|
40
|
+
* runner killed it. NOT a verdict on the tests: the child was terminated by
|
|
41
|
+
* the capture cap, and whether the suite would have passed is unknown. It has
|
|
42
|
+
* its own code because it was previously indistinguishable from a hang — node
|
|
43
|
+
* kills an over-buffer child with a signal, which the old discriminator read
|
|
44
|
+
* as a deadline miss.
|
|
45
|
+
*/
|
|
46
|
+
"required_test_output_overflow",
|
|
47
|
+
/**
|
|
48
|
+
* A plan block declares a dependency id that exists in NO block of the plan.
|
|
49
|
+
* The block is unschedulable — never level 0 — and the producer bug is named
|
|
50
|
+
* rather than absorbed.
|
|
51
|
+
*/
|
|
52
|
+
"dependency_missing",
|
|
53
|
+
/**
|
|
54
|
+
* A block arrived outside the normalized write-scope / declared-command shape
|
|
55
|
+
* this boundary consumes (artifact:normalized-block-write-scope). Refused, not
|
|
56
|
+
* silently normalized: a silently sorted, deduped or re-rooted write scope
|
|
57
|
+
* hides the producer bug and widens what a host may touch.
|
|
58
|
+
*/
|
|
59
|
+
"block_contract_invalid",
|
|
60
|
+
/**
|
|
61
|
+
* A recovery-mode acceptance could not be marked on the submission ledger, so
|
|
62
|
+
* it was refused. An acceptance that used the relaxation MUST stay
|
|
63
|
+
* distinguishable from a clean one; an unrecordable mark is a refusal, never
|
|
64
|
+
* a silent acceptance.
|
|
65
|
+
*/
|
|
66
|
+
"recovery_unrecorded",
|
|
67
|
+
/**
|
|
68
|
+
* The repository HEAD moved between the recovery verb's unlocked test phase
|
|
69
|
+
* and its locked write phase, so the pre-computed test verdicts describe a
|
|
70
|
+
* tree that is no longer current. The whole recovery aborts.
|
|
71
|
+
*/
|
|
72
|
+
"tree_moved_between_phases",
|
|
34
73
|
];
|
|
35
74
|
const CURRENT_STATE_KEYS = new Set([
|
|
36
75
|
"applied_edit_surface",
|
|
@@ -140,6 +179,207 @@ function normalizeDeclaredPath(root, candidate, label) {
|
|
|
140
179
|
}
|
|
141
180
|
return repoRelativePath(root, candidate, label);
|
|
142
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Does a command leave the declared single-invocation shape?
|
|
184
|
+
*
|
|
185
|
+
* A `targeted_command` is executed VERBATIM through `shell: true` in the
|
|
186
|
+
* repository root, so anything that chains, redirects, substitutes or subshells
|
|
187
|
+
* turns one declared test into arbitrary execution. A flat regex cannot decide
|
|
188
|
+
* this: `node -e "process.exit(0)"` is an ordinary test invocation whose parens
|
|
189
|
+
* are inside quotes, and refusing it would refuse the normal case.
|
|
190
|
+
*
|
|
191
|
+
* So the scan is QUOTE-AWARE — a tiny, fully-owned grammar, not a shell parser.
|
|
192
|
+
* It is deliberately narrower than EITHER shell's grammar, because `shell: true`
|
|
193
|
+
* is TWO grammars and no per-grammar state machine is sound for both: `/bin/sh
|
|
194
|
+
* -c` on posix, `cmd.exe /d /s /c` on win32. Where they disagree, tracking state
|
|
195
|
+
* under one of them MIS-CLASSIFIES the other:
|
|
196
|
+
*
|
|
197
|
+
* - `'` quotes on sh and is an ORDINARY CHARACTER on cmd.exe, so crediting it
|
|
198
|
+
* reads `echo '& evil.exe'` as fully quoted while cmd.exe reads `&` as a
|
|
199
|
+
* command separator and starts a second process.
|
|
200
|
+
* - `\` escapes a quote on sh, so `echo \" & evil \"` de-syncs any
|
|
201
|
+
* double-quote tracking: the scan believes `&` is quoted, sh sees an escaped
|
|
202
|
+
* literal quote and a live separator.
|
|
203
|
+
* - `%VAR%` is expanded by cmd.exe BEFORE the line is split into commands, and
|
|
204
|
+
* expands inside double quotes too, so a `%…%` reference can introduce
|
|
205
|
+
* separators that were never in the scanned string.
|
|
206
|
+
* - `^` is cmd.exe's escape character and de-syncs quote state the same way.
|
|
207
|
+
* - CR and LF are not inert inside cmd.exe's double quotes: LF truncates the
|
|
208
|
+
* command there and CR is deleted outright, so what runs is not what was
|
|
209
|
+
* scanned. Every control character is refused for that reason.
|
|
210
|
+
*
|
|
211
|
+
* What remains is the one construct both shells agree on: a double quote makes
|
|
212
|
+
* the enclosed metacharacters literal. So `' \ ^ % $` and backtick — plus every
|
|
213
|
+
* control character — are refused in EVERY position, quoted or not; the
|
|
214
|
+
* chaining/redirection/grouping set is refused outside double quotes; and an
|
|
215
|
+
* unterminated double quote is itself a refusal, because the rest of the string
|
|
216
|
+
* cannot be classified.
|
|
217
|
+
*
|
|
218
|
+
* The fail direction is REFUSAL. Over-refusing a legitimate command costs a
|
|
219
|
+
* producer-side split, and the producer-side obligation still owns the declared
|
|
220
|
+
* shape (artifact:normalized-block-write-scope); under-admitting one hands a
|
|
221
|
+
* shell an extra process.
|
|
222
|
+
*/
|
|
223
|
+
function leavesDeclaredCommandShape(command) {
|
|
224
|
+
let inDoubleQuotes = false;
|
|
225
|
+
for (const character of command) {
|
|
226
|
+
const code = character.codePointAt(0) ?? 0;
|
|
227
|
+
if (code < 0x20 || code === 0x7f)
|
|
228
|
+
return true;
|
|
229
|
+
if ("'\\^%$`".includes(character))
|
|
230
|
+
return true;
|
|
231
|
+
if (character === '"') {
|
|
232
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (inDoubleQuotes)
|
|
236
|
+
continue;
|
|
237
|
+
if ("&|;<>()".includes(character))
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
return inDoubleQuotes;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A block that arrived outside the shape this boundary CONSUMES.
|
|
244
|
+
*
|
|
245
|
+
* The producer half of the write-scope contract is owned upstream
|
|
246
|
+
* (artifact:normalized-block-write-scope). This is the consumer half, and it
|
|
247
|
+
* exists because absorbing a malformed block is worse than refusing it: an
|
|
248
|
+
* absolute or escaping `touched_files` entry silently widens what a host may
|
|
249
|
+
* write, and a shell-chained `targeted_command` is executed verbatim. Both are
|
|
250
|
+
* producer bugs, and a boundary that normalizes them away means neither ever
|
|
251
|
+
* surfaces. The refusal is CLASSIFIED (`block_contract_invalid`) and names the
|
|
252
|
+
* block, so the bug is attributable to the module that wrote it.
|
|
253
|
+
*/
|
|
254
|
+
class BlockContractError extends Error {
|
|
255
|
+
blockId;
|
|
256
|
+
detail;
|
|
257
|
+
constructor(blockId, detail) {
|
|
258
|
+
super(`block '${blockId}' is outside the normalized write-scope contract: ${detail}`);
|
|
259
|
+
this.blockId = blockId;
|
|
260
|
+
this.detail = detail;
|
|
261
|
+
this.name = "BlockContractError";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Refuse a block whose declared write scope or commands leave the consumed
|
|
266
|
+
* shape. Throws {@link BlockContractError}; callers turn it into a classified
|
|
267
|
+
* issue. Runs BEFORE anything is built from the block, so a refused block never
|
|
268
|
+
* becomes a work item and its commands never run.
|
|
269
|
+
*
|
|
270
|
+
* COVERS THE HANDOFF BOUNDARY ONLY — state the uncovered half rather than let
|
|
271
|
+
* the covered half read as a close. `reverifyBlockedItemAgainstTree` in
|
|
272
|
+
* `src/remediate/phases/triage.ts` spawns the SAME `block.targeted_commands`
|
|
273
|
+
* through `shell: true` with no gate in front of it, so a command this boundary
|
|
274
|
+
* would refuse still reaches a shell on the triage path. Routing that spawn
|
|
275
|
+
* through this gate is tracked as backlog work, not covered here.
|
|
276
|
+
*/
|
|
277
|
+
function assertBlockContract(root, block) {
|
|
278
|
+
for (const raw of block.touched_files) {
|
|
279
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
280
|
+
throw new BlockContractError(block.block_id, "touched_files carries an empty entry");
|
|
281
|
+
}
|
|
282
|
+
if (isAbsolute(raw)) {
|
|
283
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is absolute, not repository-relative`);
|
|
284
|
+
}
|
|
285
|
+
let normalized;
|
|
286
|
+
try {
|
|
287
|
+
normalized = repoRelativePath(root, raw, `${block.block_id}.touched_files[]`);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} does not resolve beneath the repository root`);
|
|
291
|
+
}
|
|
292
|
+
if (normalized !== raw) {
|
|
293
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is not in normalized repo-relative form ` +
|
|
294
|
+
`(${JSON.stringify(normalized)})`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const command of block.targeted_commands ?? []) {
|
|
298
|
+
if (typeof command !== "string" || command.trim().length === 0) {
|
|
299
|
+
throw new BlockContractError(block.block_id, "targeted_commands carries an empty command");
|
|
300
|
+
}
|
|
301
|
+
if (leavesDeclaredCommandShape(command)) {
|
|
302
|
+
throw new BlockContractError(block.block_id, `targeted_command ${JSON.stringify(command)} leaves the declared shape — it chains, ` +
|
|
303
|
+
"redirects or substitutes, and this boundary executes commands verbatim through a shell");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Every block of the plan that cannot be scheduled, with the reason, as
|
|
309
|
+
* classified ingest issues. Two producer bugs live here: a dependency id that
|
|
310
|
+
* resolves to no block (unschedulable forever — see `hostDependencyLevels`),
|
|
311
|
+
* and a block outside the consumed write-scope/command shape.
|
|
312
|
+
*
|
|
313
|
+
* The scanned set is BOUND ∪ UNSETTLED, and it is that union because those are
|
|
314
|
+
* exactly the blocks something else re-derives:
|
|
315
|
+
* - UNSETTLED (any item not terminal) — the blocks still to be scheduled. A
|
|
316
|
+
* settled block's historical shape is not this ingest's business, and
|
|
317
|
+
* reporting it would turn every later ingest into a repeat of the same noise.
|
|
318
|
+
* - BOUND (`block_id` in `host_handoff.work_item_ids`, WHATEVER its items'
|
|
319
|
+
* statuses) — because `parseWorkItem` re-derives every bound item through
|
|
320
|
+
* `buildWorkItem` regardless of status. A status filter alone therefore
|
|
321
|
+
* scanned a DIFFERENT set than the one that can throw: a bound block whose
|
|
322
|
+
* items had all reached terminal still failed the workload parse when its
|
|
323
|
+
* contract was malformed, and surfaced as a bare `workload_invalid` naming no
|
|
324
|
+
* block. Scanning the union is what makes "the block that broke the parse is
|
|
325
|
+
* always named" true rather than usually true.
|
|
326
|
+
*/
|
|
327
|
+
function planBlockIssues(root, state) {
|
|
328
|
+
const blockIds = new Set(state.plan.blocks.map((block) => block.block_id));
|
|
329
|
+
const boundIds = new Set(state.host_handoff?.work_item_ids ?? []);
|
|
330
|
+
const issues = [];
|
|
331
|
+
for (const block of state.plan.blocks) {
|
|
332
|
+
const unsettled = block.items.some((findingId) => {
|
|
333
|
+
const status = state.items[findingId]?.status;
|
|
334
|
+
return status !== undefined && !isTerminalStatus(status);
|
|
335
|
+
});
|
|
336
|
+
if (!unsettled && !boundIds.has(block.block_id))
|
|
337
|
+
continue;
|
|
338
|
+
const missing = (block.dependencies ?? []).filter((dependencyId) => !blockIds.has(dependencyId));
|
|
339
|
+
if (missing.length > 0) {
|
|
340
|
+
issues.push({
|
|
341
|
+
code: "dependency_missing",
|
|
342
|
+
work_item_id: block.block_id,
|
|
343
|
+
message: `block '${block.block_id}' declares ${missing.length === 1 ? "a dependency" : "dependencies"} ` +
|
|
344
|
+
`${missing.map((id) => `'${id}'`).join(", ")} present in no block of the plan, so it can ` +
|
|
345
|
+
"never be dependency-verified and is never scheduled",
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
assertBlockContract(root, block);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (!(error instanceof BlockContractError))
|
|
354
|
+
throw error;
|
|
355
|
+
issues.push({
|
|
356
|
+
code: "block_contract_invalid",
|
|
357
|
+
work_item_id: block.block_id,
|
|
358
|
+
message: error.message,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return issues;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The classified "cannot prepare" message for a producer defect that reached the
|
|
366
|
+
* build path as a throw. Aggregates the whole plan scan so an operator sees
|
|
367
|
+
* EVERY malformed block, not just the first one the builder tripped on.
|
|
368
|
+
*
|
|
369
|
+
* The THROWER is attributed by name, not by whether the scan happened to find
|
|
370
|
+
* anything. Falling back only on an EMPTY scan silently dropped the raised error
|
|
371
|
+
* whenever the scan named some OTHER block — a plan with one ghost dependency
|
|
372
|
+
* elsewhere was enough to make the message describe a block that did not throw
|
|
373
|
+
* and omit the one that did.
|
|
374
|
+
*/
|
|
375
|
+
function cannotPrepareMessage(root, state, raised) {
|
|
376
|
+
const scanned = planBlockIssues(root, state);
|
|
377
|
+
const messages = scanned.map((issue) => issue.message);
|
|
378
|
+
if (!scanned.some((issue) => issue.work_item_id === raised.blockId)) {
|
|
379
|
+
messages.push(raised.message);
|
|
380
|
+
}
|
|
381
|
+
return `Cannot prepare a remediation host workload: ${messages.join("; ")}`;
|
|
382
|
+
}
|
|
143
383
|
function resolveBoundaryPaths(params) {
|
|
144
384
|
assertSubmissionRunId(params.runId, "remediation host run id");
|
|
145
385
|
const root = resolve(params.root);
|
|
@@ -224,7 +464,14 @@ export function hostDependencyLevels(state) {
|
|
|
224
464
|
const permanentlyIneligible = (block) => {
|
|
225
465
|
for (const dependencyId of block.dependencies ?? []) {
|
|
226
466
|
const dependency = blockById.get(dependencyId);
|
|
227
|
-
|
|
467
|
+
// An id that resolves to NO block is not a harmless declaration — it is a
|
|
468
|
+
// prerequisite that can never be verified, so the block can never become
|
|
469
|
+
// eligible. Guarding on `dependency &&` skipped exactly this case, which
|
|
470
|
+
// is the second half of the same hole as the readiness predicate below:
|
|
471
|
+
// closing only one leaves the block reaching the host anyway.
|
|
472
|
+
if (dependency === undefined)
|
|
473
|
+
return true;
|
|
474
|
+
if (!isVerifiedNow(dependency) && !isPending(dependency)) {
|
|
228
475
|
return true;
|
|
229
476
|
}
|
|
230
477
|
}
|
|
@@ -237,7 +484,13 @@ export function hostDependencyLevels(state) {
|
|
|
237
484
|
const ready = remaining.filter((block) => phaseBarrierClear(phaseOf(block)) &&
|
|
238
485
|
(block.dependencies ?? []).every((dependencyId) => {
|
|
239
486
|
const dependency = blockById.get(dependencyId);
|
|
240
|
-
|
|
487
|
+
// DEPENDENCY READINESS REQUIRES EXISTENCE. `!dependency` used to read
|
|
488
|
+
// as "satisfied", so a plan naming a block that does not exist had its
|
|
489
|
+
// dependent placed at level 0 and dispatched with the prerequisite
|
|
490
|
+
// never verified — silently, because no other check looks at it.
|
|
491
|
+
if (dependency === undefined)
|
|
492
|
+
return false;
|
|
493
|
+
if (isVerifiedNow(dependency))
|
|
241
494
|
return true;
|
|
242
495
|
return dependency.items.every((findingId) => isVerifiedCompleteStatus(items[findingId]?.status) ||
|
|
243
496
|
(items[findingId]?.status === "pending" &&
|
|
@@ -292,6 +545,10 @@ function buildFindingAssignments(state, block) {
|
|
|
292
545
|
});
|
|
293
546
|
}
|
|
294
547
|
function buildWorkItem(paths, block, baselineCommit, state) {
|
|
548
|
+
// The consumed-shape gate runs FIRST: a block outside the write-scope /
|
|
549
|
+
// command contract must never become a work item, so nothing downstream can
|
|
550
|
+
// dispatch it or execute its commands.
|
|
551
|
+
assertBlockContract(paths.root, block);
|
|
295
552
|
const allowedFiles = [...new Set(block.touched_files)].map((path) => normalizeDeclaredPath(paths.root, path, `${block.block_id}.touched_files[]`)).sort(compareCodeUnits);
|
|
296
553
|
const resultPath = resultPathFor(paths, block.block_id);
|
|
297
554
|
const requiredTests = [...(block.targeted_commands ?? [])];
|
|
@@ -557,6 +814,31 @@ function gitCommitIsAncestor(root, ancestor, descendant) {
|
|
|
557
814
|
const result = spawnSyncHidden("git", ["merge-base", "--is-ancestor", ancestor, descendant], { cwd: root, encoding: "utf8", shell: false });
|
|
558
815
|
return !result.error && result.status === 0;
|
|
559
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* Is this commit ORPHANED — unreachable from anything the repository still
|
|
819
|
+
* keeps?
|
|
820
|
+
*
|
|
821
|
+
* "Not an ancestor of HEAD" is NOT orphanhood. A baseline sitting on an
|
|
822
|
+
* unmerged `feature` branch while the work landed on trunk fails the ancestry
|
|
823
|
+
* test exactly like a rewritten-away commit does, and treating that as orphaned
|
|
824
|
+
* would hand the relaxation to the ordinary cross-branch case — precisely the
|
|
825
|
+
* stale-worker situation the ancestry check exists to catch.
|
|
826
|
+
*
|
|
827
|
+
* So orphanhood is the CONJUNCTION of two probes: `git for-each-ref --contains`
|
|
828
|
+
* lists every branch/tag/remote ref whose history contains the commit (empty
|
|
829
|
+
* output = no live ref keeps it), and the HEAD ancestry check rides alongside
|
|
830
|
+
* it because a detached HEAD is not a ref `for-each-ref` enumerates and would
|
|
831
|
+
* otherwise scan clean. A failed scan is not evidence of orphanhood — it fails
|
|
832
|
+
* closed, so a git that cannot answer never unlocks the relaxation.
|
|
833
|
+
*/
|
|
834
|
+
function gitCommitIsOrphaned(root, commit) {
|
|
835
|
+
if (gitCommitIsAncestor(root, commit, "HEAD"))
|
|
836
|
+
return false;
|
|
837
|
+
const result = spawnSyncHidden("git", ["for-each-ref", "--contains", commit, "--format=%(refname)"], { cwd: root, encoding: "utf8", shell: false });
|
|
838
|
+
if (result.error || result.status !== 0)
|
|
839
|
+
return false;
|
|
840
|
+
return (result.stdout ?? "").trim().length === 0;
|
|
841
|
+
}
|
|
560
842
|
function gitChangedFilesOfCommit(root, commit) {
|
|
561
843
|
const result = spawnSyncHidden("git", [
|
|
562
844
|
"diff-tree",
|
|
@@ -571,26 +853,246 @@ function gitChangedFilesOfCommit(root, commit) {
|
|
|
571
853
|
return null;
|
|
572
854
|
return [...new Set((result.stdout ?? "").split("\0").filter(Boolean))].sort(compareCodeUnits);
|
|
573
855
|
}
|
|
574
|
-
|
|
856
|
+
/**
|
|
857
|
+
* Per-command deadline. A required test is host-authored and may legitimately be
|
|
858
|
+
* a full suite, so the bound is generous; what changed is that hitting it is now
|
|
859
|
+
* a NAMED outcome instead of an unlabelled failure string.
|
|
860
|
+
*/
|
|
861
|
+
const REQUIRED_TEST_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
862
|
+
/**
|
|
863
|
+
* Captured output is bounded and TAIL-biased: a failing suite's verdict is at
|
|
864
|
+
* the end, and an unbounded capture would put a whole test log into state and
|
|
865
|
+
* into every rendered issue.
|
|
866
|
+
*/
|
|
867
|
+
const CAPTURED_OUTPUT_LIMIT = 4_000;
|
|
868
|
+
/**
|
|
869
|
+
* The spawn's raw capture buffer. Exceeding it does not truncate — node KILLS
|
|
870
|
+
* the child — so the cap is a named constant the `output_overflow` message can
|
|
871
|
+
* quote, rather than a literal buried in the spawn options.
|
|
872
|
+
*/
|
|
873
|
+
const REQUIRED_TEST_MAX_BUFFER_BYTES = 8 * 1_024 * 1_024;
|
|
874
|
+
function tail(value) {
|
|
875
|
+
const text = value ?? "";
|
|
876
|
+
return text.length <= CAPTURED_OUTPUT_LIMIT
|
|
877
|
+
? text
|
|
878
|
+
: `…${text.slice(text.length - CAPTURED_OUTPUT_LIMIT)}`;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Render one classified failure for a host-facing issue message.
|
|
882
|
+
*
|
|
883
|
+
* `output_overflow` says the verdict is UNKNOWN, not that the tests were fine: a
|
|
884
|
+
* child killed at the buffer cap may have been heading for exit 0 or exit 3, and
|
|
885
|
+
* the runner cannot tell which. Either way the item is refused — the honest
|
|
886
|
+
* report is "we could not find out", and it fails closed.
|
|
887
|
+
*
|
|
888
|
+
* A signal-killed child renders the SIGNAL, not `exit null`: `exit_code` is null
|
|
889
|
+
* for every non-exit outcome, so printing it there described nothing.
|
|
890
|
+
*/
|
|
891
|
+
function describeRequiredTestFailure(failure) {
|
|
892
|
+
const head = failure.outcome === "timed_out"
|
|
893
|
+
? `${failure.command} (timed out)`
|
|
894
|
+
: failure.outcome === "output_overflow"
|
|
895
|
+
? `${failure.command} (killed after exceeding the ${String(REQUIRED_TEST_MAX_BUFFER_BYTES)}-byte ` +
|
|
896
|
+
"output buffer — the run ended at the capture cap, so whether the tests pass is UNKNOWN)"
|
|
897
|
+
: failure.outcome === "spawn_error"
|
|
898
|
+
? `${failure.command} (could not be started)`
|
|
899
|
+
: failure.exit_code === null
|
|
900
|
+
? `${failure.command} (terminated by ${failure.signal ?? "an unreported signal"})`
|
|
901
|
+
: `${failure.command} (exit ${String(failure.exit_code)})`;
|
|
902
|
+
const captured = [failure.stdout, failure.stderr]
|
|
903
|
+
.filter((stream) => stream.trim().length > 0)
|
|
904
|
+
.join("\n");
|
|
905
|
+
return captured.length > 0 ? `${head}: ${captured}` : head;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Length-prefixed so the root/command boundary is unambiguous for any path, and
|
|
909
|
+
* printable so the source stays text (a raw separator byte would make the file
|
|
910
|
+
* binary to git and invisible to grep). The root is part of the key because a
|
|
911
|
+
* verdict is a fact about one command in one working tree, and nothing
|
|
912
|
+
* guarantees a single process only ever ingests for one root.
|
|
913
|
+
*/
|
|
914
|
+
function requiredTestVerdictKey(root, command) {
|
|
915
|
+
return `${String(root.length)}:${root}:${command}`;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* The ONE place a required-test command is spawned.
|
|
919
|
+
*
|
|
920
|
+
* `timeoutMs` is a parameter so the deadline is exercisable: a hang is a
|
|
921
|
+
* first-class outcome of this function, and an outcome that can only be reached
|
|
922
|
+
* by waiting ten real minutes is an outcome nothing ever tests.
|
|
923
|
+
*/
|
|
924
|
+
export function runRequiredTest(root, command, timeoutMs = REQUIRED_TEST_TIMEOUT_MS) {
|
|
925
|
+
const result = spawnSync(command, {
|
|
926
|
+
cwd: root,
|
|
927
|
+
shell: true,
|
|
928
|
+
// Captured, not discarded: without it a red ingest reports that something
|
|
929
|
+
// failed and nothing about why.
|
|
930
|
+
encoding: "utf8",
|
|
931
|
+
maxBuffer: REQUIRED_TEST_MAX_BUFFER_BYTES,
|
|
932
|
+
timeout: timeoutMs,
|
|
933
|
+
windowsHide: true,
|
|
934
|
+
});
|
|
935
|
+
const stdout = tail(result.stdout);
|
|
936
|
+
const stderr = tail(result.stderr);
|
|
937
|
+
// The ERROR CODE discriminates, never `signal`. node kills an over-deadline
|
|
938
|
+
// child AND an over-`maxBuffer` child, and an external `kill` sets `signal`
|
|
939
|
+
// too — so `signal !== null` was true for three unrelated facts and reported
|
|
940
|
+
// all of them as a hang, including a command killed purely for printing more
|
|
941
|
+
// than the buffer holds.
|
|
942
|
+
//
|
|
943
|
+
// ASSUMPTION, stated: a deadline miss reports `ETIMEDOUT`. Verified on win32;
|
|
944
|
+
// it is node's documented contract, not a platform quirk this code confirmed
|
|
945
|
+
// everywhere. On a platform that killed a child at the deadline WITHOUT that
|
|
946
|
+
// code, the case degrades to `spawn_error` — a less specific refusal, still a
|
|
947
|
+
// refusal, so the fail direction holds and only the label is lost.
|
|
948
|
+
const code = result.error?.code;
|
|
949
|
+
if (code === "ETIMEDOUT") {
|
|
950
|
+
return { command, outcome: "timed_out", exit_code: null, stdout, stderr };
|
|
951
|
+
}
|
|
952
|
+
if (code === "ENOBUFS") {
|
|
953
|
+
return {
|
|
954
|
+
command,
|
|
955
|
+
outcome: "output_overflow",
|
|
956
|
+
exit_code: null,
|
|
957
|
+
stdout,
|
|
958
|
+
stderr,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
if (result.error) {
|
|
962
|
+
return {
|
|
963
|
+
command,
|
|
964
|
+
outcome: "spawn_error",
|
|
965
|
+
exit_code: null,
|
|
966
|
+
stdout,
|
|
967
|
+
stderr: stderr.length > 0 ? stderr : result.error.message,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
// Killed by something outside this runner (an operator `kill`, an OOM reaper).
|
|
971
|
+
// Reported as FAILED with the signal named: the command did not complete, and
|
|
972
|
+
// calling it a deadline miss would attribute it to a bound this runner set.
|
|
973
|
+
//
|
|
974
|
+
// POSIX-ONLY IN PRACTICE, and UNTESTED for that reason: Windows has no signal
|
|
975
|
+
// delivery to report here — a killed child surfaces as an ordinary non-zero
|
|
976
|
+
// `status` with `signal` null — so this branch is unreachable on the platform
|
|
977
|
+
// this repo runs its suites on, and no test exercises it. It is kept because
|
|
978
|
+
// the runner is OS-agnostic by contract, not because it has been observed.
|
|
979
|
+
if (result.signal !== null) {
|
|
980
|
+
return {
|
|
981
|
+
command,
|
|
982
|
+
outcome: "failed",
|
|
983
|
+
exit_code: null,
|
|
984
|
+
stdout,
|
|
985
|
+
stderr,
|
|
986
|
+
signal: result.signal,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (result.status !== 0) {
|
|
990
|
+
return {
|
|
991
|
+
command,
|
|
992
|
+
outcome: "failed",
|
|
993
|
+
exit_code: result.status,
|
|
994
|
+
stdout,
|
|
995
|
+
stderr,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
function rerunRequiredTests(root, commands,
|
|
1001
|
+
/** `null` on the normal lane — see {@link RemediationRequiredTestVerdicts}. */
|
|
1002
|
+
verdicts) {
|
|
575
1003
|
const failures = [];
|
|
576
1004
|
for (const command of commands) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1005
|
+
if (verdicts) {
|
|
1006
|
+
const verdict = verdicts.get(requiredTestVerdictKey(root, command));
|
|
1007
|
+
if (verdict === undefined) {
|
|
1008
|
+
failures.push({
|
|
1009
|
+
command,
|
|
1010
|
+
outcome: "spawn_error",
|
|
1011
|
+
exit_code: null,
|
|
1012
|
+
stdout: "",
|
|
1013
|
+
stderr: "no pre-computed verdict — refusing to spawn a test while the state lock is held",
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
else if (verdict !== null) {
|
|
1017
|
+
failures.push(verdict);
|
|
1018
|
+
}
|
|
1019
|
+
continue;
|
|
586
1020
|
}
|
|
1021
|
+
const failure = runRequiredTest(root, command);
|
|
1022
|
+
if (failure !== null)
|
|
1023
|
+
failures.push(failure);
|
|
587
1024
|
}
|
|
588
1025
|
return failures;
|
|
589
1026
|
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The classified issue for a set of required-test failures. An ENVIRONMENT fact
|
|
1029
|
+
* anywhere in the set wins over a red sibling, timeout first: a hung or
|
|
1030
|
+
* buffer-killed suite is the fact that explains the ingest, and burying it under
|
|
1031
|
+
* a sibling's exit code is exactly the conflation the code split exists to end.
|
|
1032
|
+
* Only a set where every failure is a genuine non-zero exit reads as
|
|
1033
|
+
* `required_test_failed`.
|
|
1034
|
+
*/
|
|
1035
|
+
function requiredTestIssue(workItem, failures) {
|
|
1036
|
+
return {
|
|
1037
|
+
code: failures.some((failure) => failure.outcome === "timed_out")
|
|
1038
|
+
? "required_test_timed_out"
|
|
1039
|
+
: failures.some((failure) => failure.outcome === "output_overflow")
|
|
1040
|
+
? "required_test_output_overflow"
|
|
1041
|
+
: "required_test_failed",
|
|
1042
|
+
work_item_id: workItem.id,
|
|
1043
|
+
result_path: workItem.result_path,
|
|
1044
|
+
message: `mechanical required-test rerun failed: ${failures
|
|
1045
|
+
.map(describeRequiredTestFailure)
|
|
1046
|
+
.join("; ")}`,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Run every required-test command a recovery ingest could need, ONCE each, and
|
|
1051
|
+
* return the finished verdict table. Call this OUTSIDE the state lock — that is
|
|
1052
|
+
* the entire point (see {@link RemediationRequiredTestVerdicts}).
|
|
1053
|
+
*
|
|
1054
|
+
* Candidates are the work items with at least one still-pending finding whose
|
|
1055
|
+
* result file is present and parses as JSON; an item with no result file is
|
|
1056
|
+
* refused before its tests would ever run, so spawning for it is pure cost. The
|
|
1057
|
+
* filter is deliberately generous otherwise — over-inclusion costs one spawn,
|
|
1058
|
+
* while under-inclusion becomes a fail-closed refusal of a good result.
|
|
1059
|
+
*/
|
|
1060
|
+
export async function precomputeRecoveryTestVerdicts(params) {
|
|
1061
|
+
const state = parseCurrentState(params.state);
|
|
1062
|
+
if (!state)
|
|
1063
|
+
return "unsupported_retired_state";
|
|
1064
|
+
const paths = resolveBoundaryPaths(params);
|
|
1065
|
+
const verdicts = new Map();
|
|
1066
|
+
const workloadRead = await readSubmissionDocument(paths.workloadPath);
|
|
1067
|
+
if (workloadRead.kind !== "value")
|
|
1068
|
+
return verdicts;
|
|
1069
|
+
const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
|
|
1070
|
+
if (!workload)
|
|
1071
|
+
return verdicts;
|
|
1072
|
+
const commands = [];
|
|
1073
|
+
for (const workItem of workload.work_items) {
|
|
1074
|
+
const hasPending = workItem.finding_ids.some((findingId) => state.items[findingId]?.status === "pending");
|
|
1075
|
+
if (!hasPending)
|
|
1076
|
+
continue;
|
|
1077
|
+
const absoluteResultPath = resolveContainedPath(paths.root, workItem.result_path, `result path for ${workItem.id}`);
|
|
1078
|
+
const resultRead = await readSubmissionDocument(absoluteResultPath);
|
|
1079
|
+
if (resultRead.kind !== "value")
|
|
1080
|
+
continue;
|
|
1081
|
+
for (const command of workItem.required_tests) {
|
|
1082
|
+
if (!commands.includes(command))
|
|
1083
|
+
commands.push(command);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
for (const command of commands) {
|
|
1087
|
+
verdicts.set(requiredTestVerdictKey(paths.root, command), runRequiredTest(paths.root, command));
|
|
1088
|
+
}
|
|
1089
|
+
return verdicts;
|
|
1090
|
+
}
|
|
590
1091
|
function corroborateHostResult(params) {
|
|
591
|
-
const { root, state, workItem, result } = params;
|
|
1092
|
+
const { root, state, workItem, result, verdicts } = params;
|
|
592
1093
|
const baseline = workItem.baseline_commit;
|
|
593
1094
|
const landed = result.commit_evidence.after;
|
|
1095
|
+
let usedRecovery = false;
|
|
594
1096
|
if (!gitCommitExists(root, baseline) || !gitCommitExists(root, landed)) {
|
|
595
1097
|
return {
|
|
596
1098
|
ok: false,
|
|
@@ -599,11 +1101,32 @@ function corroborateHostResult(params) {
|
|
|
599
1101
|
};
|
|
600
1102
|
}
|
|
601
1103
|
if (!gitCommitIsAncestor(root, baseline, landed)) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1104
|
+
if (!params.recovery) {
|
|
1105
|
+
return {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
code: "baseline_not_ancestor",
|
|
1108
|
+
message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
// The relaxation is precondition-bound: it applies ONLY when the trusted
|
|
1112
|
+
// baseline is genuinely ORPHANED — contained by no ref AND unreachable from
|
|
1113
|
+
// HEAD (see gitCommitIsOrphaned). That is the one state in which no landed
|
|
1114
|
+
// commit could ever descend from it, so the item is unacceptable under
|
|
1115
|
+
// every preparable binding. A baseline the repository still keeps — on an
|
|
1116
|
+
// unmerged branch, a tag, a remote ref, or HEAD itself — is a HEALTHY
|
|
1117
|
+
// binding, and a landed commit that does not descend from it is exactly the
|
|
1118
|
+
// stale-worker case the ancestry check exists to catch; recovery refuses it
|
|
1119
|
+
// identically to the normal lane.
|
|
1120
|
+
if (!gitCommitIsOrphaned(root, baseline)) {
|
|
1121
|
+
return {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
code: "baseline_not_ancestor",
|
|
1124
|
+
message: "the trusted workload baseline is not an ancestor of the claimed landed commit, " +
|
|
1125
|
+
"and the baseline is NOT orphaned (a ref still contains it, or it is reachable " +
|
|
1126
|
+
"from HEAD), so the stale-worker protection stands and recovery cannot waive it",
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
usedRecovery = true;
|
|
607
1130
|
}
|
|
608
1131
|
if (!gitCommitIsAncestor(root, landed, "HEAD")) {
|
|
609
1132
|
return {
|
|
@@ -636,15 +1159,12 @@ function corroborateHostResult(params) {
|
|
|
636
1159
|
message: `landed files overlap pre-existing run-start dirt: ${dirtyOverlap.join(", ")}`,
|
|
637
1160
|
};
|
|
638
1161
|
}
|
|
639
|
-
const failedTests = rerunRequiredTests(root, workItem.required_tests);
|
|
1162
|
+
const failedTests = rerunRequiredTests(root, workItem.required_tests, verdicts);
|
|
640
1163
|
if (failedTests.length > 0) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
code: "required_test_failed",
|
|
644
|
-
message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
|
|
645
|
-
};
|
|
1164
|
+
const issue = requiredTestIssue(workItem, failedTests);
|
|
1165
|
+
return { ok: false, code: issue.code, message: issue.message };
|
|
646
1166
|
}
|
|
647
|
-
return { ok: true, changedFiles: actualFiles };
|
|
1167
|
+
return { ok: true, changedFiles: actualFiles, usedRecovery };
|
|
648
1168
|
}
|
|
649
1169
|
export async function prepareRemediationHostHandoff(params) {
|
|
650
1170
|
const state = parseCurrentState(params.state);
|
|
@@ -665,17 +1185,39 @@ export async function prepareRemediationHostHandoff(params) {
|
|
|
665
1185
|
}
|
|
666
1186
|
}
|
|
667
1187
|
const baselineCommit = existingRecord?.baseline_commit ?? params.baselineCommit;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1188
|
+
let workload;
|
|
1189
|
+
try {
|
|
1190
|
+
workload = buildCanonicalWorkload({
|
|
1191
|
+
paths,
|
|
1192
|
+
state,
|
|
1193
|
+
runId: params.runId,
|
|
1194
|
+
baselineCommit,
|
|
1195
|
+
...(existingRecord
|
|
1196
|
+
? { workItemIds: existingRecord.work_item_ids }
|
|
1197
|
+
: {}),
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
// A malformed block ON the frontier reaches this as a raw BlockContractError
|
|
1202
|
+
// — an uncaught throw whose stack says nothing about which producer wrote the
|
|
1203
|
+
// bad block, and which every retry reproduces. Re-raised in the SAME
|
|
1204
|
+
// classified aggregate form the empty-workload branch below uses, so both
|
|
1205
|
+
// producer-defect exits read alike.
|
|
1206
|
+
if (!(error instanceof BlockContractError))
|
|
1207
|
+
throw error;
|
|
1208
|
+
throw new Error(cannotPrepareMessage(paths.root, state, error));
|
|
1209
|
+
}
|
|
677
1210
|
if (workload.work_items.length === 0) {
|
|
678
|
-
|
|
1211
|
+
// Name the producer defect when it is the cause. An empty level 0 that is
|
|
1212
|
+
// really "every candidate block declares a prerequisite that does not
|
|
1213
|
+
// exist" used to surface as a bare "empty workload", sending the operator
|
|
1214
|
+
// to look at scheduling rather than at the plan.
|
|
1215
|
+
const blocked = planBlockIssues(paths.root, state);
|
|
1216
|
+
throw new Error(blocked.length === 0
|
|
1217
|
+
? "Cannot prepare an empty remediation host workload"
|
|
1218
|
+
: `Cannot prepare a remediation host workload: ${blocked
|
|
1219
|
+
.map((issue) => issue.message)
|
|
1220
|
+
.join("; ")}`);
|
|
679
1221
|
}
|
|
680
1222
|
const workloadDigest = hostWorkloadSha256(workload);
|
|
681
1223
|
if (existingRecord &&
|
|
@@ -697,6 +1239,48 @@ export async function prepareRemediationHostHandoff(params) {
|
|
|
697
1239
|
handoff_record: handoffRecord,
|
|
698
1240
|
};
|
|
699
1241
|
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Consume the host's landed results for the trusted workload.
|
|
1244
|
+
*
|
|
1245
|
+
* ## The `recovery` option, and what it actually buys
|
|
1246
|
+
*
|
|
1247
|
+
* A trusted binding can be stranded: a post-prepare `git commit --amend` (or
|
|
1248
|
+
* any history rewrite) re-mints the baseline the workload was bound to, leaving
|
|
1249
|
+
* it ORPHANED — contained by no ref and unreachable from HEAD. Every commit the
|
|
1250
|
+
* host then lands sits on the re-minted line, so `baseline → landed` ancestry is
|
|
1251
|
+
* false for all of them, and re-preparing does not help: a fresh binding must be
|
|
1252
|
+
* minted at HEAD, and HEAD is a DESCENDANT of the landed work. The items are
|
|
1253
|
+
* unacceptable under every preparable binding, with real, reachable,
|
|
1254
|
+
* correctly-scoped commits on disk.
|
|
1255
|
+
*
|
|
1256
|
+
* `recovery` waives ONE check — baseline→landed ancestry — and only when
|
|
1257
|
+
* the baseline is genuinely orphaned by BOTH probes in `gitCommitIsOrphaned`: no
|
|
1258
|
+
* branch/tag/remote ref contains it, and it is not reachable from HEAD. A
|
|
1259
|
+
* baseline the repository still keeps (an unmerged feature branch, a tag, a
|
|
1260
|
+
* remote ref) also fails the ancestry test when work lands elsewhere, and that
|
|
1261
|
+
* is the ordinary stale-worker case — recovery refuses it. Every other
|
|
1262
|
+
* corroboration check runs unchanged (the landed commit exists and is reachable
|
|
1263
|
+
* from HEAD; its mechanically derived changed files exactly equal
|
|
1264
|
+
* `changed_files` and lie within the prompt-bound `allowed_files`; no overlap
|
|
1265
|
+
* with run-start dirt; the required tests rerun green), `parseResult` stays
|
|
1266
|
+
* fully strict, and dependency/phase eligibility is enforced exactly as on the
|
|
1267
|
+
* normal lane.
|
|
1268
|
+
*
|
|
1269
|
+
* RESIDUAL RISK, stated plainly: under an orphaned baseline the evidence bar
|
|
1270
|
+
* drops to "the claimed commit is reachable from the current green HEAD and
|
|
1271
|
+
* matches this item's scope exactly". That CANNOT prove the work was built on
|
|
1272
|
+
* the trusted baseline — a commit landed from a stale or unrelated starting
|
|
1273
|
+
* tree satisfies it as long as its own file set stays in scope. Ancestry is the
|
|
1274
|
+
* check that would have caught that, and it is the one being waived. Which is
|
|
1275
|
+
* precisely why the relaxation costs an explicit operator verb, is gated on the
|
|
1276
|
+
* orphan precondition, and is marked `accepted_via_recovery` on the submission
|
|
1277
|
+
* ledger before the item lands — and why the normal lane keeps the full check.
|
|
1278
|
+
*
|
|
1279
|
+
* In recovery mode this function performs NO required-test spawn: the verdicts
|
|
1280
|
+
* arrive pre-computed on the `recovery` option, and a command missing from that
|
|
1281
|
+
* table is treated as failed. Its caller runs the tests first, unlocked — see
|
|
1282
|
+
* `recoverIngestHostResults`.
|
|
1283
|
+
*/
|
|
700
1284
|
export async function ingestRemediationHostResults(params) {
|
|
701
1285
|
const state = parseCurrentState(params.state);
|
|
702
1286
|
if (!state)
|
|
@@ -725,33 +1309,48 @@ export async function ingestRemediationHostResults(params) {
|
|
|
725
1309
|
state: nextState,
|
|
726
1310
|
};
|
|
727
1311
|
}
|
|
1312
|
+
// Producer-side plan defects are reported BEFORE the workload is parsed: a
|
|
1313
|
+
// block with an unresolvable dependency or an unnormalized write scope makes
|
|
1314
|
+
// the whole workload fail to re-derive, and `workload_invalid` alone would
|
|
1315
|
+
// name the symptom while hiding which block caused it.
|
|
1316
|
+
//
|
|
1317
|
+
// REPORTED, never fatal. A defect in a NON-frontier block says nothing about a
|
|
1318
|
+
// frontier item's landed result, and refusing the whole ingest over one made
|
|
1319
|
+
// the run unadvanceable: every ingest returned zero acceptances, `next-step`
|
|
1320
|
+
// read `state_changed: false` and re-emitted the same items against the same
|
|
1321
|
+
// malformed plan, forever. The frontier's OWN defect is enforced elsewhere and
|
|
1322
|
+
// does not rely on this: `parseWorkItem` re-derives each bound work item
|
|
1323
|
+
// through `buildWorkItem`, whose `assertBlockContract` throws, so a malformed
|
|
1324
|
+
// bound block fails the workload parse and its commands never run.
|
|
1325
|
+
issues.push(...planBlockIssues(paths.root, state));
|
|
728
1326
|
if (isGitRepo(paths.root) && !state.host_handoff) {
|
|
1327
|
+
issues.push({
|
|
1328
|
+
code: "trusted_binding_missing",
|
|
1329
|
+
message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
|
|
1330
|
+
});
|
|
729
1331
|
return {
|
|
730
1332
|
accepted_count: 0,
|
|
731
1333
|
completed_work_item_ids: [],
|
|
732
1334
|
pending_work_item_ids: [],
|
|
733
|
-
issues
|
|
734
|
-
{
|
|
735
|
-
code: "trusted_binding_missing",
|
|
736
|
-
message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
|
|
737
|
-
},
|
|
738
|
-
],
|
|
1335
|
+
issues,
|
|
739
1336
|
state_changed: false,
|
|
740
1337
|
state: nextState,
|
|
741
1338
|
};
|
|
742
1339
|
}
|
|
743
1340
|
const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
|
|
744
1341
|
if (!workload) {
|
|
1342
|
+
// Accumulated, not replaced: when a block-contract defect is WHY the
|
|
1343
|
+
// canonical re-derivation failed, the block-attributed issue is the only
|
|
1344
|
+
// thing that names the cause.
|
|
1345
|
+
issues.push({
|
|
1346
|
+
code: "workload_invalid",
|
|
1347
|
+
message: "the workload does not match its canonical state shape and persisted digest binding",
|
|
1348
|
+
});
|
|
745
1349
|
return {
|
|
746
1350
|
accepted_count: 0,
|
|
747
1351
|
completed_work_item_ids: [],
|
|
748
1352
|
pending_work_item_ids: state.host_handoff?.work_item_ids ?? [],
|
|
749
|
-
issues
|
|
750
|
-
{
|
|
751
|
-
code: "workload_invalid",
|
|
752
|
-
message: "the workload does not match its canonical state shape and persisted digest binding",
|
|
753
|
-
},
|
|
754
|
-
],
|
|
1353
|
+
issues,
|
|
755
1354
|
state_changed: false,
|
|
756
1355
|
state: nextState,
|
|
757
1356
|
};
|
|
@@ -759,6 +1358,15 @@ export async function ingestRemediationHostResults(params) {
|
|
|
759
1358
|
const eligibleIds = new Set((hostDependencyLevels(state)[0] ?? []).map((block) => block.block_id));
|
|
760
1359
|
const resultIds = new Set();
|
|
761
1360
|
const completed = [];
|
|
1361
|
+
// Recovery-only answer table; the normal lane gets null and spawns exactly as
|
|
1362
|
+
// it always has. See RemediationRequiredTestVerdicts.
|
|
1363
|
+
const requiredTestVerdicts = params.recovery?.requiredTestVerdicts ?? null;
|
|
1364
|
+
// Lazily loaded on the first recovery-marked acceptance: the recovery marks
|
|
1365
|
+
// the ledger ALREADY carries. A crash between the append and the state write
|
|
1366
|
+
// leaves a mark whose item is still pending, and the natural response is to
|
|
1367
|
+
// re-run the verb — which must converge, not accumulate a second record of
|
|
1368
|
+
// the same acceptance.
|
|
1369
|
+
let recordedRecoveryMarks = null;
|
|
762
1370
|
const landedFiles = new Set(nextState.applied_edit_surface ?? []);
|
|
763
1371
|
const requireRepositoryCorroboration = state.host_handoff !== undefined || isGitRepo(paths.root);
|
|
764
1372
|
for (const workItem of workload.work_items) {
|
|
@@ -820,14 +1428,9 @@ export async function ingestRemediationHostResults(params) {
|
|
|
820
1428
|
const result = parsed.result;
|
|
821
1429
|
const outcome = result.outcome;
|
|
822
1430
|
if (outcome.status === "resolved_no_change") {
|
|
823
|
-
const failedTests = rerunRequiredTests(paths.root, workItem.required_tests);
|
|
1431
|
+
const failedTests = rerunRequiredTests(paths.root, workItem.required_tests, requiredTestVerdicts);
|
|
824
1432
|
if (failedTests.length > 0) {
|
|
825
|
-
issues.push(
|
|
826
|
-
code: "required_test_failed",
|
|
827
|
-
work_item_id: workItem.id,
|
|
828
|
-
result_path: workItem.result_path,
|
|
829
|
-
message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
|
|
830
|
-
});
|
|
1433
|
+
issues.push(requiredTestIssue(workItem, failedTests));
|
|
831
1434
|
continue;
|
|
832
1435
|
}
|
|
833
1436
|
}
|
|
@@ -874,6 +1477,8 @@ export async function ingestRemediationHostResults(params) {
|
|
|
874
1477
|
state,
|
|
875
1478
|
workItem,
|
|
876
1479
|
result,
|
|
1480
|
+
verdicts: requiredTestVerdicts,
|
|
1481
|
+
recovery: params.recovery !== undefined,
|
|
877
1482
|
});
|
|
878
1483
|
if (!corroborated.ok) {
|
|
879
1484
|
issues.push({
|
|
@@ -884,6 +1489,55 @@ export async function ingestRemediationHostResults(params) {
|
|
|
884
1489
|
});
|
|
885
1490
|
continue;
|
|
886
1491
|
}
|
|
1492
|
+
if (corroborated.usedRecovery) {
|
|
1493
|
+
// No acceptance without a record. The mark goes down BEFORE the item is
|
|
1494
|
+
// marked resolved, and an append that throws refuses this item rather
|
|
1495
|
+
// than landing an acceptance the ledger cannot account for — the run
|
|
1496
|
+
// must never read as one that never drifted. The refusal is per item:
|
|
1497
|
+
// an unwritable ledger is not a reason to discard the whole ingest.
|
|
1498
|
+
try {
|
|
1499
|
+
recordedRecoveryMarks ??= (await readSubmissionLedger(paths.artifactsDir)).filter((event) => event.kind === "accepted_via_recovery");
|
|
1500
|
+
// The mark's identity is (run, item, LANDED COMMIT), not just
|
|
1501
|
+
// (run, item): an item re-opened and later re-accepted from a
|
|
1502
|
+
// DIFFERENT landing is a different relaxed acceptance and earns its
|
|
1503
|
+
// own record. Only a retry of the SAME landing is a duplicate. The
|
|
1504
|
+
// landed sha is matched inside the message because the shared event
|
|
1505
|
+
// contract carries no commit field, and a 40-hex sha this writer
|
|
1506
|
+
// itself emitted is an unambiguous token to match on.
|
|
1507
|
+
const landedCommit = result.commit_evidence.after;
|
|
1508
|
+
const alreadyMarked = recordedRecoveryMarks.some((event) => event.run_id === params.runId &&
|
|
1509
|
+
event.submission_id === workItem.id &&
|
|
1510
|
+
(event.message ?? "").includes(landedCommit));
|
|
1511
|
+
if (!alreadyMarked) {
|
|
1512
|
+
const event = {
|
|
1513
|
+
contract_version: SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION,
|
|
1514
|
+
run_id: params.runId,
|
|
1515
|
+
submission_id: workItem.id,
|
|
1516
|
+
lane: workItem.id,
|
|
1517
|
+
kind: "accepted_via_recovery",
|
|
1518
|
+
// Derived from what was actually probed, never asserted: the
|
|
1519
|
+
// baseline was found in no ref and unreachable from HEAD.
|
|
1520
|
+
message: `accepted under recovery: the trusted baseline ${workItem.baseline_commit} is ` +
|
|
1521
|
+
"contained by no ref and unreachable from HEAD, so landed commit " +
|
|
1522
|
+
`${landedCommit} was corroborated against HEAD and this ` +
|
|
1523
|
+
"item's bound scope instead of against baseline ancestry",
|
|
1524
|
+
recorded_at: new Date().toISOString(),
|
|
1525
|
+
};
|
|
1526
|
+
await appendSubmissionEvent(paths.artifactsDir, event);
|
|
1527
|
+
recordedRecoveryMarks.push(event);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
issues.push({
|
|
1532
|
+
code: "recovery_unrecorded",
|
|
1533
|
+
work_item_id: workItem.id,
|
|
1534
|
+
result_path: workItem.result_path,
|
|
1535
|
+
message: "the recovery acceptance could not be recorded on the submission ledger, so it " +
|
|
1536
|
+
`was refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
1537
|
+
});
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
887
1541
|
acceptedFiles = corroborated.changedFiles;
|
|
888
1542
|
}
|
|
889
1543
|
const completedAt = new Date().toISOString();
|