Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,18 @@ new file. A publication that has committed can win a later cancellation; the
after commit, the `cleanup_incomplete` error reports the same file as
`details.published_path` and `details.bytes`.

When a failure retains a completed observation and no bundle committed,
`details.source_closure` states what that observation cites. `publication` is
`not_published`. `records` holds authentic source Evidence this invocation
captured, within 64 KiB. `omitted` lists up to 64 other identities and
`omitted_count` the total: `not_captured` means the invocation holds no such
record, so nothing is shown for it or its ancestors; `not_inlined` means it was
captured but is not in this response. `complete` is true only when every cited
record is in `records` or embedded in the observation. If examining the closure
itself fails, `collection_failure` names the error type and the closure state is
unknown; the original failure is still returned. Nothing is retained after exit,
and running the command again is a new observation.

When the reader of piped output closes early, as `rea ... | head` does, the
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
SIGPIPE-terminated writer). The operation itself may have completed; only its
Expand Down
128 changes: 127 additions & 1 deletion src/application/AnalysisEvidenceSources.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readArtifactInspectionSources } from "../domain/artifactInspection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { Evidence } from "../domain/evidence.js";
import { tryParseEvidence, type Evidence } from "../domain/evidence.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { err, ok, type Result } from "../domain/result.js";

/** Resolve the embedded publication closure without reinterpreting it in delivery adapters. */
Expand All @@ -15,3 +16,128 @@ export const analysisEvidenceSources = (
return err(embedded.error.retainPartialObservation(complete));
return ok([...embedded.value, complete]);
};

/** Matches the diagnostics allowance retained for other finalized one-shot work. */
const SOURCE_CLOSURE_INLINE_BYTES = 64 * 1024;
/** Identities beyond this stay counted; direct links remain on the observation. */
const SOURCE_CLOSURE_LISTED_OMISSIONS = 64;
const SOURCE_CLOSURE_RECOVERY =
"No bundle was written and this invocation retains nothing after exit. Keep any records shown here, or run the operation again after following remediation; that is a new observation and may differ.";

/** What a failed one-shot delivery holds for one record its observation cites. */
export type SourceClosureOmission = {
readonly evidence_id: string;
/**
* `not_captured`: this invocation holds no such record, so none can be shown.
* `not_inlined`: captured, but not part of this bounded failure response.
*/
readonly reason: "not_captured" | "not_inlined";
};

/** Bounded failure handoff for a completed observation whose bundle never committed. */
export type UndeliveredSourceClosure = {
readonly publication: "not_published";
/** Every cited record is in `records` or embedded in the observation itself. */
readonly complete: boolean;
readonly records: Evidence[];
readonly omitted_count: number;
readonly omitted: SourceClosureOmission[];
readonly recovery: string;
};

/** The closure state is unknown because examining it failed. */
export type UnavailableSourceClosure = {
readonly publication: "not_published";
readonly complete: false;
readonly collection_failure: string;
readonly recovery: string;
};

