mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
* feat(environment): extract the initial state a capture's reads observed Add the environment-state analysis view. From the parents the environment manifest accepts, it extracts the data the capture's reads observed before the first related write: entity types with an identity field (proved by a path parameter or inferred from an identity name), entities with their recorded values and provenance, documents for written non-entity paths, and a render plan per read tool that is kept only when it reproduces the tool's latest initial recordings from the state; every other read tool is a recorded view with the reason. The Evidence is always inferred. Response-body decoding moves to a domain owner shared with replay, exact JSON parsing moves to the owner of the exact-number policy, the manifest and state views share parent admission and record grouping, and projectAnalysisView takes its accumulators as one object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): bound state at redacted writes; infer identity only from items A scenario write whose URL was redacted has no request line, so it was dropped and reads after it counted as initial state. Such a successful write now joins the timeline with an empty path, whose root relates to every type, so it ends every type's state; a limitation counts them. A list whose recordings were all empty passed the "every listed item carries it" check vacuously and published an identity field nothing showed; it now needs at least one item, else the view stays recorded as no-identity. A value that differed between two request lines of one item tool, such as a query variant, was called volatile, and the gate then substituted each recording's own value; only repeats of one request line now show volatility, so differing lines go through fields-differ. List membership is compared only while a list can still be the whole collection, and merged entities are indexed by type and collection once, so paged captures no longer cost pages times entities (8,000 pages of 40,000 entities: 5.7 s to 0.23 s). Docs and the tool contract say scenario state keeps the capture's redaction markers and how redacted writes bound it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep first sightings, one list pointer and linear field unions Entities took the order of the first plan observed, and item plans run before list plans, so a complete list read before its detail reads came out reordered and lost "all" membership; each entity now keeps its earliest recorded sighting. A list plan reused the first recording's pointer for later recordings, so a later response that was itself an entity wrapping a list was read as that list; every recording must now locate the same list. The field union scanned its ordered list for every key, quadratic in an object's width; a set now answers membership. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep post-write values and partial lists out of the initial state A value that changed between repeats of one request line was named volatile whenever no write under the type's own root lay between, so a write under another root (for example POST /api/dashboards/db before a second GET /api/search) left its effect in the initial state. Any recorded write between the two observations now leaves every tool of the type recorded with reason fields-differ instead. A list was published with membership "all" even when a total or count envelope member exceeded the number it listed. Such a counter now makes the membership "recorded". Also drop a stale doc comment on ObservedEntity.order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): scope item-shaped and deleted written paths out of documents State documents are held only for list-shaped paths a POST, PUT or PATCH tool writes. Reads of item-shaped written paths and of paths that are only deleted stay recorded views; the guide now says so and the spec defers them to a later increment of #4933. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): count writes in flight when a value changes between reads The volatility check compared write start positions with the two reads, so a write already in flight when the earlier read started was missed and its effect was named volatile. A write now counts when its interval overlaps the span from the earlier read's start to the later read's end. The check also ran once per changed field and scanned every write. It now runs once per pair of observations, answered by a binary search over the writes in timeline order and the latest end among each prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): catch cross-tool changes across writes; read only item totals A value that changed across a write was caught only between repeats of one tool's request line, so a list read before a write and an item read after it put the post-write value into the initial state. Changes are now classified per entity in timeline order after observation: across any recorded write, a change between repeats of a line or between two lines of the type rewrites the type (every tool recorded as fields-differ); with no write between, a repeat's change is volatile. The classification lives in environmentStateMerge.ts. Any numeric member whose name contained "total" or "count" counted as an item total, so total_pages, account_id or page_count made complete lists partial. Item totals are now members whose name tokens include total or count and none of page, pages or per; a string of digits counts as a number; and a set has_more, next or next-page token member also marks the list partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep post-write observations out of initial state * fix(environment): retain field provenance and bound state inference work --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import {
|
|
analysisViewResultSchema,
|
|
type AnalysisViewResult,
|
|
} from "../../domain/analysisView/analysisView.js";
|
|
import { scenarioCaptureRecords } from "../../domain/analysisView/scenarioCaptureRecords.js";
|
|
import {
|
|
readRecordedResponseBody,
|
|
type RecordedResponseBody,
|
|
} from "../../domain/analysisView/recordedResponseBody.js";
|
|
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
|
import { browserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
|
|
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
|
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import {
|
|
webNetworkCaptureSchema,
|
|
type WebNetworkCaptureRecord,
|
|
} from "../../domain/webNetworkCapture.js";
|
|
|
|
/** Operation name every refusal of the serve command reports. */
|
|
export const ENVIRONMENT_SERVE_OPERATION = "environment-serve";
|
|
|
|
export type EnvironmentManifest = Extract<
|
|
AnalysisViewResult,
|
|
{ kind: "environment-manifest" }
|
|
>;
|
|
export type EnvironmentManifestTool = EnvironmentManifest["tools"][number];
|
|
|
|
/** One retained parent record reduced to what a replay returns; a cut body is reported truncated. */
|
|
export interface RecordedExchange extends RecordedResponseBody {
|
|
readonly ordinal: number;
|
|
readonly path: string;
|
|
/** WHATWG query spelling including `?`, or null when the request had none. */
|
|
readonly query: string | null;
|
|
readonly status: number | null;
|
|
readonly contentType: string | null;
|
|
}
|
|
|
|
/** Replayed bodies larger than this are cut at a UTF-8 boundary and flagged. */
|
|
export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
|
|
|
/** A HAR inspected by inspect_web_network_capture, or a session recorded by capture_browser_scenario. */
|
|
export type EnvironmentCaptureKind = "web-network-capture" | "browser-scenario";
|
|
|
|
/** A manifest and the parent capture it cites, authenticated and cross-checked. */
|
|
export interface EnvironmentSource {
|
|
readonly manifest: EnvironmentManifest;
|
|
readonly manifestEvidenceId: string;
|
|
readonly evidenceId: string;
|
|
/** Which REA operation recorded the parent capture. */
|
|
readonly captureKind: EnvironmentCaptureKind;
|
|
/**
|
|
* SHA-256 of the captured artifact for a web network capture, or of the scenario definition for a
|
|
* browser scenario; null when the capture excluded it from Evidence.
|
|
*/
|
|
readonly captureSha256: string | null;
|
|
/** Parent records cited by at least one tool, keyed by producer ordinal. */
|
|
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
|
|
}
|
|
|
|
const inputError = (
|
|
operation: string,
|
|
issues: readonly AnalysisInputIssue[],
|
|
userMessage?: string,
|
|
): AnalysisInputError =>
|
|
new AnalysisInputError(
|
|
operation,
|
|
userMessage === undefined ? undefined : { userMessage },
|
|
issues,
|
|
);
|
|
|
|
/** Authenticate one Evidence file; schema failures name the file role in their paths. */
|
|
const parseEvidenceFile = (
|
|
operation: string,
|
|
role: "manifest" | "evidence",
|
|
value: unknown,
|
|
): Result<Evidence, AnalysisError> => {
|
|
try {
|
|
for (const key of ["parameters", "raw_result", "normalized_result"])
|
|
immutableJsonSnapshotMember(value, key);
|
|
return ok(immutableEvidence(value));
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof z.ZodError)) throw cause;
|
|
return err(
|
|
inputError(
|
|
operation,
|
|
projectInputIssues(cause.issues, value).map((issue) => ({
|
|
...issue,
|
|
path: [role, ...issue.path],
|
|
})),
|
|
`The ${role} file is not authenticated REA Evidence JSON.`,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const recordedExchange = (
|
|
record: WebNetworkCaptureRecord,
|
|
): RecordedExchange | undefined => {
|
|
const line = readRecordedRequestLine(record.reported, true, () => true);
|
|
if (line === null || line.kind !== "line") return undefined;
|
|
return {
|
|
ordinal: record.ordinal,
|
|
path: line.path,
|
|
query: line.query === undefined || line.query === "" ? null : line.query,
|
|
status: line.status,
|
|
contentType: line.rawMime === "" ? null : line.rawMime,
|
|
...readRecordedResponseBody(record, REPLAY_BODY_LIMIT_BYTES),
|
|
};
|
|
};
|
|
|
|
/** Authenticate the manifest file and require the environment-manifest view kind. */
|
|
const parseManifestEvidence = (
|
|
operation: string,
|
|
value: unknown,
|
|
): Result<
|
|
{ readonly manifest: EnvironmentManifest; readonly evidenceId: string },
|
|
AnalysisError
|
|
> => {
|
|
const evidence = parseEvidenceFile(operation, "manifest", value);
|
|
if (!evidence.ok) return evidence;
|
|
const view = analysisViewResultSchema.safeParse(
|
|
evidence.value.normalized_result,
|
|
);
|
|
if (
|
|
evidence.value.operation !== "inspect_analysis_view" ||
|
|
!view.success ||
|
|
view.data.kind !== "environment-manifest"
|
|
)
|
|
return err(
|
|
inputError(
|
|
operation,
|
|
[
|
|
{
|
|
path: ["manifest", "normalized_result", "kind"],
|
|
reason: "invalid_value",
|
|
expected: "environment-manifest",
|
|
message:
|
|
"The manifest file must be inspect_analysis_view Evidence of kind environment-manifest.",
|
|
},
|
|
],
|
|
"The manifest file is not environment-manifest view Evidence.",
|
|
),
|
|
);
|
|
return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id });
|
|
};
|
|
|
|
/** Authenticate the evidence file and require complete capture Evidence of either kind. */
|
|
const parseCaptureEvidence = (
|
|
operation: string,
|
|
value: unknown,
|
|
): Result<
|
|
{
|
|
readonly records: readonly WebNetworkCaptureRecord[];
|
|
readonly evidenceId: string;
|
|
readonly captureKind: EnvironmentCaptureKind;
|
|
readonly captureSha256: string | null;
|
|
},
|
|
AnalysisError
|
|
> => {
|
|
const evidence = parseEvidenceFile(operation, "evidence", value);
|
|
if (!evidence.ok) return evidence;
|
|
const { evidence_id: evidenceId } = evidence.value;
|
|
const normalized = immutableJsonSnapshotMember(
|
|
evidence.value,
|
|
"normalized_result",
|
|
);
|
|
if (evidence.value.operation === "capture_browser_scenario") {
|
|
const scenario = browserScenarioCaptureSchema.safeParse(normalized);
|
|
const definition = z
|
|
.object({ scenario_sha256: z.string() })
|
|
.safeParse(evidence.value.parameters);
|
|
if (scenario.success)
|
|
return ok({
|
|
records: scenarioCaptureRecords(scenario.data, "replay").records,
|
|
evidenceId,
|
|
captureKind: "browser-scenario",
|
|
captureSha256: definition.success
|
|
? definition.data.scenario_sha256
|
|
: null,
|
|
});
|
|
} else if (evidence.value.operation === "inspect_web_network_capture") {
|
|
const records = immutableJsonSnapshotMember(normalized, "records");
|
|
if (Array.isArray(records))
|
|
for (let index = 0; index < records.length; index += 1) {
|
|
const record = immutableJsonSnapshotMember(records, String(index));
|
|
immutableJsonSnapshotMember(record, "reported");
|
|
}
|
|
const capture = webNetworkCaptureSchema.safeParse(normalized);
|
|
if (capture.success) {
|
|
// The manifest view projects HAR captures only, and replay reads HAR's response fields.
|
|
if (capture.data.format !== "har")
|
|
return err(
|
|
inputError(
|
|
operation,
|
|
[
|
|
{
|
|
path: ["evidence", "normalized_result", "format"],
|
|
reason: "invalid_value",
|
|
expected: "har",
|
|
message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`,
|
|
},
|
|
],
|
|
"The evidence file is not a HAR capture.",
|
|
),
|
|
);
|
|
return ok({
|
|
records: capture.data.records,
|
|
evidenceId,
|
|
captureKind: "web-network-capture",
|
|
captureSha256: capture.data.artifact.sha256,
|
|
});
|
|
}
|
|
}
|
|
return err(
|
|
inputError(
|
|
operation,
|
|
[
|
|
{
|
|
path: ["evidence", "operation"],
|
|
reason: "invalid_value",
|
|
expected: "inspect_web_network_capture or capture_browser_scenario",
|
|
message:
|
|
"The evidence file must be complete inspect_web_network_capture or capture_browser_scenario Evidence.",
|
|
},
|
|
],
|
|
"The evidence file is not web network capture or browser scenario Evidence.",
|
|
),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Reduce every record a tool cites to its replayable exchange. Authentication
|
|
* already proves the capture is the manifest's parent, so every cited ordinal
|
|
* is a record with a request line; a record that still yields none is left
|
|
* out and the tool refuses at call time.
|
|
*/
|
|
const indexExchanges = (
|
|
manifest: EnvironmentManifest,
|
|
records: readonly WebNetworkCaptureRecord[],
|
|
): ReadonlyMap<number, RecordedExchange> => {
|
|
const byOrdinal = new Map(records.map((record) => [record.ordinal, record]));
|
|
const exchanges = new Map<number, RecordedExchange>();
|
|
for (const tool of manifest.tools)
|
|
for (const ordinal of tool.record_ordinals) {
|
|
if (exchanges.has(ordinal)) continue;
|
|
const record = byOrdinal.get(ordinal);
|
|
const exchange =
|
|
record === undefined ? undefined : recordedExchange(record);
|
|
if (exchange !== undefined) exchanges.set(ordinal, exchange);
|
|
}
|
|
return exchanges;
|
|
};
|
|
|
|
/**
|
|
* Load the manifest Evidence and its parent capture Evidence, refusing any pair
|
|
* that does not belong together; refusals name the command that loaded them.
|
|
*/
|
|
export const loadEnvironmentSource = (
|
|
manifestValue: unknown,
|
|
evidenceValue: unknown,
|
|
operation: string = ENVIRONMENT_SERVE_OPERATION,
|
|
): Result<EnvironmentSource, AnalysisError> => {
|
|
const manifest = parseManifestEvidence(operation, manifestValue);
|
|
if (!manifest.ok) return manifest;
|
|
const capture = parseCaptureEvidence(operation, evidenceValue);
|
|
if (!capture.ok) return capture;
|
|
const parentId = manifest.value.manifest.parent_evidence_id;
|
|
if (parentId !== capture.value.evidenceId)
|
|
return err(
|
|
inputError(
|
|
operation,
|
|
[
|
|
{
|
|
path: ["evidence", "evidence_id"],
|
|
reason: "invalid_value",
|
|
expected: parentId,
|
|
message: `The manifest cites parent Evidence ${parentId}; the evidence file is ${capture.value.evidenceId}.`,
|
|
},
|
|
],
|
|
"The manifest was projected from a different capture than the supplied evidence file.",
|
|
),
|
|
);
|
|
return ok({
|
|
manifest: manifest.value.manifest,
|
|
manifestEvidenceId: manifest.value.evidenceId,
|
|
evidenceId: capture.value.evidenceId,
|
|
captureKind: capture.value.captureKind,
|
|
captureSha256: capture.value.captureSha256,
|
|
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
|
|
});
|
|
};
|