feat(browser): retain compact scenario summaries and selected views (#7120)

Retain complete browser scenario Evidence while delivering compact summaries and selected step, event, transaction and text views through shared CLI/MCP workflows. Preserve initial CLI failures across cleanup retries and validate with real Chromium/TodoMVC.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 19:03:06 +08:00
1 parent bc143125ed
commit fc43e285ea
19 files changed
+2297 -90

No files matched your search

+28 -3
View File
@@ -122,6 +122,13 @@ import {
import { absoluteLocalPathStringSchema } from "../localPath.js";
import { z } from "zod";
import {
browserScenarioSummarySchema,
browserScenarioViewContextSchema,
browserScenarioViewItemsSchema,
browserScenarioViewRequestSchema,
} from "./browserScenarioViewSchemas.js";
import { projectBrowserScenarioView } from "./browserScenarioView.js";
import {
desktopViewRequestSchema,
desktopViewItemsSchema,
@@ -393,6 +400,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
environmentStateViewRequestSchema,
environmentCoverageViewRequestSchema,
captureViewRequestSchema,
browserScenarioViewRequestSchema,
protobufViewRequestSchema,
qlogViewRequestSchema,
pcapViewRequestSchema,
@@ -729,6 +737,14 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
summary: captureSummarySchema,
});
/** Compact outcomes and the focus screen from a retained browser scenario. */
export const browserScenarioSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
artifact: z.null(),
parent_operation: z.literal("capture_browser_scenario"),
summary: browserScenarioSummarySchema,
});
/** Exact package declarations with retained decoded manifest text. */
export const androidPackageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -1022,6 +1038,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
webNetworkCaptureSummaryViewResultSchema,
browserScenarioSummaryViewResultSchema,
androidPackageSummaryViewResultSchema,
pcapCaptureSummaryViewResultSchema,
dartAotSummaryViewResultSchema,
@@ -1076,6 +1093,15 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
z.strictObject({
kind: z.literal("browser-scenario"),
view: browserScenarioViewRequestSchema,
items: browserScenarioViewItemsSchema,
...viewResultBase,
...browserScenarioViewContextSchema.shape,
artifact: z.null(),
parent_operation: z.literal("capture_browser_scenario"),
}),
z.strictObject({
kind: z.literal("qlog"),
view: qlogViewRequestSchema,
@@ -1283,9 +1309,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
parent.operation === "capture_browser_scenario"
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest, environment-state and environment-coverage views.`
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed Evidence from a supported parent operation with a compatible view. For a summary receipt, use its normalized_result.parent_evidence_id rather than the receipt's own evidence_id.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed Evidence from a supported parent operation with a compatible view. For a summary receipt, use its normalized_result.parent_evidence_id rather than the receipt's own evidence_id.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -1331,6 +1355,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_managed_members", projectManagedMemberView],
["inspect_android_package", projectAndroidPackageView],
["capture_browser_scenario", projectBrowserScenarioView],
["inspect_dart_aot", projectDartAotView],
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
["inspect_apk_signature", projectApkSignatureView],
@@ -0,0 +1,193 @@
import { z } from "zod";
import type { RetainedBrowserScenarioCapture } from "../browserScenarioCapture.js";
import { isImmutableJsonSnapshot } from "../immutableJson.js";
import { compareAsciiStrings } from "../canonicalOrdering.js";
const count = z.int().nonnegative();
const statusClass = z.enum([
"1xx",
"2xx",
"3xx",
"4xx",
"5xx",
"failed",
"unknown",
]);
/** Counts concern retained transaction identities, including those without a request event. */
export const browserScenarioNetworkSummarySchema = z.strictObject({
selected: z.boolean().nullable(),
transactions: count.nullable(),
without_request: count,
ambiguous_transactions: count,
failed_transactions: count,
by_origin: z.array(
z.strictObject({ origin: z.string().nullable(), transactions: count }),
),
by_resource_type: z.array(
z.strictObject({
resource_type: z.string().nullable(),
transactions: count,
}),
),
by_status_class: z.array(
z.strictObject({ status_class: statusClass, transactions: count }),
),
});
interface TransactionGroup {
readonly transaction_id: string;
readonly source_item_indices: readonly number[];
readonly origin: string | null;
readonly resource_type: string | null;
readonly status_class: z.infer<typeof statusClass>;
readonly has_request: boolean;
readonly failed: boolean;
readonly ambiguous: boolean;
}
interface TransactionFacts {
indices: number[];
origins: Set<string | null>;
resourceTypes: Set<string>;
statuses: Set<number>;
requests: number;
failed: boolean;
}
/** Sealed historical captures own this metadata-only index until their retained lifetime ends. */
const transactionsByCapture = new WeakMap<
object,
readonly TransactionGroup[]
>();
/** Keep all observed alternatives; one missing event never erases its transaction. */
export const browserScenarioTransactionGroups = (
capture: RetainedBrowserScenarioCapture,
): readonly TransactionGroup[] => {
const reusable = isImmutableJsonSnapshot(capture);
const known = reusable ? transactionsByCapture.get(capture) : undefined;
if (known !== undefined) return known;
const groups = new Map<string, TransactionFacts>();
for (const [index, event] of capture.events.items.entries()) {
if (!("transaction_id" in event)) continue;
let group = groups.get(event.transaction_id);
if (group === undefined) {
group = {
indices: [],
origins: new Set(),
resourceTypes: new Set(),
statuses: new Set(),
requests: 0,
failed: false,
};
groups.set(event.transaction_id, group);
}
group.indices.push(index);
if (event.kind === "network-content") continue;
group.origins.add(event.url.origin);
group.resourceTypes.add(event.resource_type);
if (event.kind === "request") group.requests += 1;
if (event.kind === "response") group.statuses.add(event.status);
if (event.kind === "request-failed") group.failed = true;
}
const result = [...groups].map(
([transaction_id, group]): TransactionGroup => {
const status = group.statuses.values().next().value;
return Object.freeze({
transaction_id,
source_item_indices: Object.freeze(group.indices),
origin:
group.origins.size === 1
? (group.origins.values().next().value ?? null)
: null,
resource_type:
group.resourceTypes.size === 1
? (group.resourceTypes.values().next().value ?? null)
: null,
status_class:
group.statuses.size === 1 && status !== undefined
? (`${String(Math.floor(status / 100))}xx` as z.infer<
typeof statusClass
>)
: group.statuses.size === 0 && group.failed
? "failed"
: "unknown",
has_request: group.requests > 0,
failed: group.failed,
ambiguous:
group.requests > 1 ||
group.statuses.size > 1 ||
group.origins.size > 1 ||
group.resourceTypes.size > 1,
});
},
);
if (reusable) transactionsByCapture.set(capture, result);
return result;
};
const increment = <Key>(values: Map<Key, number>, key: Key): void => {
values.set(key, (values.get(key) ?? 0) + 1);
};
const sorted = <Key extends string | null>(
values: ReadonlyMap<Key, number>,
): [Key, number][] =>
[...values].sort(([left], [right]) =>
left === right
? 0
: left === null
? -1
: right === null
? 1
: compareAsciiStrings(left, right),
);
/** Null selection/counts preserve legacy unknowns and unrequested network observation. */
export const summarizeBrowserScenarioNetwork = (
capture: RetainedBrowserScenarioCapture,
): z.infer<typeof browserScenarioNetworkSummarySchema> => {
const groups = browserScenarioTransactionGroups(capture);
const contentSelected = Object.values(capture.scenario.network_content).some(
Boolean,
);
const selected =
contentSelected ||
capture.scenario.event_families?.includes("network") === true
? true
: capture.scenario.event_families === undefined
? null
: false;
const origins = new Map<string | null, number>();
const resources = new Map<string | null, number>();
const statuses = new Map<z.infer<typeof statusClass>, number>();
let withoutRequest = 0;
let ambiguous = 0;
let failed = 0;
for (const group of groups) {
if (!group.has_request) withoutRequest += 1;
if (group.ambiguous) ambiguous += 1;
if (group.failed) failed += 1;
increment(origins, group.origin);
increment(resources, group.resource_type);
increment(statuses, group.status_class);
}
return {
selected,
transactions: selected === true || groups.length > 0 ? groups.length : null,
without_request: withoutRequest,
ambiguous_transactions: ambiguous,
failed_transactions: failed,
by_origin: sorted(origins).map(([origin, transactions]) => ({
origin,
transactions,
})),
by_resource_type: sorted(resources).map(
([resource_type, transactions]) => ({ resource_type, transactions }),
),
by_status_class: sorted(statuses).map(([status_class, transactions]) => ({
status_class,
transactions,
})),
};
};
@@ -0,0 +1,388 @@
import type { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import {
retainedBrowserScenarioCaptureSchema,
type RetainedBrowserScenarioCapture as BrowserScenarioCapture,
} from "../browserScenarioCapture.js";
import type { BrowserScenarioStep as Step } from "../browserScenarioCaptureValues.js";
import { compareAsciiStrings } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import type {
AnalysisViewCoverage,
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
import {
ARTIFACT_SECTIONS,
stepRowSchema,
browserScenarioSummarySchema,
stepSectionsSchema,
summaryStepSchema,
focusAccessibilitySchema,
browserScenarioViewItemsSchema,
type BrowserScenarioViewRequest,
type ArtifactSection,
} from "./browserScenarioViewSchemas.js";
import {
browserScenarioTransactionGroups,
summarizeBrowserScenarioNetwork,
} from "./browserScenarioNetworkView.js";
const OPERATION = "capture_browser_scenario" as const;
const readCapture = createAnalysisViewResultReader(
retainedBrowserScenarioCaptureSchema,
);
const unsupportedView = (): AnalysisError =>
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
'Parent Evidence operation "capture_browser_scenario" supports summary and browser-scenario views, plus compatible environment views.',
},
]);
const sameUrl = (
left: Step["after_url"] | undefined,
right: Step["before_url"],
): boolean =>
left !== undefined &&
left.url === right.url &&
left.redacted === right.redacted;
const stepSections = (step: Step): z.infer<typeof stepSectionsSchema> => {
const sections: z.infer<typeof stepSectionsSchema> = {
captured: [],
missing: [],
truncated: [],
};
for (const section of ARTIFACT_SECTIONS) {
const { state } = step.artifacts[section];
if (state !== "not_requested") sections[state].push(section);
}
return sections;
};
const summaryStep = (
step: Step,
previous: Step | undefined,
): z.infer<typeof summaryStepSchema> => ({
step_index: step.step_index,
step_id: step.step_id,
action: step.action,
status: step.status,
error: step.error,
elapsed_ms: step.elapsed_ms,
...(sameUrl(previous?.after_url, step.before_url)
? {}
: { before_url: step.before_url }),
after_url: step.after_url,
events: {
sequence_start: step.event_sequence_start,
sequence_end: step.event_sequence_end,
},
sections: stepSections(step),
completeness: step.completeness.status,
});
/** The first unsuccessful step explains a failure; otherwise the final screen does. */
const focusAccessibility = (
steps: readonly Step[],
): z.infer<typeof focusAccessibilitySchema> => {
const unsuccessful = steps.findIndex(({ status }) => status !== "completed");
const focusIndex = unsuccessful === -1 ? steps.length - 1 : unsuccessful;
const focus = steps[focusIndex];
if (focus === undefined) throw new Error("Capture has no steps");
const kind =
unsuccessful === -1 ? ("last-step" as const) : "first-unsuccessful-step";
for (let index = focusIndex; index >= 0; index -= 1) {
const accessibility = steps[index]?.artifacts.accessibility;
if (accessibility?.state === "captured")
return {
focus_step_index: focusIndex,
focus: kind,
step_index: index,
accessibility,
};
}
return {
focus_step_index: focusIndex,
focus: kind,
step_index: null,
accessibility: focus.artifacts.accessibility,
};
};
/** Counts in canonical key order; a null origin sorts first. */
const sortedCounts = <Key extends string | null>(
counts: ReadonlyMap<Key, number>,
): [Key, number][] =>
[...counts].sort(([left], [right]) =>
left === right
? 0
: left === null
? -1
: right === null
? 1
: compareAsciiStrings(left, right),
);
const increment = <Key>(counts: Map<Key, number>, key: Key): void => {
counts.set(key, (counts.get(key) ?? 0) + 1);
};
const eventCounts = (
capture: BrowserScenarioCapture,
): z.infer<typeof browserScenarioSummarySchema>["events"] => {
const kinds = new Map<
BrowserScenarioCapture["events"]["items"][number]["kind"],
number
>();
for (const event of capture.events.items) increment(kinds, event.kind);
const { omissions } = capture.events;
return {
retained: capture.events.retained,
dropped: capture.events.dropped,
by_kind: sortedCounts(kinds).map(([kind, count]) => ({ kind, count })),
omissions: {
by_kind: omissions.by_kind,
network_transactions: omissions.network_transactions.length,
network_transaction_range: omissions.network_transaction_range,
},
};
};
const selected = <Value>(
filter: readonly Value[] | undefined,
): ((value: Value) => boolean) => {
if (filter === undefined) return () => true;
const values = new Set(filter);
return (value) => values.has(value);
};
const stepRow = (
{ artifacts: recorded, ...step }: Step,
selection: ReadonlySet<ArtifactSection>,
): z.infer<typeof stepRowSchema> => {
const artifacts: z.infer<typeof stepRowSchema>["artifacts"] = {};
const omitted: z.infer<typeof stepRowSchema>["omitted_artifacts"] = [];
for (const section of ARTIFACT_SECTIONS) {
const artifact = recorded[section];
if (selection.has(section))
Object.assign(artifacts, { [section]: artifact });
else if (artifact.state !== "not_requested")
omitted.push({ section, state: artifact.state });
}
return { ...step, artifacts, omitted_artifacts: omitted };
};
/** Scan matching rows once and materialize only the requested page. */
const selectedPage = <Input, Row>(
source: readonly Input[],
view: { readonly offset: number; readonly limit: number },
matches: (item: Input, index: number) => boolean,
project: (item: Input, index: number) => Row,
): { rows: Row[]; coverage: AnalysisViewCoverage } => {
const rows: Row[] = [];
let total = 0;
for (const [index, item] of source.entries()) {
if (!matches(item, index)) continue;
if (total >= view.offset && rows.length < view.limit)
rows.push(project(item, index));
total += 1;
}
return { rows, coverage: pageViewCoverage(view.offset, rows.length, total) };
};
/** Text offsets are UTF-16 positions in the exact digest-bound retained artifact. */
const textPage = (
capture: BrowserScenarioCapture,
view: Extract<BrowserScenarioViewRequest, { collection: "text" }>,
) => {
const artifact = capture.steps[view.step_index]!.artifacts[view.artifact];
const value = artifact.state === "captured" ? artifact.value : null;
const text = value?.text.slice(view.offset, view.offset + view.limit) ?? null;
return {
items: {
collection: "text" as const,
step_index: view.step_index,
artifact: view.artifact,
state: artifact.state,
reason: "reason" in artifact ? artifact.reason : null,
sha256: value?.sha256 ?? null,
bytes: value?.bytes ?? null,
observed: artifact.state === "truncated" ? artifact.observed : null,
retained: artifact.state === "truncated" ? artifact.retained : null,
text,
},
coverage: pageViewCoverage(
view.offset,
text?.length ?? 0,
value?.text.length ?? 0,
),
};
};
/** One stable page of exact recorded facts; no whole-result filtering arrays. */
const pageRecords = (
capture: BrowserScenarioCapture,
view: BrowserScenarioViewRequest,
): {
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
readonly coverage: AnalysisViewCoverage;
} => {
if (view.collection === "text") return textPage(capture, view);
const stepSelected = selected(view.step_indices);
if (view.collection === "steps") {
const artifacts = new Set(view.artifacts);
const page = selectedPage(
capture.steps,
view,
({ step_index }) => stepSelected(step_index),
(step) => stepRow(step, artifacts),
);
return {
items: { collection: "steps", rows: page.rows },
coverage: page.coverage,
};
}
const kindSelected = selected(view.event_kinds);
const transactionSelected = selected(view.transaction_ids);
if (view.collection === "transactions") {
const page = selectedPage(
browserScenarioTransactionGroups(capture),
view,
(group) =>
transactionSelected(group.transaction_id) &&
(view.step_indices === undefined ||
group.source_item_indices.some((index) =>
stepSelected(capture.events.items[index]!.step_index),
)),
(group) => {
const events: {
item_index: number;
event: BrowserScenarioCapture["events"]["items"][number];
}[] = [];
const omitted: {
item_index: number;
kind: BrowserScenarioCapture["events"]["items"][number]["kind"];
}[] = [];
for (const item_index of group.source_item_indices) {
const event = capture.events.items[item_index]!;
if (kindSelected(event.kind)) events.push({ item_index, event });
else omitted.push({ item_index, kind: event.kind });
}
return {
transaction_id: group.transaction_id,
source_item_indices: [...group.source_item_indices],
events,
omitted_events: omitted,
};
},
);
return {
items: { collection: "transactions", rows: page.rows },
coverage: page.coverage,
};
}
const page = selectedPage(
capture.events.items,
view,
(event) =>
stepSelected(event.step_index) &&
kindSelected(event.kind) &&
(view.transaction_ids === undefined ||
("transaction_id" in event &&
transactionSelected(event.transaction_id))),
(event, item_index) => ({ item_index, event }),
);
return {
items: { collection: "events", rows: page.rows },
coverage: page.coverage,
};
};
/** Project retained scenario Evidence without relaunching the browser or replaying actions. */
export const projectBrowserScenarioView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "browser-scenario")
return err(unsupportedView());
const parsed = readCapture(parent);
if (!parsed.ok) return parsed;
const capture = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: OPERATION,
parent_digest: parent.evidenceId.slice(3),
artifact: null,
limitations: [...parent.limitations],
unknowns: [
"Step states record one controlled replay; they do not establish the application's current state.",
"Event step references record receipt attribution; they do not prove an action caused a transaction.",
...(capture.events.dropped > 0
? [
`${String(capture.events.dropped)} events were dropped by capture budgets; counts and pages cover retained events only.`,
]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
summary: {
browser: capture.browser,
scenario: capture.scenario,
duration_ms: capture.duration_ms,
completeness: capture.completeness,
steps: capture.steps.map((step, index) =>
summaryStep(step, capture.steps[index - 1]),
),
final_accessibility: focusAccessibility(capture.steps),
events: eventCounts(capture),
network: summarizeBrowserScenarioNetwork(capture),
limitation_count: shared.limitations.length,
},
coverage: completeWithinViewCoverage(1, 1),
});
if (
view.collection === "text" &&
capture.steps[view.step_index] === undefined
)
return err(
analysisViewInputError([
{
path: ["view", "step_index"],
reason: "invalid_value",
message: "The selected step is not present in the retained capture.",
},
]),
);
return ok({
kind: "browser-scenario",
view,
...shared,
source_completeness: capture.completeness,
source_events: {
retained: capture.events.retained,
dropped: capture.events.dropped,
},
source_network_omissions: {
transactions: capture.events.omissions.network_transactions.length,
transaction_range: capture.events.omissions.network_transaction_range,
},
...pageRecords(capture, view),
});
};
@@ -0,0 +1,250 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { browserScenarioNetworkSummarySchema } from "./browserScenarioNetworkView.js";
import { sanitizedBrowserUrlSchema } from "../browserObservation.js";
import { retainedBrowserScenarioCaptureSchema } from "../browserScenarioCapture.js";
import {
browserScenarioCompletenessSchema,
browserScenarioEventKindSchema,
browserScenarioEventSchema,
browserStepArtifactsSchema,
} from "../browserScenarioCaptureValues.js";
export const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options;
const artifactSectionSchema = browserStepArtifactsSchema.keyof();
export type ArtifactSection = z.infer<typeof artifactSectionSchema>;
const countSchema = z.number().int().nonnegative();
/** Section names by recorded state; not_requested sections are omitted. */
export const stepSectionsSchema = z.strictObject({
captured: z.array(artifactSectionSchema),
missing: z.array(artifactSectionSchema),
truncated: z.array(artifactSectionSchema),
});
export const summaryStepSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema
.exactOptional()
.describe(
"Present only when it differs from the previous step's after_url (always for step 0).",
),
after_url: sanitizedBrowserUrlSchema,
events: z.strictObject({
sequence_start: z.number().int().min(1),
sequence_end: countSchema,
}),
sections: stepSectionsSchema,
completeness: z.enum(["complete", "incomplete", "truncated"]),
});
export const focusAccessibilitySchema = z.strictObject({
focus_step_index: countSchema,
focus: z.enum(["first-unsuccessful-step", "last-step"]),
/** The step whose tree is returned: the focus step, or the nearest earlier step that captured one. */
step_index: countSchema.nullable(),
accessibility: browserStepArtifactsSchema.shape.accessibility,
});
/** Compact capture overview; complete records stay in the retained parent. */
export const browserScenarioSummarySchema = z.strictObject({
browser: retainedBrowserScenarioCaptureSchema.shape.browser,
scenario: retainedBrowserScenarioCaptureSchema.shape.scenario,
duration_ms: countSchema,
completeness: browserScenarioCompletenessSchema,
steps: z.array(summaryStepSchema),
final_accessibility: focusAccessibilitySchema,
events: z.strictObject({
retained: countSchema,
dropped: countSchema,
by_kind: z.array(
z.strictObject({
kind: browserScenarioEventKindSchema,
count: countSchema,
}),
),
omissions: z.strictObject({
by_kind:
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
.by_kind,
network_transactions: countSchema,
network_transaction_range:
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
.network_transaction_range,
}),
}),
network: browserScenarioNetworkSummarySchema,
limitation_count: countSchema.describe(
"Number of parent Evidence limitations returned with this summary, including inherited provenance caveats.",
),
});
const pageShape = {
kind: z.literal("browser-scenario"),
offset: countSchema.default(0),
limit: z.int().positive().default(64),
};
const stepSelection = {
step_indices: z
.array(countSchema)
.optional()
.describe(
"Exact recorded step indices; omitted selects all, [] selects none.",
),
};
const transactionSelection = {
transaction_ids: z
.array(z.string().min(1))
.optional()
.describe(
"Exact recorded transaction IDs; omitted selects all, [] selects none.",
),
};
/** Collection-specific fields are validated rather than silently ignored. */
export const browserScenarioViewRequestSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
...pageShape,
...stepSelection,
collection: z.literal("steps"),
artifacts: z
.array(artifactSectionSchema)
.default([])
.describe(
"Return only selected artifact payloads; omitted_artifacts names the other recorded sections.",
),
}),
z.strictObject({
...pageShape,
...stepSelection,
...transactionSelection,
collection: z.literal("events"),
event_kinds: z
.array(browserScenarioEventKindSchema)
.optional()
.describe("Omitted selects all event kinds; [] selects none."),
}),
z.strictObject({
...pageShape,
...stepSelection,
...transactionSelection,
collection: z.literal("transactions"),
event_kinds: z
.array(browserScenarioEventKindSchema)
.default([
"request",
"response",
"request-finished",
"request-failed",
"request-unfinished",
])
.describe(
"Metadata events by default; select network-content explicitly for bodies and header values. [] returns identity and source indices only.",
),
}),
z.strictObject({
...pageShape,
collection: z.literal("text"),
step_index: countSchema,
artifact: z.enum(["accessibility", "dom"]),
limit: z
.int()
.positive()
.default(2048)
.describe("Caller-selected UTF-16 code units, matching offset."),
}),
],
);
export type BrowserScenarioViewRequest = z.output<
typeof browserScenarioViewRequestSchema
>;
/** One exact step whose unselected captured sections are named rather than returned. */
export const stepRowSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema,
after_url: sanitizedBrowserUrlSchema,
event_sequence_start: z.number().int().min(1),
event_sequence_end: countSchema,
completeness: browserScenarioCompletenessSchema,
artifacts: browserStepArtifactsSchema.partial(),
omitted_artifacts: z.array(
z.strictObject({
section: artifactSectionSchema,
state: z.enum(["captured", "missing", "truncated"]),
}),
),
});
const eventRowSchema = z.strictObject({
item_index: countSchema,
event: browserScenarioEventSchema,
});
export const browserScenarioViewItemsSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
collection: z.literal("steps"),
rows: z.array(stepRowSchema),
}),
z.strictObject({
collection: z.literal("events"),
rows: z.array(eventRowSchema),
}),
z.strictObject({
collection: z.literal("transactions"),
rows: z.array(
z.strictObject({
transaction_id: z.string().min(1),
source_item_indices: z.array(countSchema),
events: z.array(eventRowSchema),
omitted_events: z.array(
z.strictObject({
item_index: countSchema,
kind: browserScenarioEventKindSchema,
}),
),
}),
),
}),
z.strictObject({
collection: z.literal("text"),
step_index: countSchema,
artifact: z.enum(["accessibility", "dom"]),
state: z.enum(["captured", "missing", "truncated", "not_requested"]),
reason: z.string().nullable(),
sha256: digestSchema.nullable(),
bytes: countSchema.nullable(),
observed: countSchema.nullable(),
retained: countSchema.nullable(),
text: z.string().nullable(),
}),
],
);
/** Capture-wide facts every page repeats so exhaustion is not read as completeness. */
export const browserScenarioViewContextSchema = z.strictObject({
source_completeness: browserScenarioCompletenessSchema,
source_events: z.strictObject({
retained: countSchema,
dropped: countSchema,
}),
source_network_omissions: z.strictObject({
transactions: countSchema,
transaction_range:
retainedBrowserScenarioCaptureSchema.shape.events.shape.omissions.shape
.network_transaction_range,
}),
});