const collectSourceClosure = (
observation: Evidence,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | undefined => {
const inspection = analysisEvidenceSources(observation);
const embedded = new Map(
(inspection.ok ? inspection.value : []).map((record) => [
record.evidence_id,
record,
]),
);
const held = new Map(captured.map((record) => [record.evidence_id, record]));
// Set iteration reaches entries added while it runs, which makes this a
// deduplicating breadth-first queue: directly cited records spend the
// allowance first, and each link costs one lookup however often it repeats.
const closure = new Set([observation.evidence_id]);
for (const evidenceId of observation.evidence_links) closure.add(evidenceId);
for (const evidenceId of held.keys()) closure.add(evidenceId);
const records: Evidence[] = [];
const omitted: SourceClosureOmission[] = [];
let omittedCount = 0;
let inlineBytes = options.inlineRecords ? SOURCE_CLOSURE_INLINE_BYTES : 0;
const omit = (
evidenceId: string,
reason: SourceClosureOmission["reason"],
) => {
omittedCount += 1;
if (omitted.length < SOURCE_CLOSURE_LISTED_OMISSIONS)
omitted.push({ evidence_id: evidenceId, reason });
};
for (const evidenceId of closure) {
if (evidenceId === observation.evidence_id) continue;
const inObservation = embedded.get(evidenceId);
const record = inObservation ?? held.get(evidenceId);
if (record === undefined) {
omit(evidenceId, "not_captured");
continue;
}
for (const link of record.evidence_links) closure.add(link);
if (inObservation !== undefined) continue;
const bytes = jsonValueByteLength(record, inlineBytes);
if (bytes > inlineBytes) {
omit(evidenceId, "not_inlined");
continue;
}
records.push(record);
inlineBytes -= bytes;
}
if (closure.size === 1) return undefined;
return {
publication: "not_published",
complete: omittedCount === 0,
records,
omitted_count: omittedCount,
omitted,
recovery: SOURCE_CLOSURE_RECOVERY,
};
};

/**
* Describe the source closure of a completed observation after delivery failed
* before commit. Only already captured records are reported, so an unresolved
* link stays an explicit omission and its own ancestors remain unknown.
*
* This annotates a failure that must reach the caller, so it never throws. If
* examining the closure fails, the state is reported as unknown, naming only
* the bounded error type rather than a message that could quote analyzed data.
*/
export const undeliveredSourceClosure = (
partialObservation: unknown,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | UnavailableSourceClosure | undefined => {
try {
const observation = tryParseEvidence(partialObservation);
return observation === undefined
? undefined
: collectSourceClosure(observation, captured, options);
} catch (cause: unknown) {
return {
publication: "not_published",
complete: false,
collection_failure: cause instanceof Error ? cause.name : "unknown",
recovery: SOURCE_CLOSURE_RECOVERY,
};
}
};
5 changes: 5 additions & 0 deletions src/application/EvidenceBundleCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ export const publishEvidenceDelivery = async (input: {
return ok({ delivered: input.delivered, evidenceBundle: written.value });
};

/** A delivery failure can follow commit; its receipt then owns the source closure. */
export const evidenceDeliveryCommitted = (error: AnalysisError): boolean =>
error.diagnostics?.evidence_bundle !== undefined ||
(error instanceof EvidenceFileError && error.published !== undefined);

/** Publish a completed bundle and retry its exact staged-output cleanup owner. */
const persistEvidenceBundleCommand = async (
bundle: EvidenceBundle,
Expand Down
51 changes: 44 additions & 7 deletions src/cli/analysisEvidenceDelivery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { recordCliEvidencePublication } from "./cliEvidencePublication.js";
import { analysisEvidenceSources } from "../application/AnalysisEvidenceSources.js";
import {
analysisEvidenceSources,
undeliveredSourceClosure,
} from "../application/AnalysisEvidenceSources.js";
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
import { z } from "incur";
import {
Expand All @@ -14,12 +17,18 @@ import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
import { err, ok, type Result } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
import { publishEvidenceDelivery } from "../application/EvidenceBundleCommands.js";
import {
evidenceDeliveryCommitted,
publishEvidenceDelivery,
} from "../application/EvidenceBundleCommands.js";
import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import {
isObject,
projectCliAnalysisResult,
} from "./projectCliAnalysisResult.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { ZodSafeParseResult } from "zod";
import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js";
Expand Down Expand Up @@ -169,6 +178,29 @@ export const deliverCliAnalysisEvidence = async (
});
};

/**
* A requested bundle that never committed cannot silently drop what a completed
* observation cites. Absent delivery or observation leaves the failure untouched.
*/
const withUndeliveredSourceClosure = (
failure: JsonValue,
input: CliEvidenceDelivery,
): JsonValue => {
if (input.evidenceOutput === undefined || !isObject(failure)) return failure;
const { details } = failure;
if (!isObject(details)) return failure;
const closure = undeliveredSourceClosure(
details.partial_observation,
input.sourceEvidence ?? [],
// Error output is not the caller's selected Evidence sink. Keep records
// out of it when this invocation carries explicit exclusions.
{ inlineRecords: (input.sensitiveValues?.length ?? 0) === 0 },
);
return closure === undefined
? failure
: { ...failure, details: { ...details, source_closure: closure } };
};

/** Publish already finalized CLI analysis without opening a second provider/session. */
export const publishCliAnalysisEvidence = async (
result: Result<Evidence, JsonValue>,
Expand All @@ -179,7 +211,9 @@ export const publishCliAnalysisEvidence = async (
projectAnalysisError(
redactExplicitFailure(error, input.sensitiveValues ?? []),
);
if (!result.ok) return result.error;
const undelivered = (error: AnalysisError) =>
withUndeliveredSourceClosure(projectFailure(error), input);
if (!result.ok) return withUndeliveredSourceClosure(result.error, input);
const complete = parseEvidence(result.value);
const inputError = cliEvidenceDeliveryInputError(input);
if (inputError !== undefined) {
Expand All @@ -197,15 +231,15 @@ export const publishCliAnalysisEvidence = async (
})
: ok(complete);
if (!delivered.ok)
return projectFailure(delivered.error.retainPartialObservation(complete));
return undelivered(delivered.error.retainPartialObservation(complete));
// This projection answers the same direct invocation as its parent. Later
// inspect_analysis_view calls own their own input and do not inherit it.
const selected =
detail === "summary" && complete.caller_input_path !== undefined
? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path)
: delivered.value;
const inspectionSources = analysisEvidenceSources(complete);
if (!inspectionSources.ok) return projectFailure(inspectionSources.error);
if (!inspectionSources.ok) return undelivered(inspectionSources.error);
const published = await publishEvidenceDelivery({
bundle: createEvidenceBundle([
...new Map(
Expand All @@ -220,7 +254,10 @@ export const publishCliAnalysisEvidence = async (
operation,
signal,
});
if (!published.ok) return projectFailure(published.error);
if (!published.ok)
return evidenceDeliveryCommitted(published.error)
? projectFailure(published.error)
: undelivered(published.error);
const receipt = {
...(input.sensitiveValues?.some(
(literal) =>
Expand Down
2 changes: 1 addition & 1 deletion src/cli/projectCliAnalysisResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const projectCliAnalysisResult = <Value>(
return projectInputFields(result.error, inputBinding);
};

const isObject = (
export const isObject = (
value: JsonValue | undefined,
): value is Record<string, JsonValue> =>
value !== null && typeof value === "object" && !Array.isArray(value);
Expand Down
Loading
Loading