ai-foreman 1.7.19 → 1.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +24 -1
- package/dist/buildRuns.js +216 -70
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -10
- package/dist/index.js +4 -1
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +293 -41
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type QaEvidenceSnapshot } from "./qaEvidenceReader.js";
|
|
2
|
+
export interface QaTicketTimeline {
|
|
3
|
+
counters: {
|
|
4
|
+
failedReviews: number;
|
|
5
|
+
passedReviews: number;
|
|
6
|
+
blockedReviews: number;
|
|
7
|
+
interruptedReviews: number;
|
|
8
|
+
pendingReviews: number;
|
|
9
|
+
malformedResponses: number;
|
|
10
|
+
corrections: number;
|
|
11
|
+
remediationFailures: number;
|
|
12
|
+
uncertainRemediations: number;
|
|
13
|
+
};
|
|
14
|
+
events: Array<Record<string, unknown>>;
|
|
15
|
+
gaps: string[];
|
|
16
|
+
}
|
|
17
|
+
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
18
|
+
export declare function buildQaTimeline(snapshot: QaEvidenceSnapshot, workId: string): QaTicketTimeline;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { decisionBelongsToWork } from "./buildInterventions.js";
|
|
2
|
+
import { evidenceRecord } from "./qaEvidenceReader.js";
|
|
3
|
+
import { parseQaResponseContract } from "rafi-spec";
|
|
4
|
+
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
5
|
+
export function buildQaTimeline(snapshot, workId) {
|
|
6
|
+
const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
|
|
7
|
+
const counters = { failedReviews: 0, passedReviews: 0, blockedReviews: 0, interruptedReviews: 0, pendingReviews: 0, malformedResponses: 0, corrections: 0, remediationFailures: 0, uncertainRemediations: 0 };
|
|
8
|
+
const events = [];
|
|
9
|
+
for (const row of scoped("qa_review_attempts")) {
|
|
10
|
+
const record = evidenceRecord(row);
|
|
11
|
+
const status = String(row.status);
|
|
12
|
+
if (status === "failed")
|
|
13
|
+
counters.failedReviews++;
|
|
14
|
+
else if (status === "passed")
|
|
15
|
+
counters.passedReviews++;
|
|
16
|
+
else if (status === "started")
|
|
17
|
+
counters.pendingReviews++;
|
|
18
|
+
else if (status === "interrupted")
|
|
19
|
+
counters.interruptedReviews++;
|
|
20
|
+
events.push({ kind: "review", id: row.attempt_id, reviewNumber: row.review_number, status, sourceDigest: row.source_digest, detail: record?.detail, asOf: row.updated_at, sequence: Number(row.review_number), order: 0 });
|
|
21
|
+
}
|
|
22
|
+
for (const row of scoped("qa_turns")) {
|
|
23
|
+
const receipt = evidenceRecord(row, "receipt_json");
|
|
24
|
+
const slot = String(row.retry_slot);
|
|
25
|
+
if (/correction|repair/.test(slot))
|
|
26
|
+
counters.corrections++;
|
|
27
|
+
const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
|
|
28
|
+
const contract = bytes ? parseQaResponseContract(bytes.toString("utf8")) : undefined;
|
|
29
|
+
const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
|
|
30
|
+
if (slot === "initial" && classification === "malformed")
|
|
31
|
+
counters.malformedResponses++;
|
|
32
|
+
if (slot === "initial" && classification === "blocked")
|
|
33
|
+
counters.blockedReviews++;
|
|
34
|
+
events.push({ kind: "qa_turn", id: row.operation_id, reviewNumber: row.review_number, slot, status: row.status, classification, responseDigest: receipt?.rawResponseDigest, sequence: Number(row.review_number), order: 1 });
|
|
35
|
+
}
|
|
36
|
+
for (const row of scoped("qa_remediation_attempts")) {
|
|
37
|
+
if (row.status === "failed")
|
|
38
|
+
counters.remediationFailures++;
|
|
39
|
+
if (row.status === "uncertain")
|
|
40
|
+
counters.uncertainRemediations++;
|
|
41
|
+
const parent = scoped("qa_review_attempts").find(attempt => attempt.attempt_id === row.review_attempt_id);
|
|
42
|
+
events.push({ kind: "remediation", id: row.attempt_id, reviewAttemptId: row.review_attempt_id, generation: row.generation, status: row.status, requestDigest: row.request_digest, responseDigest: row.response_digest, verification: "requires subsequent source-bound QA", sequence: Number(parent?.review_number ?? -1), order: 3 });
|
|
43
|
+
}
|
|
44
|
+
const prior = new Map();
|
|
45
|
+
for (const row of scoped("qa_reports").sort((a, b) => Number(a.review_number) - Number(b.review_number))) {
|
|
46
|
+
const report = evidenceRecord(row, "report_json");
|
|
47
|
+
const findings = Array.isArray(report?.findings) ? report.findings : [];
|
|
48
|
+
const correlations = findings.map((value, ordinal) => {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
50
|
+
return { ordinal, label: "unavailable", confidence: "unestablished", limitation: "Corrupt retained finding; no issue correlation was inferred" };
|
|
51
|
+
const finding = value;
|
|
52
|
+
const key = JSON.stringify([finding.requirement, finding.locations, finding.problem, finding.verification]).toLowerCase().replace(/\s+/g, " ");
|
|
53
|
+
const previous = prior.get(key);
|
|
54
|
+
prior.set(key, { occurrenceId: row.report_occurrence_id, ordinal, reviewNumber: row.review_number, disposition: row.disposition });
|
|
55
|
+
const previousAttempt = previous ? scoped("qa_review_attempts").find(attempt => attempt.review_number === previous.reviewNumber) : undefined;
|
|
56
|
+
const linkedRemediations = previousAttempt ? scoped("qa_remediation_attempts").filter(remediation => remediation.review_attempt_id === previousAttempt.attempt_id).map(remediation => ({ attemptId: remediation.attempt_id, status: remediation.status, responseDigest: remediation.response_digest })) : [];
|
|
57
|
+
return { ordinal, rawId: finding.id, label: previous ? "possibly_recurring" : "newly_observed", confidence: previous ? "exact_normalized_content" : "unestablished", previous, linkedRemediations, limitation: "Matching content is correlation; review-local IDs and changed source digests do not prove issue identity or exact code changes" };
|
|
58
|
+
});
|
|
59
|
+
events.push({ kind: "report", id: row.report_occurrence_id ?? row.report_digest, reviewNumber: row.review_number, reportDigest: row.report_digest, disposition: row.disposition, correlations, sequence: Number(row.review_number), order: 2 });
|
|
60
|
+
}
|
|
61
|
+
for (const table of ["qa_transitions", "qa_pass_certificates", "qa_finalization_steps", "qa_recovery_heads"])
|
|
62
|
+
for (const row of scoped(table)) {
|
|
63
|
+
events.push({ kind: table, id: row.operation_id ?? row.certificate_id ?? row.sequence ?? row.packet_id, revision: row.to_revision ?? row.qa_revision ?? row.revision, state: row.status ?? row.pending_action, event: row.event_json ? evidenceRecord(row, "event_json") : undefined, certificateId: row.certificate_id, sourceDigest: row.source_state_digest ?? row.reviewed_state_digest, sequence: Number(row.sequence ?? row.qa_revision ?? row.revision), order: 4 });
|
|
64
|
+
}
|
|
65
|
+
for (const row of snapshot.rows.human_decisions ?? []) {
|
|
66
|
+
const decision = evidenceRecord(row, "decision_json");
|
|
67
|
+
if (!decisionBelongsToWork(decision, snapshot.runId, workId, (snapshot.rows.qa_remediation_stops ?? []).map(stop => ({ runId: String(stop.run_id), workId: String(stop.ticket_id), operationId: String(stop.operation_id), decisionId: evidenceRecord(stop)?.decisionId }))))
|
|
68
|
+
continue;
|
|
69
|
+
events.push({ kind: "human_decision", id: row.decision_id, status: row.status, decision, sequence: -1, order: 5 });
|
|
70
|
+
}
|
|
71
|
+
for (const row of snapshot.rows.operation_journal ?? []) {
|
|
72
|
+
const intent = evidenceRecord(row, "intent_json");
|
|
73
|
+
if (row.kind !== "build-assignment" || intent?.ticketId !== workId)
|
|
74
|
+
continue;
|
|
75
|
+
const result = evidenceRecord(row, "result_json");
|
|
76
|
+
const diffReferences = [];
|
|
77
|
+
for (const side of ["before", "after"]) {
|
|
78
|
+
const source = (side === "before" ? intent?.before : result?.after);
|
|
79
|
+
if (!source)
|
|
80
|
+
continue;
|
|
81
|
+
for (const kind of ["staged", "unstaged"])
|
|
82
|
+
if (source[`${kind}DiffDigest`] || Array.isArray(source[`${kind}DiffChunks`]))
|
|
83
|
+
diffReferences.push({ id: `${row.idempotency_key}:${side}:${kind}`, sourceDigest: source.digest, byteLength: source[`${kind}DiffBytes`], digest: source[`${kind}DiffDigest`], chunks: source[`${kind}DiffChunks`] });
|
|
84
|
+
for (const [index, file] of (source.untracked ?? []).entries())
|
|
85
|
+
diffReferences.push({ id: `${row.idempotency_key}:${side}:untracked-${index}`, sourceDigest: source.digest, path: file.path, byteLength: file.byteLength, digest: file.evidenceDigest, chunks: file.evidenceChunks });
|
|
86
|
+
}
|
|
87
|
+
events.push({ kind: "builder_assignment", id: row.idempotency_key, status: row.status, intent, result, diffReferences, sequence: -1, order: 5 });
|
|
88
|
+
}
|
|
89
|
+
for (const row of snapshot.rows.continuity_events ?? []) {
|
|
90
|
+
const payload = evidenceRecord(row, "payload_json");
|
|
91
|
+
if (payload?.ticketId !== workId && payload?.ticket !== workId)
|
|
92
|
+
continue;
|
|
93
|
+
events.push({ kind: "continuity", id: row.sequence, type: row.kind, payload, sequence: row.sequence, order: 5 });
|
|
94
|
+
}
|
|
95
|
+
const occurrences = new Set(scoped("qa_reports").map(row => row.report_occurrence_id));
|
|
96
|
+
for (const table of ["qa_report_dispositions", "qa_report_chains", "qa_delivery_turns"])
|
|
97
|
+
for (const row of snapshot.rows[table] ?? []) {
|
|
98
|
+
if (!occurrences.has(row.report_occurrence_id ?? row.predecessor_occurrence_id))
|
|
99
|
+
continue;
|
|
100
|
+
const record = evidenceRecord(row);
|
|
101
|
+
const parsed = typeof record?.parsedResponseDigest === "string" ? snapshot.blobs.get(record.parsedResponseDigest) : undefined;
|
|
102
|
+
let reportedFindings;
|
|
103
|
+
if (parsed)
|
|
104
|
+
try {
|
|
105
|
+
reportedFindings = JSON.parse(parsed.toString("utf8")).findings;
|
|
106
|
+
}
|
|
107
|
+
catch { /* snapshot discloses retained corruption */ }
|
|
108
|
+
events.push({ kind: table, id: row.sequence ?? row.turn_record_id ?? row.successor_occurrence_id, ...row, record, reportedFindings, applicationEvidence: "Builder-reported dispositions; independent source-bound QA establishes verification", providerElapsedMs: record?.providerElapsedMs ?? null, sequence: Number(row.sequence ?? -1), order: 6 });
|
|
109
|
+
}
|
|
110
|
+
for (const row of scoped("qa_source_states"))
|
|
111
|
+
events.push({ kind: "source_basis", id: row.digest, originDigest: row.origin_digest, contentDigest: row.content_digest, paths: evidenceRecord(row, "state_json")?.paths, limitation: "Content changes require retained diff evidence for exact code-change explanations", sequence: -1, order: 6 });
|
|
112
|
+
for (const row of snapshot.rows.build_instructions ?? [])
|
|
113
|
+
if (row.work_id === workId)
|
|
114
|
+
events.push({ kind: "manager_instruction", id: row.instruction_id, record: evidenceRecord(row), deliveries: (snapshot.rows.build_instruction_deliveries ?? []).filter(delivery => delivery.instruction_id === row.instruction_id).map(delivery => evidenceRecord(delivery)), sequence: Number(row.sequence), order: 6 });
|
|
115
|
+
const committed = (snapshot.rows.build_work_events ?? []).filter(row => row.work_id === workId).sort((a, b) => Number(a.sequence) - Number(b.sequence));
|
|
116
|
+
if (committed.length) {
|
|
117
|
+
const ordered = committed.map(row => ({ kind: "committed_work_event", id: row.sequence, commitSequence: row.sequence, table: row.table_name, recordKey: row.record_key, action: row.action, stateAtEvent: evidenceRecord(row), createdAt: row.created_at, ordering: "durable_commit_sequence" }));
|
|
118
|
+
return { counters, events: [...ordered, ...events.map(event => ({ ...event, ordering: "snapshot_record_or_legacy_history", commitSequence: null }))], gaps: [...snapshot.gaps, "Snapshot records and pre-upgrade history are separately labeled; only indexed events have a proven cross-table commit order. Exact fixes require retained diffs; absent timing evidence leaves duration unknown."] };
|
|
119
|
+
}
|
|
120
|
+
// Independent event streams retain their own explicit sequence; timestamps do not establish ownership.
|
|
121
|
+
events.sort((a, b) => Number(a.sequence) - Number(b.sequence) || Number(a.order) - Number(b.order) || String(a.id).localeCompare(String(b.id)));
|
|
122
|
+
return { counters, events, gaps: [...snapshot.gaps, "No retained diff is interpreted as proof of the exact fix; durations are unavailable without correlated timing evidence"] };
|
|
123
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EquivalentAuthorityResolver } from "./qaAuthorizedDisposition.js";
|
|
2
|
+
import type { QaContractCandidateV1, QaVerificationContractV1, QaSemanticAssessmentV1, QaChallengeReceiptV1, ContractInputRef, RequirementRef } from "rafi-spec";
|
|
3
|
+
import type { TicketDef } from "./tickets/ticketSchema.js";
|
|
4
|
+
export declare function canonicalContractJson(value: unknown): string;
|
|
5
|
+
export declare function contractDigest(domain: string, value: unknown): string;
|
|
6
|
+
export declare function candidatePayload(contract: QaContractCandidateV1 | QaVerificationContractV1): QaContractCandidateV1;
|
|
7
|
+
export declare function draftDigest(candidate: QaContractCandidateV1): string;
|
|
8
|
+
/** Allocate identities from meaning, replacing proposed agent IDs throughout the draft. */
|
|
9
|
+
export declare function allocateStableCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
|
|
10
|
+
export declare function inventoryDigest(requirements: RequirementRef[]): string;
|
|
11
|
+
export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
|
|
12
|
+
export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
|
|
13
|
+
export declare function validateCandidate(candidate: QaContractCandidateV1, inventory: RequirementRef[], resolveEquivalent?: EquivalentAuthorityResolver): string[];
|
|
14
|
+
export declare function validateAssessment(candidate: QaContractCandidateV1, assessment: QaSemanticAssessmentV1, authorSessionId: string): string[];
|
|
15
|
+
export declare function assembleContract(candidate: QaContractCandidateV1, inventory: RequirementRef[], assessment: QaSemanticAssessmentV1, authorSessionId: string, challenge?: QaChallengeReceiptV1, resolveEquivalent?: EquivalentAuthorityResolver): QaVerificationContractV1;
|
|
16
|
+
export declare function verifyContractDigest(contract: QaVerificationContractV1): void;
|
|
17
|
+
export declare function renderVerificationContract(contract: QaVerificationContractV1): string;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import { qaContractCandidateV1Schema, qaSemanticAssessmentV1Schema, qaChallengeReceiptV1Schema } from "rafi-spec";
|
|
3
|
+
const contractAjv = new Ajv({ allErrors: true });
|
|
4
|
+
const candidateSchema = contractAjv.compile(qaContractCandidateV1Schema);
|
|
5
|
+
const assessmentSchema = contractAjv.compile(qaSemanticAssessmentV1Schema);
|
|
6
|
+
const challengeSchema = contractAjv.compile(qaChallengeReceiptV1Schema);
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { canonicalJson } from "./qaProtocolV2.js";
|
|
9
|
+
import { depthObligations, validateDepthDecision } from "./qaPreparationPolicy.js";
|
|
10
|
+
export function canonicalContractJson(value) {
|
|
11
|
+
const serialized = JSON.stringify(value);
|
|
12
|
+
if (serialized === undefined)
|
|
13
|
+
throw new Error("Contract artifacts must have a JSON representation");
|
|
14
|
+
return canonicalJson(JSON.parse(serialized));
|
|
15
|
+
}
|
|
16
|
+
export function contractDigest(domain, value) { return createHash("sha256").update(`rafi:${domain}:v1\n`).update(canonicalContractJson(value)).digest("hex"); }
|
|
17
|
+
export function candidatePayload(contract) {
|
|
18
|
+
const { contentDigest: _content, draftPayloadDigest: _draft, semanticAssessmentDigest: _semantic, challengeReceiptDigest: _challenge, ...payload } = contract;
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
export function draftDigest(candidate) { return contractDigest("qa-contract-draft", candidatePayload(candidate)); }
|
|
22
|
+
/** Allocate identities from meaning, replacing proposed agent IDs throughout the draft. */
|
|
23
|
+
export function allocateStableCheckIds(candidate) {
|
|
24
|
+
const draft = structuredClone(candidate), mapping = new Map(), counts = new Map();
|
|
25
|
+
const proposed = new Map(draft.checks.map(check => [check.id, check]));
|
|
26
|
+
if (proposed.size !== draft.checks.length)
|
|
27
|
+
throw new Error("Duplicate proposed check identity");
|
|
28
|
+
const fingerprints = new Map(), active = new Set();
|
|
29
|
+
const fingerprintFor = (id) => {
|
|
30
|
+
const prior = fingerprints.get(id);
|
|
31
|
+
if (prior)
|
|
32
|
+
return prior;
|
|
33
|
+
const check = proposed.get(id);
|
|
34
|
+
if (!check)
|
|
35
|
+
throw new Error(`Unknown proposed dependency ${id}`);
|
|
36
|
+
if (active.has(id))
|
|
37
|
+
throw new Error(`Check dependency cycle ${id}`);
|
|
38
|
+
active.add(id);
|
|
39
|
+
const { id: _id, successorOf: _successor, dependsOnChecks, ...meaning } = check;
|
|
40
|
+
const fingerprint = contractDigest("check-identity", { ...meaning, requirementRefs: [...meaning.requirementRefs].sort(), prerequisiteRefs: [...meaning.prerequisiteRefs].sort(), dependencies: dependsOnChecks.map(fingerprintFor).sort() });
|
|
41
|
+
active.delete(id);
|
|
42
|
+
fingerprints.set(id, fingerprint);
|
|
43
|
+
return fingerprint;
|
|
44
|
+
};
|
|
45
|
+
for (const check of draft.checks) {
|
|
46
|
+
const fingerprint = fingerprintFor(check.id), occurrence = counts.get(fingerprint) ?? 0;
|
|
47
|
+
counts.set(fingerprint, occurrence + 1);
|
|
48
|
+
mapping.set(check.id, `check-${fingerprint.slice(0, 24)}-${occurrence}`);
|
|
49
|
+
}
|
|
50
|
+
const replace = (id) => { const replacement = mapping.get(id); if (!replacement)
|
|
51
|
+
throw new Error(`Unknown proposed check reference ${id}`); return replacement; };
|
|
52
|
+
for (const check of draft.checks) {
|
|
53
|
+
check.id = replace(check.id);
|
|
54
|
+
check.dependsOnChecks = check.dependsOnChecks.map(replace);
|
|
55
|
+
}
|
|
56
|
+
for (const row of draft.coverage)
|
|
57
|
+
row.checkIds = row.checkIds.map(replace);
|
|
58
|
+
for (const observation of draft.baseline)
|
|
59
|
+
if (observation.checkId)
|
|
60
|
+
observation.checkId = replace(observation.checkId);
|
|
61
|
+
for (const concern of draft.unresolved)
|
|
62
|
+
concern.checkIds = concern.checkIds.map(replace);
|
|
63
|
+
return draft;
|
|
64
|
+
}
|
|
65
|
+
export function inventoryDigest(requirements) { return contractDigest("qa-requirement-inventory", requirements); }
|
|
66
|
+
export function authoritativeInventory(ticket, checklist, inputs) {
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const [kind, entries] of [["acceptance", ticket.acceptance], ["required_tests", ticket.required_tests], ["checklist", checklist]]) {
|
|
69
|
+
const input = inputs.find(ref => ref.kind === (kind === "checklist" ? "checklist" : "ticket"));
|
|
70
|
+
if (!input)
|
|
71
|
+
throw new Error(`Missing authoritative ${kind} input`);
|
|
72
|
+
const occurrences = new Map();
|
|
73
|
+
entries.forEach((statement, index) => {
|
|
74
|
+
const fingerprint = contractDigest("requirement-statement", { kind, statement });
|
|
75
|
+
const occurrence = occurrences.get(fingerprint) ?? 0;
|
|
76
|
+
occurrences.set(fingerprint, occurrence + 1);
|
|
77
|
+
result.push({ id: `req-${fingerprint.slice(0, 24)}-${occurrence}`, inputRef: input.id, locator: `${kind}[${index}]`, statement,
|
|
78
|
+
digest: contractDigest("statement", statement), obligation: "mandatory", origin: "explicit", authority: "approved" });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
export function parsePreparationArtifact(text, tag, maximum = 2 * 1024 * 1024) {
|
|
84
|
+
if (Buffer.byteLength(text) > maximum)
|
|
85
|
+
throw new Error("QA preparation response exceeds size limit");
|
|
86
|
+
const start = `${tag}_START`, end = `${tag}_END`;
|
|
87
|
+
if (text.split(start).length !== 2 || text.split(end).length !== 2)
|
|
88
|
+
throw new Error(`Expected exactly one ${tag} artifact`);
|
|
89
|
+
const from = text.indexOf(start) + start.length, to = text.indexOf(end);
|
|
90
|
+
if (to < from)
|
|
91
|
+
throw new Error("Malformed preparation artifact envelope");
|
|
92
|
+
return JSON.parse(text.slice(from, to).trim());
|
|
93
|
+
}
|
|
94
|
+
export function validateCandidate(candidate, inventory, resolveEquivalent) {
|
|
95
|
+
if (!candidateSchema(candidatePayload(candidate)))
|
|
96
|
+
return (candidateSchema.errors ?? []).map(error => `${error.instancePath} ${error.message}`);
|
|
97
|
+
const issues = validateDepthDecision(candidate.depthDecision);
|
|
98
|
+
if (candidate.version !== 1 || !candidate.contractId || !candidate.runId || !candidate.workId || !candidate.admissionDigest || !Number.isSafeInteger(candidate.revision) || candidate.revision < 1)
|
|
99
|
+
issues.push("Invalid contract identity");
|
|
100
|
+
for (const field of ["inputs", "baseline", "requirements", "checks", "coverage", "preparationEvidence", "unresolved"])
|
|
101
|
+
if (!Array.isArray(candidate[field]))
|
|
102
|
+
issues.push(`Missing ${field} array`);
|
|
103
|
+
if (issues.length)
|
|
104
|
+
return issues;
|
|
105
|
+
const unique = (items, label) => {
|
|
106
|
+
const ids = new Set();
|
|
107
|
+
for (const item of items) {
|
|
108
|
+
if (!item.id || ids.has(item.id))
|
|
109
|
+
issues.push(`Duplicate/missing ${label} ID ${item.id}`);
|
|
110
|
+
ids.add(item.id);
|
|
111
|
+
}
|
|
112
|
+
return ids;
|
|
113
|
+
};
|
|
114
|
+
const inputs = unique(candidate.inputs, "input"), requirements = unique(candidate.requirements, "requirement"), checks = unique(candidate.checks, "check");
|
|
115
|
+
for (const expected of inventory)
|
|
116
|
+
if (!candidate.requirements.some(actual => canonicalJson(actual) === canonicalJson(expected)))
|
|
117
|
+
issues.push(`Missing/altered authoritative requirement ${expected.id}`);
|
|
118
|
+
for (const input of candidate.inputs)
|
|
119
|
+
if (input.availability !== "available" || !/^[a-f0-9]{64}$/.test(input.digest))
|
|
120
|
+
issues.push(`Unavailable/invalid input ${input.id}`);
|
|
121
|
+
for (const req of candidate.requirements) {
|
|
122
|
+
if (!inputs.has(req.inputRef) || !req.statement?.trim() || req.digest !== contractDigest("statement", req.statement))
|
|
123
|
+
issues.push(`Invalid source/statement ${req.id}`);
|
|
124
|
+
if (req.conflict)
|
|
125
|
+
issues.push(`Unresolved requirement conflict ${req.id}`);
|
|
126
|
+
if (req.authority === "proposed" && req.obligation === "mandatory")
|
|
127
|
+
issues.push(`Proposed scope cannot be mandatory ${req.id}`);
|
|
128
|
+
const rows = candidate.coverage.filter(row => row.requirementId === req.id);
|
|
129
|
+
if (rows.length !== 1 || !rows[0].checkIds.length || rows[0].dispositionRef)
|
|
130
|
+
issues.push(`Requirement lacks concrete coverage ${req.id}`);
|
|
131
|
+
for (const id of rows[0]?.checkIds ?? [])
|
|
132
|
+
if (!checks.has(id) || !candidate.checks.find(check => check.id === id)?.requirementRefs.includes(req.id))
|
|
133
|
+
issues.push(`Invalid coverage ${req.id}/${id}`);
|
|
134
|
+
if (req.obligation === "mandatory" && !(rows[0]?.checkIds ?? []).some(id => candidate.checks.find(check => check.id === id)?.obligation === "mandatory"))
|
|
135
|
+
issues.push(`Mandatory requirement only covered by advisory checks ${req.id}`);
|
|
136
|
+
}
|
|
137
|
+
for (const row of candidate.coverage)
|
|
138
|
+
if (!requirements.has(row.requirementId))
|
|
139
|
+
issues.push(`Unknown coverage requirement ${row.requirementId}`);
|
|
140
|
+
for (const check of candidate.checks) {
|
|
141
|
+
if (!check.requirementRefs?.length || check.requirementRefs.some(id => !requirements.has(id)))
|
|
142
|
+
issues.push(`Unknown requirement on ${check.id}`);
|
|
143
|
+
if (!check.expectedBehavior?.trim() || !check.expectedEvidence?.length || !check.verification?.length)
|
|
144
|
+
issues.push(`Nonactionable check ${check.id}`);
|
|
145
|
+
if (!["mandatory", "advisory"].includes(check.obligation) || !["preimplementation", "postimplementation", "both"].includes(check.timing))
|
|
146
|
+
issues.push(`Illegal obligation/timing ${check.id}`);
|
|
147
|
+
if (!check.applicability || !["unconditional", "conditional"].includes(check.applicability.kind) || !["host", "qa"].includes(check.applicability.decisionOwner))
|
|
148
|
+
issues.push(`Invalid applicability ${check.id}`);
|
|
149
|
+
else if (check.applicability.kind === "conditional" && (!check.applicability.predicate?.trim() || !check.applicability.requiredEvidence?.length))
|
|
150
|
+
issues.push(`Underspecified predicate ${check.id}`);
|
|
151
|
+
if (check.obligation === "mandatory" && check.timing !== "postimplementation") {
|
|
152
|
+
const readiness = candidate.baseline.find(observation => observation.checkId === check.id);
|
|
153
|
+
if (!readiness || readiness.applicability === "unresolved" || !readiness.applicability || check.applicability.kind === "unconditional" && readiness.applicability !== "applicable" || readiness.applicability === "applicable" && readiness.status !== "passed" || readiness.applicability === "not-applicable" && !readiness.predicateEvidence?.length)
|
|
154
|
+
issues.push(`Mandatory preimplementation readiness incomplete ${check.id}`);
|
|
155
|
+
if (check.applicability.kind === "conditional" && readiness?.applicability === "applicable" && !readiness.predicateEvidence?.length)
|
|
156
|
+
issues.push(`Preimplementation predicate evidence missing ${check.id}`);
|
|
157
|
+
}
|
|
158
|
+
if (check.dispositionRef)
|
|
159
|
+
issues.push(`Disposition requires resolved persisted authority ${check.id}`);
|
|
160
|
+
for (const method of check.verification ?? []) {
|
|
161
|
+
if (!method.expectedOutcome?.trim() || !method.runtime?.trim() || !Number.isSafeInteger(method.timeoutMs) || method.timeoutMs < 1 || method.timeoutMs > 3_600_000 || method.cwd.startsWith("/") || method.cwd.split(/[\\/]/).includes(".."))
|
|
162
|
+
issues.push(`Invalid method ${check.id}`);
|
|
163
|
+
if (method.kind === "command" ? !method.argv?.length || method.argv.some(arg => typeof arg !== "string") : method.kind !== "procedure" || !method.steps?.length)
|
|
164
|
+
issues.push(`Missing verification procedure ${check.id}`);
|
|
165
|
+
if (method.equivalentAuthorityId && !resolveEquivalent?.(method.equivalentAuthorityId, candidate, check, method))
|
|
166
|
+
issues.push(`Equivalent method requires persisted authority ${check.id}`);
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(check.dependsOnChecks) || !Array.isArray(check.prerequisiteRefs))
|
|
169
|
+
issues.push(`Missing dependencies ${check.id}`);
|
|
170
|
+
for (const id of check.dependsOnChecks ?? [])
|
|
171
|
+
if (!checks.has(id))
|
|
172
|
+
issues.push(`Unknown dependency ${id}`);
|
|
173
|
+
for (const id of check.prerequisiteRefs ?? [])
|
|
174
|
+
if (!candidate.baseline.some(observation => observation.id === id))
|
|
175
|
+
issues.push(`Unknown prerequisite ${id}`);
|
|
176
|
+
}
|
|
177
|
+
const active = new Set(), visited = new Set();
|
|
178
|
+
const visit = (id) => { if (active.has(id)) {
|
|
179
|
+
issues.push(`Check dependency cycle ${id}`);
|
|
180
|
+
return;
|
|
181
|
+
} if (visited.has(id))
|
|
182
|
+
return; active.add(id); for (const dep of candidate.checks.find(check => check.id === id)?.dependsOnChecks ?? [])
|
|
183
|
+
visit(dep); active.delete(id); visited.add(id); };
|
|
184
|
+
for (const id of checks)
|
|
185
|
+
visit(id);
|
|
186
|
+
for (const obligation of depthObligations(candidate.depthDecision.level))
|
|
187
|
+
if (!candidate.preparationEvidence.some(evidence => evidence.obligation === obligation && evidence.analysis?.trim() && evidence.evidenceDigest && evidence.operationId && evidence.sessionId && evidence.references.length))
|
|
188
|
+
issues.push(`Missing depth evidence ${obligation}`);
|
|
189
|
+
if (candidate.unresolved.some(concern => concern.material))
|
|
190
|
+
issues.push("Unresolved material concern");
|
|
191
|
+
return [...new Set(issues)];
|
|
192
|
+
}
|
|
193
|
+
export function validateAssessment(candidate, assessment, authorSessionId) {
|
|
194
|
+
const validator = "approach" in assessment ? challengeSchema : assessmentSchema;
|
|
195
|
+
if (!validator(assessment))
|
|
196
|
+
return (validator.errors ?? []).map(error => `${error.instancePath} ${error.message}`);
|
|
197
|
+
const issues = [];
|
|
198
|
+
if (assessment.version !== 1 || assessment.draftPayloadDigest !== draftDigest(candidate) || assessment.inventoryDigest !== inventoryDigest(candidate.requirements) || assessment.policyVersion !== candidate.depthDecision.policyVersion)
|
|
199
|
+
issues.push("Assessment does not bind the exact draft/inventory/policy");
|
|
200
|
+
if (!assessment.operationId || !assessment.sessionId || assessment.sessionId === authorSessionId || assessment.authorSessionId !== authorSessionId)
|
|
201
|
+
issues.push("Assessment lacks independent authorship");
|
|
202
|
+
for (const req of candidate.requirements)
|
|
203
|
+
if (!assessment.assessedRequirementIds?.includes(req.id))
|
|
204
|
+
issues.push(`Requirement not semantically assessed ${req.id}`);
|
|
205
|
+
for (const obligation of depthObligations(candidate.depthDecision.level))
|
|
206
|
+
if (!assessment.assessedObligations?.includes(obligation))
|
|
207
|
+
issues.push(`Obligation not assessed ${obligation}`);
|
|
208
|
+
if (!Array.isArray(assessment.concerns) || assessment.concerns.some(concern => concern.material))
|
|
209
|
+
issues.push("Semantic concerns unresolved");
|
|
210
|
+
return issues;
|
|
211
|
+
}
|
|
212
|
+
export function assembleContract(candidate, inventory, assessment, authorSessionId, challenge, resolveEquivalent) {
|
|
213
|
+
const issues = [...validateCandidate(candidate, inventory, resolveEquivalent), ...validateAssessment(candidate, assessment, authorSessionId)];
|
|
214
|
+
if (candidate.depthDecision.level === 5) {
|
|
215
|
+
if (!challenge)
|
|
216
|
+
issues.push("Exceptional requires independent challenge");
|
|
217
|
+
else {
|
|
218
|
+
issues.push(...validateAssessment(candidate, challenge, authorSessionId));
|
|
219
|
+
if (!challenge.approach?.trim() || !["scope", "invariants", "failure-boundaries", "verification"].every(category => challenge.concernCategories.includes(category)) || challenge.approachConcerns.some(concern => concern.material))
|
|
220
|
+
issues.push("Material approach challenge incomplete");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (issues.length)
|
|
224
|
+
throw new Error(issues.join("; "));
|
|
225
|
+
const body = { ...candidatePayload(candidate), draftPayloadDigest: draftDigest(candidate), semanticAssessmentDigest: contractDigest("artifact-semantic-assessment", assessment), ...(challenge ? { challengeReceiptDigest: contractDigest("artifact-approach-challenge", challenge) } : {}) };
|
|
226
|
+
return { ...body, contentDigest: contractDigest("qa-contract-content", body) };
|
|
227
|
+
}
|
|
228
|
+
export function verifyContractDigest(contract) {
|
|
229
|
+
const { contentDigest, ...body } = contract;
|
|
230
|
+
if (contentDigest !== contractDigest("qa-contract-content", body) || contract.draftPayloadDigest !== draftDigest(contract))
|
|
231
|
+
throw new Error("Corrupt verification contract");
|
|
232
|
+
}
|
|
233
|
+
export function renderVerificationContract(contract) {
|
|
234
|
+
return [`Verification contract ${contract.contractId}, revision ${contract.revision}`, `Work: ${contract.workId}; digest: ${contract.contentDigest}`, `Depth: ${contract.depthDecision.level}: ${contract.depthDecision.rationale}`,
|
|
235
|
+
...contract.checks.map(check => [`${check.id}: ${check.obligation}; ${check.timing}; ${check.applicability.kind}`, check.expectedBehavior, check.applicability.predicate ?? "Always applicable", `Evidence: ${check.expectedEvidence.join("; ")}`, ...check.verification.map(method => JSON.stringify(method))].join("\n"))].join("\n\n");
|
|
236
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ReadinessProcess {
|
|
2
|
+
id: string;
|
|
3
|
+
run_id: string;
|
|
4
|
+
owner: string;
|
|
5
|
+
pid: number | null;
|
|
6
|
+
process_start: string | null;
|
|
7
|
+
host: string;
|
|
8
|
+
state: string;
|
|
9
|
+
outcome_json: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface CleanupEvidence {
|
|
12
|
+
state: "quiescent" | "unknown";
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function readinessMetadata(row: ReadinessProcess): Record<string, any>;
|
|
16
|
+
/** Read-only evidence. Never infer that an authorized helper cannot still spawn. */
|
|
17
|
+
export declare function inspectReadiness(row: ReadinessProcess, deadline?: number): CleanupEvidence;
|
|
18
|
+
/** Coalesce identical observations in this process; cross-process callers use DB CAS. */
|
|
19
|
+
export declare function cleanupReadiness(row: ReadinessProcess, deadline?: number): Promise<CleanupEvidence>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { classifyProcess, processGroupQuiescent, taggedProcesses, processStartIdentity } from "./processIdentity.js";
|
|
3
|
+
import { windowsProbeJobState } from "./windowsProbeJob.js";
|
|
4
|
+
export function readinessMetadata(row) {
|
|
5
|
+
try {
|
|
6
|
+
const value = JSON.parse(row.outcome_json ?? "{}");
|
|
7
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Read-only evidence. Never infer that an authorized helper cannot still spawn. */
|
|
14
|
+
export function inspectReadiness(row, deadline = Date.now() + 5000) {
|
|
15
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
16
|
+
const identityOf = (pid, start, host) => classifyProcess(pid, start, host, { kill: pid => { process.kill(pid, 0); }, identity: pid => processStartIdentity(pid, remaining()) });
|
|
17
|
+
if (row.state === "quiescent")
|
|
18
|
+
return { state: "quiescent", reason: "durable cleanup receipt" };
|
|
19
|
+
const metadata = readinessMetadata(row);
|
|
20
|
+
if (row.host !== hostname())
|
|
21
|
+
return { state: "unknown", reason: "probe belongs to another host" };
|
|
22
|
+
if (metadata.protocol === "gated-v3" && ["intended", "registered"].includes(metadata.startup))
|
|
23
|
+
return { state: "unknown", reason: "startup capability must be revoked atomically" };
|
|
24
|
+
if (metadata.protocol === "gated-v3" && metadata.startup === "revoked")
|
|
25
|
+
return { state: "quiescent", reason: "execution capability irrevocably revoked" };
|
|
26
|
+
if (!["gated-v3", "tagged-v2", "windows-job-v1"].includes(metadata.protocol))
|
|
27
|
+
return { state: "unknown", reason: "missing or unsupported probe protocol" };
|
|
28
|
+
if (!row.pid)
|
|
29
|
+
return { state: "unknown", reason: "missing registered process identity" };
|
|
30
|
+
if (Date.now() >= deadline)
|
|
31
|
+
return { state: "unknown", reason: "cleanup inventory deadline exceeded" };
|
|
32
|
+
const identity = identityOf(row.pid, row.process_start ?? undefined, row.host);
|
|
33
|
+
if (identity.state === "unknown")
|
|
34
|
+
return { state: "unknown", reason: identity.reason };
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
if (metadata.protocol === "tagged-v2")
|
|
37
|
+
return { state: "unknown", reason: "Unix containment unavailable" };
|
|
38
|
+
// Both the gated Node helper and the Job creator must be unable to create late work.
|
|
39
|
+
const creator = metadata.creator;
|
|
40
|
+
if (metadata.protocol === "gated-v3" && (creator && identityOf(creator.pid, creator.start, row.host).state !== "dead"))
|
|
41
|
+
return { state: "unknown", reason: "Windows job creator remains live or unregistered" };
|
|
42
|
+
if (identity.state !== "dead")
|
|
43
|
+
return { state: "unknown", reason: "creating helper remains live" };
|
|
44
|
+
return ["empty", "absent"].includes(windowsProbeJobState(row.id, remaining())) ? { state: "quiescent", reason: "helper exited and job empty" } : { state: "unknown", reason: "Windows job active or unavailable" };
|
|
45
|
+
}
|
|
46
|
+
if (metadata.protocol === "windows-job-v1")
|
|
47
|
+
return { state: "unknown", reason: "Windows containment unavailable" };
|
|
48
|
+
if (metadata.protocol === "gated-v3" && identity.state !== "dead" && !processGroupQuiescent(row.pid, remaining()))
|
|
49
|
+
return { state: "unknown", reason: "authorized helper can still execute" };
|
|
50
|
+
if (!processGroupQuiescent(row.pid, remaining()))
|
|
51
|
+
return { state: "unknown", reason: "owned process group remains active or group inventory is unavailable" };
|
|
52
|
+
const members = taggedProcesses(row.id, remaining());
|
|
53
|
+
if (!members)
|
|
54
|
+
return { state: "unknown", reason: "inherited probe tag inventory is unavailable" };
|
|
55
|
+
return members.length === 0 ? { state: "quiescent", reason: "group and inherited tag inventory empty" }
|
|
56
|
+
: { state: "unknown", reason: "tagged probe descendants remain active" };
|
|
57
|
+
}
|
|
58
|
+
const cleanupInFlight = new Map();
|
|
59
|
+
/** Coalesce identical observations in this process; cross-process callers use DB CAS. */
|
|
60
|
+
export function cleanupReadiness(row, deadline = Date.now() + 5000) {
|
|
61
|
+
const key = JSON.stringify(row);
|
|
62
|
+
const existing = cleanupInFlight.get(key);
|
|
63
|
+
if (existing)
|
|
64
|
+
return existing;
|
|
65
|
+
const operation = cleanReadiness(row, deadline).finally(() => { if (cleanupInFlight.get(key) === operation)
|
|
66
|
+
cleanupInFlight.delete(key); });
|
|
67
|
+
cleanupInFlight.set(key, operation);
|
|
68
|
+
return operation;
|
|
69
|
+
}
|
|
70
|
+
/** Shared by worker settlement, supervisor death handling and restricted recovery. */
|
|
71
|
+
async function cleanReadiness(row, deadline) {
|
|
72
|
+
const metadata = readinessMetadata(row);
|
|
73
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
74
|
+
const identityOf = (pid, start, host = row.host) => classifyProcess(pid, start, host, { kill: pid => { process.kill(pid, 0); }, identity: pid => processStartIdentity(pid, remaining()) });
|
|
75
|
+
if (row.host !== hostname() || !["gated-v3", "tagged-v2", "windows-job-v1"].includes(metadata.protocol))
|
|
76
|
+
return inspectReadiness(row, deadline);
|
|
77
|
+
if (metadata.protocol === "gated-v3" && metadata.startup === "revoked" && row.pid && identityOf(row.pid, row.process_start ?? undefined, row.host).state === "live") {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(row.pid, "SIGTERM");
|
|
80
|
+
}
|
|
81
|
+
catch { /* capability is already fenced */ }
|
|
82
|
+
}
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
let lastEvidence = { state: "unknown", reason: "cleanup inventory unavailable" };
|
|
85
|
+
do {
|
|
86
|
+
const evidence = inspectReadiness(row, deadline);
|
|
87
|
+
if (evidence.state === "quiescent")
|
|
88
|
+
return evidence;
|
|
89
|
+
if (evidence.reason !== "cleanup inventory deadline exceeded")
|
|
90
|
+
lastEvidence = evidence;
|
|
91
|
+
if (Date.now() >= deadline)
|
|
92
|
+
break;
|
|
93
|
+
const signal = attempt++ < 3 ? "SIGTERM" : "SIGKILL";
|
|
94
|
+
if (row.pid) {
|
|
95
|
+
const identity = identityOf(row.pid, row.process_start ?? undefined, row.host);
|
|
96
|
+
if (identity.state === "live") {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(process.platform === "win32" ? row.pid : -row.pid, signal);
|
|
99
|
+
}
|
|
100
|
+
catch { /* verify again */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (process.platform !== "win32")
|
|
104
|
+
for (const member of taggedProcesses(row.id, remaining()) ?? []) {
|
|
105
|
+
if (identityOf(member.pid, member.start).state === "live")
|
|
106
|
+
try {
|
|
107
|
+
process.kill(member.pid, signal);
|
|
108
|
+
}
|
|
109
|
+
catch { /* verify again */ }
|
|
110
|
+
}
|
|
111
|
+
else if (metadata.creator && identityOf(metadata.creator.pid, metadata.creator.start, row.host).state === "live") {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(metadata.creator.pid, signal);
|
|
114
|
+
}
|
|
115
|
+
catch { /* verify again */ }
|
|
116
|
+
}
|
|
117
|
+
if (Date.now() >= deadline)
|
|
118
|
+
break;
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
120
|
+
} while (Date.now() < deadline);
|
|
121
|
+
return { state: "unknown", reason: `${lastEvidence.reason}; cleanup verification deadline exceeded` };
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Trusted bootstrap: registration and durable authorization precede all provider work. */
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { windowsProbeCommand } from "./windowsProbeJob.js";
|
|
5
|
+
import { processStartIdentity } from "./processIdentity.js";
|
|
6
|
+
const [project, id, executable, cwd, ...args] = process.argv.slice(2);
|
|
7
|
+
if (!project || !id || !executable || !cwd || !process.send || !process.connected)
|
|
8
|
+
throw new Error("Missing readiness startup channel");
|
|
9
|
+
let disconnected = false;
|
|
10
|
+
process.once("disconnect", () => { disconnected = true; });
|
|
11
|
+
const providerEnv = JSON.parse(process.env.RAFI_PROBE_ENV ?? "{}");
|
|
12
|
+
delete process.env.RAFI_PROBE_ENV;
|
|
13
|
+
const standalone = project === "--standalone";
|
|
14
|
+
const db = standalone ? undefined : new WorkflowDb(project, undefined, { probeId: id });
|
|
15
|
+
// Keep the verified group leader until KILL so TERM-resistant group members
|
|
16
|
+
// can still be stopped without signalling a group whose identity was lost.
|
|
17
|
+
if (process.platform !== "win32")
|
|
18
|
+
process.on("SIGTERM", () => { });
|
|
19
|
+
try {
|
|
20
|
+
db?.registerReadinessHelper(id);
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
const timer = setTimeout(() => finish(new Error("Readiness authorization timed out")), 10_000);
|
|
23
|
+
const disconnect = () => finish(new Error("Readiness owner disconnected"));
|
|
24
|
+
const message = (value) => { if (value === "rafi-probe-authorized")
|
|
25
|
+
finish(); };
|
|
26
|
+
function finish(error) {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
process.off("message", message);
|
|
29
|
+
process.off("disconnect", disconnect);
|
|
30
|
+
if (error)
|
|
31
|
+
reject(error);
|
|
32
|
+
else
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
process.on("message", message);
|
|
36
|
+
process.once("disconnect", disconnect);
|
|
37
|
+
process.send({ kind: "rafi-probe-registered", id });
|
|
38
|
+
if (disconnected)
|
|
39
|
+
disconnect();
|
|
40
|
+
});
|
|
41
|
+
db?.assertReadinessHelper(id);
|
|
42
|
+
if (disconnected)
|
|
43
|
+
throw new Error("Readiness owner disconnected before execution");
|
|
44
|
+
const windows = process.platform === "win32" ? windowsProbeCommand(id, executable, args, cwd, processStartIdentity(), true) : undefined;
|
|
45
|
+
const child = spawn(windows?.executable ?? executable, windows?.args ?? args, {
|
|
46
|
+
cwd, env: { ...providerEnv, RAFI_PROBE_OWNER: id, ...(windows ? { RAFI_WINDOWS_PROBE_CONFIG: windows.config } : {}) },
|
|
47
|
+
stdio: [windows ? "pipe" : "ignore", "inherit", "inherit"],
|
|
48
|
+
});
|
|
49
|
+
child.on("error", (error) => {
|
|
50
|
+
process.send?.({ kind: "rafi-probe-spawn-error", code: error.code, message: error.message });
|
|
51
|
+
console.error(error.message);
|
|
52
|
+
process.exitCode = 127;
|
|
53
|
+
});
|
|
54
|
+
const done = new Promise(resolve => { child.once("exit", (code, signal) => { process.send?.({ kind: "rafi-probe-result", exitCode: code, signal }); process.exitCode = code ?? 1; resolve(); }); child.once("error", () => resolve()); });
|
|
55
|
+
if (windows) {
|
|
56
|
+
try {
|
|
57
|
+
if (!child.pid)
|
|
58
|
+
throw new Error("Windows job creator did not start");
|
|
59
|
+
db?.recordReadinessCreator(id, child.pid);
|
|
60
|
+
// The creator consumes this only after its identity is durable. EOF is rejection.
|
|
61
|
+
child.stdin.end("rafi-create-job\n");
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
child.stdin?.destroy();
|
|
65
|
+
child.kill();
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const ownerGone = () => { child.kill(); process.exitCode = 125; };
|
|
70
|
+
process.once("disconnect", ownerGone);
|
|
71
|
+
await done;
|
|
72
|
+
process.off("disconnect", ownerGone);
|
|
73
|
+
// Retain the registered group leader until the parent settles cleanup. This
|
|
74
|
+
// lets it signal the verified group even when provider descendants scrub env.
|
|
75
|
+
if (child.pid && !disconnected && process.connected)
|
|
76
|
+
await new Promise(resolve => process.once("disconnect", resolve));
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
db?.close();
|
|
80
|
+
if (process.connected)
|
|
81
|
+
process.disconnect();
|
|
82
|
+
}
|