audit-tools 0.42.1 → 0.44.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/laneSubmissions.d.ts +2 -2
- package/dist/audit/cli/laneSubmissions.js +2 -2
- 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 -36
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +34 -77
- 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/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/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/phases/triage.d.ts.map +1 -1
- package/dist/remediate/phases/triage.js +14 -2
- package/dist/remediate/phases/triage.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 +296 -15
- package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
- package/dist/remediate/steps/contractPipeline.js +1783 -1001
- package/dist/remediate/steps/contractPipeline.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 +657 -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 -3
- package/dist/remediate/steps/types.d.ts.map +1 -1
- package/dist/remediate/steps/types.js +10 -2
- package/dist/remediate/steps/types.js.map +1 -1
- package/dist/remediate/validation/artifacts.d.ts +20 -2
- package/dist/remediate/validation/artifacts.d.ts.map +1 -1
- package/dist/remediate/validation/artifacts.js +224 -130
- 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 +143 -27
- 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/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +5 -0
- 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/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/tooling/commandShape.d.ts +21 -0
- package/dist/shared/tooling/commandShape.d.ts.map +1 -0
- package/dist/shared/tooling/commandShape.js +120 -0
- package/dist/shared/tooling/commandShape.js.map +1 -0
- 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/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/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, commandLeavesDeclaredShape, 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,151 @@ function normalizeDeclaredPath(root, candidate, label) {
|
|
|
140
179
|
}
|
|
141
180
|
return repoRelativePath(root, candidate, label);
|
|
142
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* A block that arrived outside the shape this boundary CONSUMES.
|
|
184
|
+
*
|
|
185
|
+
* The producer half of the write-scope contract is owned upstream
|
|
186
|
+
* (artifact:normalized-block-write-scope). This is the consumer half, and it
|
|
187
|
+
* exists because absorbing a malformed block is worse than refusing it: an
|
|
188
|
+
* absolute or escaping `touched_files` entry silently widens what a host may
|
|
189
|
+
* write, and a shell-chained `targeted_command` is executed verbatim. Both are
|
|
190
|
+
* producer bugs, and a boundary that normalizes them away means neither ever
|
|
191
|
+
* surfaces. The refusal is CLASSIFIED (`block_contract_invalid`) and names the
|
|
192
|
+
* block, so the bug is attributable to the module that wrote it.
|
|
193
|
+
*/
|
|
194
|
+
class BlockContractError extends Error {
|
|
195
|
+
blockId;
|
|
196
|
+
detail;
|
|
197
|
+
constructor(blockId, detail) {
|
|
198
|
+
super(`block '${blockId}' is outside the normalized write-scope contract: ${detail}`);
|
|
199
|
+
this.blockId = blockId;
|
|
200
|
+
this.detail = detail;
|
|
201
|
+
this.name = "BlockContractError";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Refuse a block whose declared write scope or commands leave the consumed
|
|
206
|
+
* shape. Throws {@link BlockContractError}; callers turn it into a classified
|
|
207
|
+
* issue. Runs BEFORE anything is built from the block, so a refused block never
|
|
208
|
+
* becomes a work item and its commands never run.
|
|
209
|
+
*
|
|
210
|
+
* COVERS THE HANDOFF BOUNDARY ONLY — state the uncovered half rather than let
|
|
211
|
+
* the covered half read as a close. `reverifyBlockedItemAgainstTree` in
|
|
212
|
+
* `src/remediate/phases/triage.ts` spawns the SAME `block.targeted_commands`
|
|
213
|
+
* through `shell: true` with no gate in front of it, so a command this boundary
|
|
214
|
+
* would refuse still reaches a shell on the triage path. Routing that spawn
|
|
215
|
+
* through this gate is tracked as backlog work, not covered here.
|
|
216
|
+
*/
|
|
217
|
+
function assertBlockContract(root, block) {
|
|
218
|
+
for (const raw of block.touched_files) {
|
|
219
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
220
|
+
throw new BlockContractError(block.block_id, "touched_files carries an empty entry");
|
|
221
|
+
}
|
|
222
|
+
if (isAbsolute(raw)) {
|
|
223
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is absolute, not repository-relative`);
|
|
224
|
+
}
|
|
225
|
+
let normalized;
|
|
226
|
+
try {
|
|
227
|
+
normalized = repoRelativePath(root, raw, `${block.block_id}.touched_files[]`);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} does not resolve beneath the repository root`);
|
|
231
|
+
}
|
|
232
|
+
if (normalized !== raw) {
|
|
233
|
+
throw new BlockContractError(block.block_id, `touched_files entry ${JSON.stringify(raw)} is not in normalized repo-relative form ` +
|
|
234
|
+
`(${JSON.stringify(normalized)})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const command of block.targeted_commands ?? []) {
|
|
238
|
+
if (typeof command !== "string" || command.trim().length === 0) {
|
|
239
|
+
throw new BlockContractError(block.block_id, "targeted_commands carries an empty command");
|
|
240
|
+
}
|
|
241
|
+
// THE declared-command-shape rule (`audit-tools/shared`), not a local copy:
|
|
242
|
+
// the producer that promotes these commands and the triage path that also
|
|
243
|
+
// spawns them ask the same predicate, so a command cannot clear one boundary
|
|
244
|
+
// and dead-end at another.
|
|
245
|
+
if (commandLeavesDeclaredShape(command)) {
|
|
246
|
+
throw new BlockContractError(block.block_id, `targeted_command ${JSON.stringify(command)} leaves the declared shape — it chains, ` +
|
|
247
|
+
"redirects or substitutes, and this boundary executes commands verbatim through a shell");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Every block of the plan that cannot be scheduled, with the reason, as
|
|
253
|
+
* classified ingest issues. Two producer bugs live here: a dependency id that
|
|
254
|
+
* resolves to no block (unschedulable forever — see `hostDependencyLevels`),
|
|
255
|
+
* and a block outside the consumed write-scope/command shape.
|
|
256
|
+
*
|
|
257
|
+
* The scanned set is BOUND ∪ UNSETTLED, and it is that union because those are
|
|
258
|
+
* exactly the blocks something else re-derives:
|
|
259
|
+
* - UNSETTLED (any item not terminal) — the blocks still to be scheduled. A
|
|
260
|
+
* settled block's historical shape is not this ingest's business, and
|
|
261
|
+
* reporting it would turn every later ingest into a repeat of the same noise.
|
|
262
|
+
* - BOUND (`block_id` in `host_handoff.work_item_ids`, WHATEVER its items'
|
|
263
|
+
* statuses) — because `parseWorkItem` re-derives every bound item through
|
|
264
|
+
* `buildWorkItem` regardless of status. A status filter alone therefore
|
|
265
|
+
* scanned a DIFFERENT set than the one that can throw: a bound block whose
|
|
266
|
+
* items had all reached terminal still failed the workload parse when its
|
|
267
|
+
* contract was malformed, and surfaced as a bare `workload_invalid` naming no
|
|
268
|
+
* block. Scanning the union is what makes "the block that broke the parse is
|
|
269
|
+
* always named" true rather than usually true.
|
|
270
|
+
*/
|
|
271
|
+
function planBlockIssues(root, state) {
|
|
272
|
+
const blockIds = new Set(state.plan.blocks.map((block) => block.block_id));
|
|
273
|
+
const boundIds = new Set(state.host_handoff?.work_item_ids ?? []);
|
|
274
|
+
const issues = [];
|
|
275
|
+
for (const block of state.plan.blocks) {
|
|
276
|
+
const unsettled = block.items.some((findingId) => {
|
|
277
|
+
const status = state.items[findingId]?.status;
|
|
278
|
+
return status !== undefined && !isTerminalStatus(status);
|
|
279
|
+
});
|
|
280
|
+
if (!unsettled && !boundIds.has(block.block_id))
|
|
281
|
+
continue;
|
|
282
|
+
const missing = (block.dependencies ?? []).filter((dependencyId) => !blockIds.has(dependencyId));
|
|
283
|
+
if (missing.length > 0) {
|
|
284
|
+
issues.push({
|
|
285
|
+
code: "dependency_missing",
|
|
286
|
+
work_item_id: block.block_id,
|
|
287
|
+
message: `block '${block.block_id}' declares ${missing.length === 1 ? "a dependency" : "dependencies"} ` +
|
|
288
|
+
`${missing.map((id) => `'${id}'`).join(", ")} present in no block of the plan, so it can ` +
|
|
289
|
+
"never be dependency-verified and is never scheduled",
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
assertBlockContract(root, block);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (!(error instanceof BlockContractError))
|
|
298
|
+
throw error;
|
|
299
|
+
issues.push({
|
|
300
|
+
code: "block_contract_invalid",
|
|
301
|
+
work_item_id: block.block_id,
|
|
302
|
+
message: error.message,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return issues;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The classified "cannot prepare" message for a producer defect that reached the
|
|
310
|
+
* build path as a throw. Aggregates the whole plan scan so an operator sees
|
|
311
|
+
* EVERY malformed block, not just the first one the builder tripped on.
|
|
312
|
+
*
|
|
313
|
+
* The THROWER is attributed by name, not by whether the scan happened to find
|
|
314
|
+
* anything. Falling back only on an EMPTY scan silently dropped the raised error
|
|
315
|
+
* whenever the scan named some OTHER block — a plan with one ghost dependency
|
|
316
|
+
* elsewhere was enough to make the message describe a block that did not throw
|
|
317
|
+
* and omit the one that did.
|
|
318
|
+
*/
|
|
319
|
+
function cannotPrepareMessage(root, state, raised) {
|
|
320
|
+
const scanned = planBlockIssues(root, state);
|
|
321
|
+
const messages = scanned.map((issue) => issue.message);
|
|
322
|
+
if (!scanned.some((issue) => issue.work_item_id === raised.blockId)) {
|
|
323
|
+
messages.push(raised.message);
|
|
324
|
+
}
|
|
325
|
+
return `Cannot prepare a remediation host workload: ${messages.join("; ")}`;
|
|
326
|
+
}
|
|
143
327
|
function resolveBoundaryPaths(params) {
|
|
144
328
|
assertSubmissionRunId(params.runId, "remediation host run id");
|
|
145
329
|
const root = resolve(params.root);
|
|
@@ -224,7 +408,14 @@ export function hostDependencyLevels(state) {
|
|
|
224
408
|
const permanentlyIneligible = (block) => {
|
|
225
409
|
for (const dependencyId of block.dependencies ?? []) {
|
|
226
410
|
const dependency = blockById.get(dependencyId);
|
|
227
|
-
|
|
411
|
+
// An id that resolves to NO block is not a harmless declaration — it is a
|
|
412
|
+
// prerequisite that can never be verified, so the block can never become
|
|
413
|
+
// eligible. Guarding on `dependency &&` skipped exactly this case, which
|
|
414
|
+
// is the second half of the same hole as the readiness predicate below:
|
|
415
|
+
// closing only one leaves the block reaching the host anyway.
|
|
416
|
+
if (dependency === undefined)
|
|
417
|
+
return true;
|
|
418
|
+
if (!isVerifiedNow(dependency) && !isPending(dependency)) {
|
|
228
419
|
return true;
|
|
229
420
|
}
|
|
230
421
|
}
|
|
@@ -237,7 +428,13 @@ export function hostDependencyLevels(state) {
|
|
|
237
428
|
const ready = remaining.filter((block) => phaseBarrierClear(phaseOf(block)) &&
|
|
238
429
|
(block.dependencies ?? []).every((dependencyId) => {
|
|
239
430
|
const dependency = blockById.get(dependencyId);
|
|
240
|
-
|
|
431
|
+
// DEPENDENCY READINESS REQUIRES EXISTENCE. `!dependency` used to read
|
|
432
|
+
// as "satisfied", so a plan naming a block that does not exist had its
|
|
433
|
+
// dependent placed at level 0 and dispatched with the prerequisite
|
|
434
|
+
// never verified — silently, because no other check looks at it.
|
|
435
|
+
if (dependency === undefined)
|
|
436
|
+
return false;
|
|
437
|
+
if (isVerifiedNow(dependency))
|
|
241
438
|
return true;
|
|
242
439
|
return dependency.items.every((findingId) => isVerifiedCompleteStatus(items[findingId]?.status) ||
|
|
243
440
|
(items[findingId]?.status === "pending" &&
|
|
@@ -292,6 +489,10 @@ function buildFindingAssignments(state, block) {
|
|
|
292
489
|
});
|
|
293
490
|
}
|
|
294
491
|
function buildWorkItem(paths, block, baselineCommit, state) {
|
|
492
|
+
// The consumed-shape gate runs FIRST: a block outside the write-scope /
|
|
493
|
+
// command contract must never become a work item, so nothing downstream can
|
|
494
|
+
// dispatch it or execute its commands.
|
|
495
|
+
assertBlockContract(paths.root, block);
|
|
295
496
|
const allowedFiles = [...new Set(block.touched_files)].map((path) => normalizeDeclaredPath(paths.root, path, `${block.block_id}.touched_files[]`)).sort(compareCodeUnits);
|
|
296
497
|
const resultPath = resultPathFor(paths, block.block_id);
|
|
297
498
|
const requiredTests = [...(block.targeted_commands ?? [])];
|
|
@@ -557,6 +758,31 @@ function gitCommitIsAncestor(root, ancestor, descendant) {
|
|
|
557
758
|
const result = spawnSyncHidden("git", ["merge-base", "--is-ancestor", ancestor, descendant], { cwd: root, encoding: "utf8", shell: false });
|
|
558
759
|
return !result.error && result.status === 0;
|
|
559
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Is this commit ORPHANED — unreachable from anything the repository still
|
|
763
|
+
* keeps?
|
|
764
|
+
*
|
|
765
|
+
* "Not an ancestor of HEAD" is NOT orphanhood. A baseline sitting on an
|
|
766
|
+
* unmerged `feature` branch while the work landed on trunk fails the ancestry
|
|
767
|
+
* test exactly like a rewritten-away commit does, and treating that as orphaned
|
|
768
|
+
* would hand the relaxation to the ordinary cross-branch case — precisely the
|
|
769
|
+
* stale-worker situation the ancestry check exists to catch.
|
|
770
|
+
*
|
|
771
|
+
* So orphanhood is the CONJUNCTION of two probes: `git for-each-ref --contains`
|
|
772
|
+
* lists every branch/tag/remote ref whose history contains the commit (empty
|
|
773
|
+
* output = no live ref keeps it), and the HEAD ancestry check rides alongside
|
|
774
|
+
* it because a detached HEAD is not a ref `for-each-ref` enumerates and would
|
|
775
|
+
* otherwise scan clean. A failed scan is not evidence of orphanhood — it fails
|
|
776
|
+
* closed, so a git that cannot answer never unlocks the relaxation.
|
|
777
|
+
*/
|
|
778
|
+
function gitCommitIsOrphaned(root, commit) {
|
|
779
|
+
if (gitCommitIsAncestor(root, commit, "HEAD"))
|
|
780
|
+
return false;
|
|
781
|
+
const result = spawnSyncHidden("git", ["for-each-ref", "--contains", commit, "--format=%(refname)"], { cwd: root, encoding: "utf8", shell: false });
|
|
782
|
+
if (result.error || result.status !== 0)
|
|
783
|
+
return false;
|
|
784
|
+
return (result.stdout ?? "").trim().length === 0;
|
|
785
|
+
}
|
|
560
786
|
function gitChangedFilesOfCommit(root, commit) {
|
|
561
787
|
const result = spawnSyncHidden("git", [
|
|
562
788
|
"diff-tree",
|
|
@@ -571,26 +797,246 @@ function gitChangedFilesOfCommit(root, commit) {
|
|
|
571
797
|
return null;
|
|
572
798
|
return [...new Set((result.stdout ?? "").split("\0").filter(Boolean))].sort(compareCodeUnits);
|
|
573
799
|
}
|
|
574
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Per-command deadline. A required test is host-authored and may legitimately be
|
|
802
|
+
* a full suite, so the bound is generous; what changed is that hitting it is now
|
|
803
|
+
* a NAMED outcome instead of an unlabelled failure string.
|
|
804
|
+
*/
|
|
805
|
+
const REQUIRED_TEST_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
806
|
+
/**
|
|
807
|
+
* Captured output is bounded and TAIL-biased: a failing suite's verdict is at
|
|
808
|
+
* the end, and an unbounded capture would put a whole test log into state and
|
|
809
|
+
* into every rendered issue.
|
|
810
|
+
*/
|
|
811
|
+
const CAPTURED_OUTPUT_LIMIT = 4_000;
|
|
812
|
+
/**
|
|
813
|
+
* The spawn's raw capture buffer. Exceeding it does not truncate — node KILLS
|
|
814
|
+
* the child — so the cap is a named constant the `output_overflow` message can
|
|
815
|
+
* quote, rather than a literal buried in the spawn options.
|
|
816
|
+
*/
|
|
817
|
+
const REQUIRED_TEST_MAX_BUFFER_BYTES = 8 * 1_024 * 1_024;
|
|
818
|
+
function tail(value) {
|
|
819
|
+
const text = value ?? "";
|
|
820
|
+
return text.length <= CAPTURED_OUTPUT_LIMIT
|
|
821
|
+
? text
|
|
822
|
+
: `…${text.slice(text.length - CAPTURED_OUTPUT_LIMIT)}`;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Render one classified failure for a host-facing issue message.
|
|
826
|
+
*
|
|
827
|
+
* `output_overflow` says the verdict is UNKNOWN, not that the tests were fine: a
|
|
828
|
+
* child killed at the buffer cap may have been heading for exit 0 or exit 3, and
|
|
829
|
+
* the runner cannot tell which. Either way the item is refused — the honest
|
|
830
|
+
* report is "we could not find out", and it fails closed.
|
|
831
|
+
*
|
|
832
|
+
* A signal-killed child renders the SIGNAL, not `exit null`: `exit_code` is null
|
|
833
|
+
* for every non-exit outcome, so printing it there described nothing.
|
|
834
|
+
*/
|
|
835
|
+
function describeRequiredTestFailure(failure) {
|
|
836
|
+
const head = failure.outcome === "timed_out"
|
|
837
|
+
? `${failure.command} (timed out)`
|
|
838
|
+
: failure.outcome === "output_overflow"
|
|
839
|
+
? `${failure.command} (killed after exceeding the ${String(REQUIRED_TEST_MAX_BUFFER_BYTES)}-byte ` +
|
|
840
|
+
"output buffer — the run ended at the capture cap, so whether the tests pass is UNKNOWN)"
|
|
841
|
+
: failure.outcome === "spawn_error"
|
|
842
|
+
? `${failure.command} (could not be started)`
|
|
843
|
+
: failure.exit_code === null
|
|
844
|
+
? `${failure.command} (terminated by ${failure.signal ?? "an unreported signal"})`
|
|
845
|
+
: `${failure.command} (exit ${String(failure.exit_code)})`;
|
|
846
|
+
const captured = [failure.stdout, failure.stderr]
|
|
847
|
+
.filter((stream) => stream.trim().length > 0)
|
|
848
|
+
.join("\n");
|
|
849
|
+
return captured.length > 0 ? `${head}: ${captured}` : head;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Length-prefixed so the root/command boundary is unambiguous for any path, and
|
|
853
|
+
* printable so the source stays text (a raw separator byte would make the file
|
|
854
|
+
* binary to git and invisible to grep). The root is part of the key because a
|
|
855
|
+
* verdict is a fact about one command in one working tree, and nothing
|
|
856
|
+
* guarantees a single process only ever ingests for one root.
|
|
857
|
+
*/
|
|
858
|
+
function requiredTestVerdictKey(root, command) {
|
|
859
|
+
return `${String(root.length)}:${root}:${command}`;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The ONE place a required-test command is spawned.
|
|
863
|
+
*
|
|
864
|
+
* `timeoutMs` is a parameter so the deadline is exercisable: a hang is a
|
|
865
|
+
* first-class outcome of this function, and an outcome that can only be reached
|
|
866
|
+
* by waiting ten real minutes is an outcome nothing ever tests.
|
|
867
|
+
*/
|
|
868
|
+
export function runRequiredTest(root, command, timeoutMs = REQUIRED_TEST_TIMEOUT_MS) {
|
|
869
|
+
const result = spawnSync(command, {
|
|
870
|
+
cwd: root,
|
|
871
|
+
shell: true,
|
|
872
|
+
// Captured, not discarded: without it a red ingest reports that something
|
|
873
|
+
// failed and nothing about why.
|
|
874
|
+
encoding: "utf8",
|
|
875
|
+
maxBuffer: REQUIRED_TEST_MAX_BUFFER_BYTES,
|
|
876
|
+
timeout: timeoutMs,
|
|
877
|
+
windowsHide: true,
|
|
878
|
+
});
|
|
879
|
+
const stdout = tail(result.stdout);
|
|
880
|
+
const stderr = tail(result.stderr);
|
|
881
|
+
// The ERROR CODE discriminates, never `signal`. node kills an over-deadline
|
|
882
|
+
// child AND an over-`maxBuffer` child, and an external `kill` sets `signal`
|
|
883
|
+
// too — so `signal !== null` was true for three unrelated facts and reported
|
|
884
|
+
// all of them as a hang, including a command killed purely for printing more
|
|
885
|
+
// than the buffer holds.
|
|
886
|
+
//
|
|
887
|
+
// ASSUMPTION, stated: a deadline miss reports `ETIMEDOUT`. Verified on win32;
|
|
888
|
+
// it is node's documented contract, not a platform quirk this code confirmed
|
|
889
|
+
// everywhere. On a platform that killed a child at the deadline WITHOUT that
|
|
890
|
+
// code, the case degrades to `spawn_error` — a less specific refusal, still a
|
|
891
|
+
// refusal, so the fail direction holds and only the label is lost.
|
|
892
|
+
const code = result.error?.code;
|
|
893
|
+
if (code === "ETIMEDOUT") {
|
|
894
|
+
return { command, outcome: "timed_out", exit_code: null, stdout, stderr };
|
|
895
|
+
}
|
|
896
|
+
if (code === "ENOBUFS") {
|
|
897
|
+
return {
|
|
898
|
+
command,
|
|
899
|
+
outcome: "output_overflow",
|
|
900
|
+
exit_code: null,
|
|
901
|
+
stdout,
|
|
902
|
+
stderr,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if (result.error) {
|
|
906
|
+
return {
|
|
907
|
+
command,
|
|
908
|
+
outcome: "spawn_error",
|
|
909
|
+
exit_code: null,
|
|
910
|
+
stdout,
|
|
911
|
+
stderr: stderr.length > 0 ? stderr : result.error.message,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// Killed by something outside this runner (an operator `kill`, an OOM reaper).
|
|
915
|
+
// Reported as FAILED with the signal named: the command did not complete, and
|
|
916
|
+
// calling it a deadline miss would attribute it to a bound this runner set.
|
|
917
|
+
//
|
|
918
|
+
// POSIX-ONLY IN PRACTICE, and UNTESTED for that reason: Windows has no signal
|
|
919
|
+
// delivery to report here — a killed child surfaces as an ordinary non-zero
|
|
920
|
+
// `status` with `signal` null — so this branch is unreachable on the platform
|
|
921
|
+
// this repo runs its suites on, and no test exercises it. It is kept because
|
|
922
|
+
// the runner is OS-agnostic by contract, not because it has been observed.
|
|
923
|
+
if (result.signal !== null) {
|
|
924
|
+
return {
|
|
925
|
+
command,
|
|
926
|
+
outcome: "failed",
|
|
927
|
+
exit_code: null,
|
|
928
|
+
stdout,
|
|
929
|
+
stderr,
|
|
930
|
+
signal: result.signal,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (result.status !== 0) {
|
|
934
|
+
return {
|
|
935
|
+
command,
|
|
936
|
+
outcome: "failed",
|
|
937
|
+
exit_code: result.status,
|
|
938
|
+
stdout,
|
|
939
|
+
stderr,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
function rerunRequiredTests(root, commands,
|
|
945
|
+
/** `null` on the normal lane — see {@link RemediationRequiredTestVerdicts}. */
|
|
946
|
+
verdicts) {
|
|
575
947
|
const failures = [];
|
|
576
948
|
for (const command of commands) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
949
|
+
if (verdicts) {
|
|
950
|
+
const verdict = verdicts.get(requiredTestVerdictKey(root, command));
|
|
951
|
+
if (verdict === undefined) {
|
|
952
|
+
failures.push({
|
|
953
|
+
command,
|
|
954
|
+
outcome: "spawn_error",
|
|
955
|
+
exit_code: null,
|
|
956
|
+
stdout: "",
|
|
957
|
+
stderr: "no pre-computed verdict — refusing to spawn a test while the state lock is held",
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
else if (verdict !== null) {
|
|
961
|
+
failures.push(verdict);
|
|
962
|
+
}
|
|
963
|
+
continue;
|
|
586
964
|
}
|
|
965
|
+
const failure = runRequiredTest(root, command);
|
|
966
|
+
if (failure !== null)
|
|
967
|
+
failures.push(failure);
|
|
587
968
|
}
|
|
588
969
|
return failures;
|
|
589
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* The classified issue for a set of required-test failures. An ENVIRONMENT fact
|
|
973
|
+
* anywhere in the set wins over a red sibling, timeout first: a hung or
|
|
974
|
+
* buffer-killed suite is the fact that explains the ingest, and burying it under
|
|
975
|
+
* a sibling's exit code is exactly the conflation the code split exists to end.
|
|
976
|
+
* Only a set where every failure is a genuine non-zero exit reads as
|
|
977
|
+
* `required_test_failed`.
|
|
978
|
+
*/
|
|
979
|
+
function requiredTestIssue(workItem, failures) {
|
|
980
|
+
return {
|
|
981
|
+
code: failures.some((failure) => failure.outcome === "timed_out")
|
|
982
|
+
? "required_test_timed_out"
|
|
983
|
+
: failures.some((failure) => failure.outcome === "output_overflow")
|
|
984
|
+
? "required_test_output_overflow"
|
|
985
|
+
: "required_test_failed",
|
|
986
|
+
work_item_id: workItem.id,
|
|
987
|
+
result_path: workItem.result_path,
|
|
988
|
+
message: `mechanical required-test rerun failed: ${failures
|
|
989
|
+
.map(describeRequiredTestFailure)
|
|
990
|
+
.join("; ")}`,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Run every required-test command a recovery ingest could need, ONCE each, and
|
|
995
|
+
* return the finished verdict table. Call this OUTSIDE the state lock — that is
|
|
996
|
+
* the entire point (see {@link RemediationRequiredTestVerdicts}).
|
|
997
|
+
*
|
|
998
|
+
* Candidates are the work items with at least one still-pending finding whose
|
|
999
|
+
* result file is present and parses as JSON; an item with no result file is
|
|
1000
|
+
* refused before its tests would ever run, so spawning for it is pure cost. The
|
|
1001
|
+
* filter is deliberately generous otherwise — over-inclusion costs one spawn,
|
|
1002
|
+
* while under-inclusion becomes a fail-closed refusal of a good result.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function precomputeRecoveryTestVerdicts(params) {
|
|
1005
|
+
const state = parseCurrentState(params.state);
|
|
1006
|
+
if (!state)
|
|
1007
|
+
return "unsupported_retired_state";
|
|
1008
|
+
const paths = resolveBoundaryPaths(params);
|
|
1009
|
+
const verdicts = new Map();
|
|
1010
|
+
const workloadRead = await readSubmissionDocument(paths.workloadPath);
|
|
1011
|
+
if (workloadRead.kind !== "value")
|
|
1012
|
+
return verdicts;
|
|
1013
|
+
const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
|
|
1014
|
+
if (!workload)
|
|
1015
|
+
return verdicts;
|
|
1016
|
+
const commands = [];
|
|
1017
|
+
for (const workItem of workload.work_items) {
|
|
1018
|
+
const hasPending = workItem.finding_ids.some((findingId) => state.items[findingId]?.status === "pending");
|
|
1019
|
+
if (!hasPending)
|
|
1020
|
+
continue;
|
|
1021
|
+
const absoluteResultPath = resolveContainedPath(paths.root, workItem.result_path, `result path for ${workItem.id}`);
|
|
1022
|
+
const resultRead = await readSubmissionDocument(absoluteResultPath);
|
|
1023
|
+
if (resultRead.kind !== "value")
|
|
1024
|
+
continue;
|
|
1025
|
+
for (const command of workItem.required_tests) {
|
|
1026
|
+
if (!commands.includes(command))
|
|
1027
|
+
commands.push(command);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
for (const command of commands) {
|
|
1031
|
+
verdicts.set(requiredTestVerdictKey(paths.root, command), runRequiredTest(paths.root, command));
|
|
1032
|
+
}
|
|
1033
|
+
return verdicts;
|
|
1034
|
+
}
|
|
590
1035
|
function corroborateHostResult(params) {
|
|
591
|
-
const { root, state, workItem, result } = params;
|
|
1036
|
+
const { root, state, workItem, result, verdicts } = params;
|
|
592
1037
|
const baseline = workItem.baseline_commit;
|
|
593
1038
|
const landed = result.commit_evidence.after;
|
|
1039
|
+
let usedRecovery = false;
|
|
594
1040
|
if (!gitCommitExists(root, baseline) || !gitCommitExists(root, landed)) {
|
|
595
1041
|
return {
|
|
596
1042
|
ok: false,
|
|
@@ -599,11 +1045,32 @@ function corroborateHostResult(params) {
|
|
|
599
1045
|
};
|
|
600
1046
|
}
|
|
601
1047
|
if (!gitCommitIsAncestor(root, baseline, landed)) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1048
|
+
if (!params.recovery) {
|
|
1049
|
+
return {
|
|
1050
|
+
ok: false,
|
|
1051
|
+
code: "baseline_not_ancestor",
|
|
1052
|
+
message: "the trusted workload baseline is not an ancestor of the claimed landed commit",
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
// The relaxation is precondition-bound: it applies ONLY when the trusted
|
|
1056
|
+
// baseline is genuinely ORPHANED — contained by no ref AND unreachable from
|
|
1057
|
+
// HEAD (see gitCommitIsOrphaned). That is the one state in which no landed
|
|
1058
|
+
// commit could ever descend from it, so the item is unacceptable under
|
|
1059
|
+
// every preparable binding. A baseline the repository still keeps — on an
|
|
1060
|
+
// unmerged branch, a tag, a remote ref, or HEAD itself — is a HEALTHY
|
|
1061
|
+
// binding, and a landed commit that does not descend from it is exactly the
|
|
1062
|
+
// stale-worker case the ancestry check exists to catch; recovery refuses it
|
|
1063
|
+
// identically to the normal lane.
|
|
1064
|
+
if (!gitCommitIsOrphaned(root, baseline)) {
|
|
1065
|
+
return {
|
|
1066
|
+
ok: false,
|
|
1067
|
+
code: "baseline_not_ancestor",
|
|
1068
|
+
message: "the trusted workload baseline is not an ancestor of the claimed landed commit, " +
|
|
1069
|
+
"and the baseline is NOT orphaned (a ref still contains it, or it is reachable " +
|
|
1070
|
+
"from HEAD), so the stale-worker protection stands and recovery cannot waive it",
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
usedRecovery = true;
|
|
607
1074
|
}
|
|
608
1075
|
if (!gitCommitIsAncestor(root, landed, "HEAD")) {
|
|
609
1076
|
return {
|
|
@@ -636,15 +1103,12 @@ function corroborateHostResult(params) {
|
|
|
636
1103
|
message: `landed files overlap pre-existing run-start dirt: ${dirtyOverlap.join(", ")}`,
|
|
637
1104
|
};
|
|
638
1105
|
}
|
|
639
|
-
const failedTests = rerunRequiredTests(root, workItem.required_tests);
|
|
1106
|
+
const failedTests = rerunRequiredTests(root, workItem.required_tests, verdicts);
|
|
640
1107
|
if (failedTests.length > 0) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
code: "required_test_failed",
|
|
644
|
-
message: `mechanical required-test rerun failed: ${failedTests.join("; ")}`,
|
|
645
|
-
};
|
|
1108
|
+
const issue = requiredTestIssue(workItem, failedTests);
|
|
1109
|
+
return { ok: false, code: issue.code, message: issue.message };
|
|
646
1110
|
}
|
|
647
|
-
return { ok: true, changedFiles: actualFiles };
|
|
1111
|
+
return { ok: true, changedFiles: actualFiles, usedRecovery };
|
|
648
1112
|
}
|
|
649
1113
|
export async function prepareRemediationHostHandoff(params) {
|
|
650
1114
|
const state = parseCurrentState(params.state);
|
|
@@ -665,17 +1129,39 @@ export async function prepareRemediationHostHandoff(params) {
|
|
|
665
1129
|
}
|
|
666
1130
|
}
|
|
667
1131
|
const baselineCommit = existingRecord?.baseline_commit ?? params.baselineCommit;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1132
|
+
let workload;
|
|
1133
|
+
try {
|
|
1134
|
+
workload = buildCanonicalWorkload({
|
|
1135
|
+
paths,
|
|
1136
|
+
state,
|
|
1137
|
+
runId: params.runId,
|
|
1138
|
+
baselineCommit,
|
|
1139
|
+
...(existingRecord
|
|
1140
|
+
? { workItemIds: existingRecord.work_item_ids }
|
|
1141
|
+
: {}),
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
// A malformed block ON the frontier reaches this as a raw BlockContractError
|
|
1146
|
+
// — an uncaught throw whose stack says nothing about which producer wrote the
|
|
1147
|
+
// bad block, and which every retry reproduces. Re-raised in the SAME
|
|
1148
|
+
// classified aggregate form the empty-workload branch below uses, so both
|
|
1149
|
+
// producer-defect exits read alike.
|
|
1150
|
+
if (!(error instanceof BlockContractError))
|
|
1151
|
+
throw error;
|
|
1152
|
+
throw new Error(cannotPrepareMessage(paths.root, state, error));
|
|
1153
|
+
}
|
|
677
1154
|
if (workload.work_items.length === 0) {
|
|
678
|
-
|
|
1155
|
+
// Name the producer defect when it is the cause. An empty level 0 that is
|
|
1156
|
+
// really "every candidate block declares a prerequisite that does not
|
|
1157
|
+
// exist" used to surface as a bare "empty workload", sending the operator
|
|
1158
|
+
// to look at scheduling rather than at the plan.
|
|
1159
|
+
const blocked = planBlockIssues(paths.root, state);
|
|
1160
|
+
throw new Error(blocked.length === 0
|
|
1161
|
+
? "Cannot prepare an empty remediation host workload"
|
|
1162
|
+
: `Cannot prepare a remediation host workload: ${blocked
|
|
1163
|
+
.map((issue) => issue.message)
|
|
1164
|
+
.join("; ")}`);
|
|
679
1165
|
}
|
|
680
1166
|
const workloadDigest = hostWorkloadSha256(workload);
|
|
681
1167
|
if (existingRecord &&
|
|
@@ -697,6 +1183,48 @@ export async function prepareRemediationHostHandoff(params) {
|
|
|
697
1183
|
handoff_record: handoffRecord,
|
|
698
1184
|
};
|
|
699
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Consume the host's landed results for the trusted workload.
|
|
1188
|
+
*
|
|
1189
|
+
* ## The `recovery` option, and what it actually buys
|
|
1190
|
+
*
|
|
1191
|
+
* A trusted binding can be stranded: a post-prepare `git commit --amend` (or
|
|
1192
|
+
* any history rewrite) re-mints the baseline the workload was bound to, leaving
|
|
1193
|
+
* it ORPHANED — contained by no ref and unreachable from HEAD. Every commit the
|
|
1194
|
+
* host then lands sits on the re-minted line, so `baseline → landed` ancestry is
|
|
1195
|
+
* false for all of them, and re-preparing does not help: a fresh binding must be
|
|
1196
|
+
* minted at HEAD, and HEAD is a DESCENDANT of the landed work. The items are
|
|
1197
|
+
* unacceptable under every preparable binding, with real, reachable,
|
|
1198
|
+
* correctly-scoped commits on disk.
|
|
1199
|
+
*
|
|
1200
|
+
* `recovery` waives ONE check — baseline→landed ancestry — and only when
|
|
1201
|
+
* the baseline is genuinely orphaned by BOTH probes in `gitCommitIsOrphaned`: no
|
|
1202
|
+
* branch/tag/remote ref contains it, and it is not reachable from HEAD. A
|
|
1203
|
+
* baseline the repository still keeps (an unmerged feature branch, a tag, a
|
|
1204
|
+
* remote ref) also fails the ancestry test when work lands elsewhere, and that
|
|
1205
|
+
* is the ordinary stale-worker case — recovery refuses it. Every other
|
|
1206
|
+
* corroboration check runs unchanged (the landed commit exists and is reachable
|
|
1207
|
+
* from HEAD; its mechanically derived changed files exactly equal
|
|
1208
|
+
* `changed_files` and lie within the prompt-bound `allowed_files`; no overlap
|
|
1209
|
+
* with run-start dirt; the required tests rerun green), `parseResult` stays
|
|
1210
|
+
* fully strict, and dependency/phase eligibility is enforced exactly as on the
|
|
1211
|
+
* normal lane.
|
|
1212
|
+
*
|
|
1213
|
+
* RESIDUAL RISK, stated plainly: under an orphaned baseline the evidence bar
|
|
1214
|
+
* drops to "the claimed commit is reachable from the current green HEAD and
|
|
1215
|
+
* matches this item's scope exactly". That CANNOT prove the work was built on
|
|
1216
|
+
* the trusted baseline — a commit landed from a stale or unrelated starting
|
|
1217
|
+
* tree satisfies it as long as its own file set stays in scope. Ancestry is the
|
|
1218
|
+
* check that would have caught that, and it is the one being waived. Which is
|
|
1219
|
+
* precisely why the relaxation costs an explicit operator verb, is gated on the
|
|
1220
|
+
* orphan precondition, and is marked `accepted_via_recovery` on the submission
|
|
1221
|
+
* ledger before the item lands — and why the normal lane keeps the full check.
|
|
1222
|
+
*
|
|
1223
|
+
* In recovery mode this function performs NO required-test spawn: the verdicts
|
|
1224
|
+
* arrive pre-computed on the `recovery` option, and a command missing from that
|
|
1225
|
+
* table is treated as failed. Its caller runs the tests first, unlocked — see
|
|
1226
|
+
* `recoverIngestHostResults`.
|
|
1227
|
+
*/
|
|
700
1228
|
export async function ingestRemediationHostResults(params) {
|
|
701
1229
|
const state = parseCurrentState(params.state);
|
|
702
1230
|
if (!state)
|
|
@@ -725,33 +1253,48 @@ export async function ingestRemediationHostResults(params) {
|
|
|
725
1253
|
state: nextState,
|
|
726
1254
|
};
|
|
727
1255
|
}
|
|
1256
|
+
// Producer-side plan defects are reported BEFORE the workload is parsed: a
|
|
1257
|
+
// block with an unresolvable dependency or an unnormalized write scope makes
|
|
1258
|
+
// the whole workload fail to re-derive, and `workload_invalid` alone would
|
|
1259
|
+
// name the symptom while hiding which block caused it.
|
|
1260
|
+
//
|
|
1261
|
+
// REPORTED, never fatal. A defect in a NON-frontier block says nothing about a
|
|
1262
|
+
// frontier item's landed result, and refusing the whole ingest over one made
|
|
1263
|
+
// the run unadvanceable: every ingest returned zero acceptances, `next-step`
|
|
1264
|
+
// read `state_changed: false` and re-emitted the same items against the same
|
|
1265
|
+
// malformed plan, forever. The frontier's OWN defect is enforced elsewhere and
|
|
1266
|
+
// does not rely on this: `parseWorkItem` re-derives each bound work item
|
|
1267
|
+
// through `buildWorkItem`, whose `assertBlockContract` throws, so a malformed
|
|
1268
|
+
// bound block fails the workload parse and its commands never run.
|
|
1269
|
+
issues.push(...planBlockIssues(paths.root, state));
|
|
728
1270
|
if (isGitRepo(paths.root) && !state.host_handoff) {
|
|
1271
|
+
issues.push({
|
|
1272
|
+
code: "trusted_binding_missing",
|
|
1273
|
+
message: "a git-backed remediation workload requires the tool-owned host_handoff state binding",
|
|
1274
|
+
});
|
|
729
1275
|
return {
|
|
730
1276
|
accepted_count: 0,
|
|
731
1277
|
completed_work_item_ids: [],
|
|
732
1278
|
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
|
-
],
|
|
1279
|
+
issues,
|
|
739
1280
|
state_changed: false,
|
|
740
1281
|
state: nextState,
|
|
741
1282
|
};
|
|
742
1283
|
}
|
|
743
1284
|
const workload = parseWorkload(workloadRead.value, paths, params.runId, state);
|
|
744
1285
|
if (!workload) {
|
|
1286
|
+
// Accumulated, not replaced: when a block-contract defect is WHY the
|
|
1287
|
+
// canonical re-derivation failed, the block-attributed issue is the only
|
|
1288
|
+
// thing that names the cause.
|
|
1289
|
+
issues.push({
|
|
1290
|
+
code: "workload_invalid",
|
|
1291
|
+
message: "the workload does not match its canonical state shape and persisted digest binding",
|
|
1292
|
+
});
|
|
745
1293
|
return {
|
|
746
1294
|
accepted_count: 0,
|
|
747
1295
|
completed_work_item_ids: [],
|
|
748
1296
|
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
|
-
],
|
|
1297
|
+
issues,
|
|
755
1298
|
state_changed: false,
|
|
756
1299
|
state: nextState,
|
|
757
1300
|
};
|
|
@@ -759,6 +1302,15 @@ export async function ingestRemediationHostResults(params) {
|
|
|
759
1302
|
const eligibleIds = new Set((hostDependencyLevels(state)[0] ?? []).map((block) => block.block_id));
|
|
760
1303
|
const resultIds = new Set();
|
|
761
1304
|
const completed = [];
|
|
1305
|
+
// Recovery-only answer table; the normal lane gets null and spawns exactly as
|
|
1306
|
+
// it always has. See RemediationRequiredTestVerdicts.
|
|
1307
|
+
const requiredTestVerdicts = params.recovery?.requiredTestVerdicts ?? null;
|
|
1308
|
+
// Lazily loaded on the first recovery-marked acceptance: the recovery marks
|
|
1309
|
+
// the ledger ALREADY carries. A crash between the append and the state write
|
|
1310
|
+
// leaves a mark whose item is still pending, and the natural response is to
|
|
1311
|
+
// re-run the verb — which must converge, not accumulate a second record of
|
|
1312
|
+
// the same acceptance.
|
|
1313
|
+
let recordedRecoveryMarks = null;
|
|
762
1314
|
const landedFiles = new Set(nextState.applied_edit_surface ?? []);
|
|
763
1315
|
const requireRepositoryCorroboration = state.host_handoff !== undefined || isGitRepo(paths.root);
|
|
764
1316
|
for (const workItem of workload.work_items) {
|
|
@@ -820,14 +1372,9 @@ export async function ingestRemediationHostResults(params) {
|
|
|
820
1372
|
const result = parsed.result;
|
|
821
1373
|
const outcome = result.outcome;
|
|
822
1374
|
if (outcome.status === "resolved_no_change") {
|
|
823
|
-
const failedTests = rerunRequiredTests(paths.root, workItem.required_tests);
|
|
1375
|
+
const failedTests = rerunRequiredTests(paths.root, workItem.required_tests, requiredTestVerdicts);
|
|
824
1376
|
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
|
-
});
|
|
1377
|
+
issues.push(requiredTestIssue(workItem, failedTests));
|
|
831
1378
|
continue;
|
|
832
1379
|
}
|
|
833
1380
|
}
|
|
@@ -874,6 +1421,8 @@ export async function ingestRemediationHostResults(params) {
|
|
|
874
1421
|
state,
|
|
875
1422
|
workItem,
|
|
876
1423
|
result,
|
|
1424
|
+
verdicts: requiredTestVerdicts,
|
|
1425
|
+
recovery: params.recovery !== undefined,
|
|
877
1426
|
});
|
|
878
1427
|
if (!corroborated.ok) {
|
|
879
1428
|
issues.push({
|
|
@@ -884,6 +1433,55 @@ export async function ingestRemediationHostResults(params) {
|
|
|
884
1433
|
});
|
|
885
1434
|
continue;
|
|
886
1435
|
}
|
|
1436
|
+
if (corroborated.usedRecovery) {
|
|
1437
|
+
// No acceptance without a record. The mark goes down BEFORE the item is
|
|
1438
|
+
// marked resolved, and an append that throws refuses this item rather
|
|
1439
|
+
// than landing an acceptance the ledger cannot account for — the run
|
|
1440
|
+
// must never read as one that never drifted. The refusal is per item:
|
|
1441
|
+
// an unwritable ledger is not a reason to discard the whole ingest.
|
|
1442
|
+
try {
|
|
1443
|
+
recordedRecoveryMarks ??= (await readSubmissionLedger(paths.artifactsDir)).filter((event) => event.kind === "accepted_via_recovery");
|
|
1444
|
+
// The mark's identity is (run, item, LANDED COMMIT), not just
|
|
1445
|
+
// (run, item): an item re-opened and later re-accepted from a
|
|
1446
|
+
// DIFFERENT landing is a different relaxed acceptance and earns its
|
|
1447
|
+
// own record. Only a retry of the SAME landing is a duplicate. The
|
|
1448
|
+
// landed sha is matched inside the message because the shared event
|
|
1449
|
+
// contract carries no commit field, and a 40-hex sha this writer
|
|
1450
|
+
// itself emitted is an unambiguous token to match on.
|
|
1451
|
+
const landedCommit = result.commit_evidence.after;
|
|
1452
|
+
const alreadyMarked = recordedRecoveryMarks.some((event) => event.run_id === params.runId &&
|
|
1453
|
+
event.submission_id === workItem.id &&
|
|
1454
|
+
(event.message ?? "").includes(landedCommit));
|
|
1455
|
+
if (!alreadyMarked) {
|
|
1456
|
+
const event = {
|
|
1457
|
+
contract_version: SUBMISSION_LEDGER_EVENT_CONTRACT_VERSION,
|
|
1458
|
+
run_id: params.runId,
|
|
1459
|
+
submission_id: workItem.id,
|
|
1460
|
+
lane: workItem.id,
|
|
1461
|
+
kind: "accepted_via_recovery",
|
|
1462
|
+
// Derived from what was actually probed, never asserted: the
|
|
1463
|
+
// baseline was found in no ref and unreachable from HEAD.
|
|
1464
|
+
message: `accepted under recovery: the trusted baseline ${workItem.baseline_commit} is ` +
|
|
1465
|
+
"contained by no ref and unreachable from HEAD, so landed commit " +
|
|
1466
|
+
`${landedCommit} was corroborated against HEAD and this ` +
|
|
1467
|
+
"item's bound scope instead of against baseline ancestry",
|
|
1468
|
+
recorded_at: new Date().toISOString(),
|
|
1469
|
+
};
|
|
1470
|
+
await appendSubmissionEvent(paths.artifactsDir, event);
|
|
1471
|
+
recordedRecoveryMarks.push(event);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
catch (error) {
|
|
1475
|
+
issues.push({
|
|
1476
|
+
code: "recovery_unrecorded",
|
|
1477
|
+
work_item_id: workItem.id,
|
|
1478
|
+
result_path: workItem.result_path,
|
|
1479
|
+
message: "the recovery acceptance could not be recorded on the submission ledger, so it " +
|
|
1480
|
+
`was refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
1481
|
+
});
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
887
1485
|
acceptedFiles = corroborated.changedFiles;
|
|
888
1486
|
}
|
|
889
1487
|
const completedAt = new Date().toISOString();
|