feat(process): add reactive capture scenarios

This commit is contained in:
morluto 2026-07-23 07:08:47 +00:00
parent 9df3fc4bbc
commit 69761e95b6
23 changed files with 1940 additions and 112 deletions

View File

@ -115,6 +115,37 @@ redacted from recorded text. The command-shim directory is placed first in the
captured process `PATH`; any remaining `PATH` must be explicitly supplied or
inherited under operator policy.
## Reactive process scenarios
Set `reactive` to a Process Reactive Scenario v1 declaration when interaction
must follow observed output instead of guessed delays. The graph declares an
initial state, absolute scenario and state deadlines, explicitly prioritized
transitions, bounded uses and visits, trigger predicates, ordered actions, and
either a next state or `passed` outcome.
The admitted runtime slice matches decoded UTF-8 terminal literals and exact
`terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or
`shim` journal events. Process events are sampled observations, not an
event-complete lifecycle. Filesystem events identify named checkpoints; shim
events identify a command and route. Live predicates receive the same normalized
and redacted payload later persisted in Evidence. Predicates may compose with
bounded `all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at
scenario start, state entry, a prior checkpoint, or an exact event ID. Actions
can write PTY input, resize it, signal the root process, or capture a named
filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition`
event triggers and non-root signal selectors fail during preflight before the
target launches.
Reactive results are recorded in `reactive_run`, including the terminal outcome,
active state, matched event IDs, action evidence IDs, and deterministic
transition journal. Terminal controls also commit the last admitted observation
order, so later output cannot be moved ahead of a timeout, cancellation, or
target loss during validation. If the process exits before the graph finishes,
the outcome is `target_lost`; state/scenario deadlines remain distinct
`predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain
available for fixed schedules, but they are not silently translated into the
reactive graph.
## Checkpoints and deterministic shims
Checkpoint triggers may use an elapsed time, a terminal literal and occurrence

View File

@ -0,0 +1,39 @@
import type { ProcessScenario } from "../domain/processCapture.js";
import type { LoopbackReplay } from "./LoopbackReplay.js";
import type { CommandShimReplay } from "./CommandShimReplay.js";
interface ProcessCaptureEnvironmentOptions {
readonly scenario: ProcessScenario;
readonly home: string;
readonly replay: LoopbackReplay;
readonly shimReplay: CommandShimReplay;
readonly runId: string;
}
/** Build the explicitly admitted environment for a captured process. */
export const makeProcessCaptureEnvironment = (
options: ProcessCaptureEnvironmentOptions,
): Record<string, string> => {
const { scenario, home, replay, shimReplay, runId } = options;
const environment: Record<string, string> = {
...scenario.environment,
HOME: home,
TERM: "xterm-256color",
REA_PROCESS_RUN_ID: runId,
};
for (const name of scenario.inherit_environment) {
const value = process.env[name];
if (value !== undefined) environment[name] = value;
}
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
// PATH is evidence-producing input. Inheriting the host PATH here would let
// the authority and reconstruction probe different tools without recording
// that difference. Scenarios must opt in through environment or
// inherit_environment; deterministic shims always take precedence.
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
.filter((part) => part.length > 0)
.join(":");
return environment;
};

View File

@ -71,6 +71,7 @@ interface CaptureResultOptions {
};
readonly samples: readonly ProcessSample[];
readonly replay: LoopbackReplay;
readonly reactiveRun: ProcessCapture["reactive_run"];
readonly before: SnapshotResult;
readonly after: SnapshotResult;
readonly truncated: boolean;
@ -116,6 +117,7 @@ export const buildCaptureResult = (
options.scenario,
),
replay_transitions: options.replay.transitions,
reactive_run: options.reactiveRun,
event_journal: options.eventJournal,
files_before: options.before.files,
files_after: options.after.files,

View File

@ -106,6 +106,12 @@ export class ProcessCheckpoints {
this.#scheduleCapture(name, this.#signal);
}
/** Read one committed checkpoint for synchronous journal projection. */
at(index: number): FilesystemCheckpoint | undefined {
const checkpoint = this.#captures[index];
return checkpoint === undefined ? undefined : structuredClone(checkpoint);
}
#scheduleCapture(name: string, signal: AbortSignal | undefined): void {
if (this.#captured.has(name)) return;
this.#captured.add(name);

View File

@ -15,7 +15,6 @@ import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
export { ProcessCaptureError } from "./ProcessCaptureError.js";
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
import { startProcessSampler } from "./ProcessSampling.js";
@ -26,7 +25,10 @@ import {
startCommandShimReplay,
type CommandShimReplay,
} from "./CommandShimReplay.js";
import { normalizeProcessText } from "./ProcessNormalization.js";
import {
normalizeProcessShimEvent,
normalizeProcessText,
} from "./ProcessNormalization.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
import {
awaitTerminalExit,
@ -42,45 +44,17 @@ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
import {
createProcessCaptureJournal,
scheduleScenarioInteractions,
type ProcessCaptureJournal,
} from "./ProcessCaptureJournal.js";
import {
assertSupportedReactiveScenario,
projectProcessReactiveRun,
startProcessReactiveHarness,
type ProcessReactiveHarness,
} from "./ProcessReactiveHarness.js";
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
interface EnvironmentOptions {
readonly scenario: ProcessScenario;
readonly home: string;
readonly replay: LoopbackReplay;
readonly shimReplay: CommandShimReplay;
readonly runId: string;
}
const makeEnvironment = (
options: EnvironmentOptions,
): Record<string, string> => {
const { scenario, home, replay, shimReplay, runId } = options;
const environment: Record<string, string> = {
...scenario.environment,
HOME: home,
TERM: "xterm-256color",
REA_PROCESS_RUN_ID: runId,
};
for (const name of scenario.inherit_environment) {
const value = process.env[name];
if (value !== undefined) environment[name] = value;
}
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
// PATH is evidence-producing input. Inheriting the host PATH here would let
// the authority and reconstruction probe different tools without recording
// that difference. Scenarios must opt in through environment or
// inherit_environment; deterministic shims always take precedence.
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
.filter((part) => part.length > 0)
.join(":");
return environment;
};
interface StartedCaptureRuntime {
readonly replay: LoopbackReplay;
readonly renderer: TerminalRenderer;
@ -92,6 +66,7 @@ interface StartedCaptureRuntime {
readonly lastOutput: () => number;
readonly framesTruncated: () => boolean;
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
readonly reactive: ProcessReactiveHarness | undefined;
}
const cleanupFailedStartup = async (options: {
@ -136,7 +111,7 @@ const createTerminalRenderer = (
recordEvent,
});
const startCaptureRuntime = async (options: {
interface StartCaptureRuntimeOptions {
readonly scenario: ProcessScenario;
readonly home: string;
readonly temporaryRoot: string;
@ -148,14 +123,29 @@ const startCaptureRuntime = async (options: {
readonly timers: Set<NodeJS.Timeout>;
readonly dispatchedEventIndexes: Set<number>;
readonly recordEvent: RecordProcessCaptureEvent;
readonly journal: ProcessCaptureJournal;
readonly signal?: AbortSignal;
}): Promise<StartedCaptureRuntime> => {
}
const reactiveCapture = (
options: StartCaptureRuntimeOptions,
protocolEvents: () => LoopbackReplay["events"],
): Parameters<typeof startProcessReactiveHarness>[0]["capture"] => ({
...options,
processSamples: options.samples,
protocolEvents,
});
const startCaptureRuntime = async (
options: StartCaptureRuntimeOptions,
): Promise<StartedCaptureRuntime> => {
const { scenario } = options;
let replay: LoopbackReplay | undefined;
let renderer: TerminalRenderer | undefined;
let checkpoints: ProcessCheckpoints | undefined;
let shimReplay: CommandShimReplay | undefined;
let terminal: IPty | undefined;
let reactive: ProcessReactiveHarness | undefined;
try {
const { spawn } = await import("@lydell/node-pty");
replay = await startLoopbackReplay(scenario, options.recordEvent);
@ -180,7 +170,7 @@ const startCaptureRuntime = async (options: {
);
terminal = spawn(scenario.executable, [...scenario.arguments], {
cwd: scenario.working_directory,
env: makeEnvironment({ ...options, replay, shimReplay }),
env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
cols: scenario.terminal.columns,
rows: scenario.terminal.rows,
name: "xterm-256color",
@ -193,6 +183,15 @@ const startCaptureRuntime = async (options: {
renderer,
checkpoints,
});
reactive = startProcessReactiveHarness({
capture: reactiveCapture(options, () => replay?.events ?? []),
scenario,
terminal: () => terminal,
renderer,
checkpoints,
shimReplay,
started,
});
scheduleScenarioInteractions({
...options,
getTerminal: () => terminal,
@ -218,8 +217,11 @@ const startCaptureRuntime = async (options: {
lastOutput: () => lastOutput,
framesTruncated,
stopSampler,
reactive,
};
} catch (cause: unknown) {
reactive?.unsubscribe();
await reactive?.coordinator.close();
return cleanupFailedStartup({
cause,
timers: options.timers,
@ -239,23 +241,14 @@ const normalizedShimEvents = (
scenario: ProcessScenario,
temporaryRoot: string,
): ProcessCapture["shim_events"] =>
runtime.shimReplay.events.map((event) => ({
...event,
arguments: event.arguments.map((argument) =>
normalizeProcessText(
argument,
scenario,
temporaryRoot,
runtime.terminal.pid,
),
),
working_directory: normalizeProcessText(
event.working_directory,
runtime.shimReplay.events.map((event) =>
normalizeProcessShimEvent(
event,
scenario,
temporaryRoot,
runtime.terminal.pid,
),
}));
);
const finishProcessRun = async (options: {
readonly runtime: StartedCaptureRuntime | undefined;
@ -267,6 +260,7 @@ const finishProcessRun = async (options: {
readonly capture: ProcessCapture | undefined;
readonly executionFailure: unknown;
}): Promise<ProcessCapture> => {
options.runtime?.reactive?.unsubscribe();
await options.stopSampler();
const cleanupFailure = await releaseProcessResources({
timers: options.timers,
@ -285,10 +279,16 @@ const finishProcessRun = async (options: {
options.samples,
),
});
if (cleanupFailure !== undefined)
await options.runtime?.reactive?.coordinator.submit({
kind: "cleanup_failed",
});
await options.runtime?.reactive?.coordinator.close();
let { capture, executionFailure } = options;
if (capture !== undefined) {
capture = {
...capture,
reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
settlement: {
...capture.settlement,
cleanup_outcome:
@ -326,7 +326,17 @@ const completeCapture = async (options: {
const { runtime, scenario } = options;
runtime.checkpoints.trigger("root_exit");
const { reason } = options.exit;
if (reason === "cancelled") throw processCaptureCancelled();
if (reason === "cancelled") {
if (runtime.reactive !== undefined) {
await runtime.reactive.coordinator.submit({ kind: "cancelled" });
runtime.reactive.unsubscribe();
}
throw processCaptureCancelled();
}
if (runtime.reactive !== undefined) {
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
runtime.reactive.unsubscribe();
}
const settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
@ -359,6 +369,7 @@ const completeCapture = async (options: {
exit: { ...options.exit, reason },
samples: options.samples,
replay: runtime.replay,
reactiveRun: projectProcessReactiveRun(runtime.reactive),
before: options.before,
after,
truncated,
@ -381,6 +392,7 @@ const runProcessScenario = async (
policy: ProcessExecutionPolicy,
signal?: AbortSignal,
): Promise<ProcessCapture> => {
assertSupportedReactiveScenario(scenario);
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(
scenario,
policy,
@ -388,7 +400,8 @@ const runProcessScenario = async (
);
const frames: TerminalFrame[] = [];
const samples: ProcessSample[] = [];
const { entries: eventJournal, recordEvent } = createProcessCaptureJournal();
const journal = createProcessCaptureJournal();
const { entries: eventJournal, recordEvent } = journal;
let runtime: StartedCaptureRuntime | undefined;
const timers = new Set<NodeJS.Timeout>();
let capture: ProcessCapture | undefined;
@ -410,6 +423,7 @@ const runProcessScenario = async (
timers,
dispatchedEventIndexes,
recordEvent,
journal,
...(signal === undefined ? {} : { signal }),
});
stopSampler = runtime.stopSampler;

View File

@ -2,6 +2,7 @@ import type {
ProcessCapture,
ProcessSample,
ProcessScenario,
ShimEvent,
} from "../domain/processCapture.js";
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
@ -91,6 +92,25 @@ export const normalizeProcessSamples = (
}));
};
/** Normalize one shim observation identically for live matching and capture. */
export const normalizeProcessShimEvent = (
event: ShimEvent,
scenario: ProcessScenario,
temporaryRoot: string,
rootPid: number,
): ShimEvent => ({
...event,
arguments: event.arguments.map((argument) =>
normalizeProcessText(argument, scenario, temporaryRoot, rootPid),
),
working_directory: normalizeProcessText(
event.working_directory,
scenario,
temporaryRoot,
rootPid,
),
});
const normalizePidTokens = (
value: string,
identifiers: readonly number[],

View File

@ -51,7 +51,7 @@ export class ProcessReactiveCoordinator {
readonly #now: () => number;
readonly #scenarioDeadlineAt: number;
readonly #onDecision:
| ((decision: ProcessReactiveDecision) => void)
| ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
| undefined;
#snapshot: ProcessReactiveSnapshot;
readonly #pendingInputs: ProcessReactiveInput[] = [];
@ -70,7 +70,10 @@ export class ProcessReactiveCoordinator {
readonly executor: ProcessReactiveExecutor;
readonly timerHost?: ProcessReactiveTimerHost;
readonly now?: () => number;
readonly onDecision?: (decision: ProcessReactiveDecision) => void;
readonly onDecision?: (
decision: ProcessReactiveDecision,
input: ProcessReactiveInput,
) => void;
}) {
this.#scenario = options.scenario;
this.#executor = options.executor;
@ -206,7 +209,7 @@ export class ProcessReactiveCoordinator {
);
}
this.#snapshot = decision.snapshot;
this.#onDecision?.(decision);
this.#onDecision?.(decision, input);
if (this.#snapshot.status === "finished") this.#clearTimers();
else if (
this.#accepting &&
@ -216,18 +219,35 @@ export class ProcessReactiveCoordinator {
)
this.#scheduleStateDeadline();
for (const result of effectResults)
if (result.status === "succeeded")
if (
result.status === "succeeded" &&
!this.#hasObservation(result.observation.event_id)
)
this.#enqueueOwned({
kind: "observation",
observation: result.observation,
});
}
#hasObservation(eventId: string): boolean {
return (
this.#snapshot.observations.some(
(observation) => observation.event_id === eventId,
) ||
this.#pendingInputs.some(
(input) =>
input.kind === "observation" &&
input.observation.event_id === eventId,
)
);
}
#interruptsEffects(input: ProcessReactiveInput): boolean {
if (
input.kind === "cancelled" ||
input.kind === "cleanup_failed" ||
input.kind === "scenario_deadline"
input.kind === "scenario_deadline" ||
input.kind === "target_lost"
)
return true;
return (

View File

@ -184,9 +184,15 @@ const triggerSources = (
): readonly string[] => {
if (trigger.kind === "terminal_text") return [];
if (trigger.kind === "event")
return ["terminal_raw", "interaction", "filesystem"].includes(
trigger.source,
)
return [
"terminal_raw",
"interaction",
"process",
"filesystem",
"http",
"websocket",
"shim",
].includes(trigger.source)
? []
: [trigger.source];
return trigger.kind === "repeat"

View File

@ -0,0 +1,159 @@
import type { IPty } from "@lydell/node-pty";
import type {
InteractionEvent,
ProcessCapture,
ProcessReactiveControlRecord,
ProcessSample,
ProcessScenario,
ProtocolEvent,
TerminalFrame,
} from "../domain/processCapture.js";
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
import type { ProcessCheckpoints } from "./ProcessCheckpoints.js";
import type { CommandShimReplay } from "./CommandShimReplay.js";
import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
import {
executeProcessReactiveEffects,
unsupportedProcessReactiveFeatures,
} from "./ProcessReactiveEffects.js";
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
import {
normalizeProcessSamples,
normalizeProcessShimEvent,
redactProtocolEvents,
} from "./ProcessNormalization.js";
import type { TerminalRenderer } from "./TerminalRenderer.js";
/** Coordinator and subscription owned by one live process capture. */
export interface ProcessReactiveHarness {
readonly coordinator: ProcessReactiveCoordinator;
readonly controls: readonly ProcessReactiveControlRecord[];
readonly unsubscribe: () => void;
}
const controlKind = (
input: ProcessReactiveInput,
): ProcessReactiveControlRecord["kind"] | undefined =>
input.kind === "observation" ? undefined : input.kind;
/** Reject reactive declarations the current process adapter cannot execute. */
export const assertSupportedReactiveScenario = (
scenario: ProcessScenario,
): void => {
if (scenario.reactive === null) return;
const unsupported = unsupportedProcessReactiveFeatures(scenario.reactive);
if (unsupported.length === 0) return;
throw new ProcessCaptureError(
`reactive scenario requests unsupported features: ${unsupported.join(", ")}`,
);
};
/**
* Attach the serialized reactive runtime once the target PID is available.
*
* Subscription replays journal entries admitted during launcher startup before
* observing new records, so PID-dependent normalization cannot drop them.
*/
export const startProcessReactiveHarness = (options: {
readonly scenario: ProcessScenario;
readonly terminal: () => IPty | undefined;
readonly renderer: TerminalRenderer;
readonly checkpoints: ProcessCheckpoints;
readonly shimReplay: CommandShimReplay;
readonly started: number;
readonly capture: {
readonly interactions: InteractionEvent[];
readonly journal: ProcessCaptureJournal;
readonly frames: readonly TerminalFrame[];
readonly processSamples: readonly ProcessSample[];
readonly protocolEvents: () => readonly ProtocolEvent[];
readonly temporaryRoot: string;
};
}): ProcessReactiveHarness | undefined => {
if (options.scenario.reactive === null) return undefined;
const controls: ProcessReactiveControlRecord[] = [];
const coordinator = new ProcessReactiveCoordinator({
scenario: options.scenario.reactive,
executor: {
execute: (actions, signal) =>
executeProcessReactiveEffects(
{
terminal: options.terminal,
renderer: options.renderer,
checkpoints: options.checkpoints,
interactions: options.capture.interactions,
journal: options.capture.journal,
startedAtMs: options.started,
now: Date.now,
},
actions,
signal,
),
},
onDecision: (decision, input) => {
const kind = controlKind(input);
if (kind === undefined || decision.kind !== "finished") return;
controls.push({
sequence: controls.length,
kind,
after_capture_order:
decision.snapshot.observations.at(-1)?.capture_order ?? -1,
});
},
});
return {
coordinator,
controls,
unsubscribe: subscribeProcessReactiveObservations({
journal: options.capture.journal,
frames: options.capture.frames,
interactions: options.capture.interactions,
checkpointAt: (index) => options.checkpoints.at(index),
processSampleAt: (index) => {
const rootPid = options.terminal()?.pid;
return rootPid === undefined
? undefined
: normalizeProcessSamples(
options.capture.processSamples,
options.scenario,
rootPid,
)[index];
},
protocolEventAt: (index) =>
redactProtocolEvents(
options.capture.protocolEvents().slice(index, index + 1),
options.scenario,
)[0],
shimEventAt: (index) => {
const event = options.shimReplay.events[index];
const rootPid = options.terminal()?.pid;
return event === undefined || rootPid === undefined
? undefined
: normalizeProcessShimEvent(
event,
options.scenario,
options.capture.temporaryRoot,
rootPid,
);
},
sink: coordinator,
}),
};
};
/** Project internal reducer state into the stable Process Capture shape. */
export const projectProcessReactiveRun = (
harness: ProcessReactiveHarness | undefined,
): ProcessCapture["reactive_run"] =>
harness === undefined
? null
: {
status: harness.coordinator.snapshot.status,
outcome: harness.coordinator.snapshot.outcome,
active_state: harness.coordinator.snapshot.active_state,
transitions: harness.coordinator.snapshot.transitions,
controls: harness.controls,
};

View File

@ -1,8 +1,17 @@
import type {
InteractionEvent,
FilesystemCheckpoint,
ProcessCaptureEventJournalEntry,
ProcessSample,
ProtocolEvent,
ShimEvent,
TerminalFrame,
} from "../domain/processCapture.js";
import { createProcessObservation } from "../domain/processObservation.js";
import {
createProcessObservation,
processObservationSubject,
type ProcessObservation,
} from "../domain/processObservation.js";
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
@ -11,25 +20,145 @@ export interface ProcessReactiveObservationSink {
enqueue(input: ProcessReactiveInput): void;
}
/** Subscribe a reactive coordinator to normalized raw PTY journal records. */
export const subscribeProcessReactiveTerminalObservations = (options: {
interface ProcessReactiveObservationOptions {
readonly journal: ProcessCaptureJournal;
readonly frames: readonly TerminalFrame[];
readonly interactions: readonly InteractionEvent[];
readonly checkpointAt: (index: number) => FilesystemCheckpoint | undefined;
readonly processSampleAt: (index: number) => ProcessSample | undefined;
readonly protocolEventAt: (index: number) => ProtocolEvent | undefined;
readonly shimEventAt: (index: number) => ShimEvent | undefined;
readonly sink: ProcessReactiveObservationSink;
}): (() => void) =>
options.journal.subscribe((entry: ProcessCaptureEventJournalEntry) => {
if (entry.collection !== "frames") return;
const frame = options.frames[entry.index];
if (frame === undefined) return;
options.sink.enqueue({
kind: "observation",
observation: createProcessObservation({
}
const terminalObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.frames[entry.index];
return value === undefined
? undefined
: createProcessObservation({
source: "terminal_raw",
source_sequence: frame.sequence,
captured_at_ms: frame.at_ms,
source_sequence: value.sequence,
captured_at_ms: value.at_ms,
subject_id: null,
location: entry,
payload: frame,
}),
});
payload: value,
});
};
const interactionObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.interactions[entry.index];
return value === undefined
? undefined
: createProcessObservation({
source: "interaction",
source_sequence: value.sequence,
captured_at_ms: value.dispatched_at_ms,
subject_id: null,
location: entry,
payload: value,
});
};
const filesystemObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.checkpointAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: "filesystem",
source_sequence: entry.index,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject("filesystem", value),
location: entry,
payload: value,
});
};
const processObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.processSampleAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: "process",
source_sequence: entry.index,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject("process", value),
location: entry,
payload: value,
});
};
const protocolObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.protocolEventAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: value.protocol,
source_sequence: value.sequence,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject(value.protocol, value),
location: entry,
payload: value,
});
};
const shimObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const normalized = options.shimEventAt(entry.index);
if (normalized === undefined) return undefined;
return createProcessObservation({
source: "shim",
source_sequence: normalized.sequence,
captured_at_ms: normalized.at_ms,
subject_id: processObservationSubject("shim", normalized),
location: entry,
payload: normalized,
});
};
const observationFor = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
if (entry.collection === "frames") return terminalObservation(options, entry);
if (entry.collection === "interaction_events")
return interactionObservation(options, entry);
if (entry.collection === "filesystem_checkpoints")
return filesystemObservation(options, entry);
if (entry.collection === "process_samples")
return processObservation(options, entry);
if (entry.collection === "protocol_events")
return protocolObservation(options, entry);
if (entry.collection === "shim_events")
return shimObservation(options, entry);
return undefined;
};
/** Subscribe the reactive coordinator to admitted normalized journal records. */
export const subscribeProcessReactiveObservations = (
options: ProcessReactiveObservationOptions,
): (() => void) => {
const enqueue = (entry: ProcessCaptureEventJournalEntry): void => {
const observation = observationFor(options, entry);
if (observation !== undefined)
options.sink.enqueue({ kind: "observation", observation });
};
for (const entry of options.journal.entries) enqueue(entry);
return options.journal.subscribe(enqueue);
};

View File

@ -5,6 +5,11 @@ import {
LEGACY_PROCESS_CAPTURE_MESSAGE,
normalizationSchema,
} from "./processScenario.js";
import type {
ProcessReactiveOutcome,
ProcessReactiveTransitionRecord,
} from "./processReactiveRuntime.js";
import { PROCESS_REACTIVE_LIMITS } from "./processReactiveScenario.js";
import type { ReplayTransitionRecord } from "./replayMachineRuntime.js";
export * from "./processScenario.js";
@ -133,6 +138,18 @@ export interface ProcessCaptureEventJournalEntry {
readonly index: number;
}
/** Ordered control input that terminated or superseded a reactive run. */
export interface ProcessReactiveControlRecord {
readonly sequence: number;
readonly kind:
| "state_deadline"
| "scenario_deadline"
| "target_lost"
| "cancelled"
| "cleanup_failed";
readonly after_capture_order: number;
}
/** Shared observation callback used by every process-capture producer. */
export type RecordProcessCaptureEvent = (
collection: ProcessCaptureEventCollection,
@ -185,6 +202,13 @@ export interface ProcessCapture {
readonly shim_events: readonly ShimEvent[];
readonly protocol_events: readonly ProtocolEvent[];
readonly replay_transitions: readonly ReplayTransitionRecord[];
readonly reactive_run: {
readonly status: "running" | "finished";
readonly outcome: ProcessReactiveOutcome | null;
readonly active_state: string;
readonly transitions: readonly ProcessReactiveTransitionRecord[];
readonly controls: readonly ProcessReactiveControlRecord[];
} | null;
/**
* Global observation order across independently recorded collections.
*
@ -363,6 +387,75 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
}),
)
.default([]),
reactive_run: z
.object({
status: z.enum(["running", "finished"]),
outcome: z
.enum([
"passed",
"predicate_timeout",
"scenario_deadline",
"ambiguous_match",
"action_rejected",
"target_lost",
"capture_incomplete",
"cancelled",
"cleanup_failed",
])
.nullable(),
active_state: z.string().min(1).max(64),
controls: z
.array(
z.object({
sequence: z.number().int().nonnegative(),
kind: z.enum([
"state_deadline",
"scenario_deadline",
"target_lost",
"cancelled",
"cleanup_failed",
]),
after_capture_order: z.number().int().min(-1),
}),
)
.default([]),
transitions: z
.array(
z.object({
sequence: z.number().int().nonnegative(),
transition_id: z.string().min(1).max(64),
state_before: z.string().min(1).max(64),
state_after: z.string().min(1).max(64).nullable(),
outcome: z
.enum([
"passed",
"predicate_timeout",
"scenario_deadline",
"ambiguous_match",
"action_rejected",
"target_lost",
"capture_incomplete",
"cancelled",
"cleanup_failed",
])
.nullable(),
trigger_event_ids: z
.array(z.string().min(1))
.max(PROCESS_REACTIVE_LIMITS.predicates),
action_event_ids: z
.array(z.string().min(1))
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
action_types: z
.array(
z.enum(["send_input", "resize", "send_signal", "checkpoint"]),
)
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
}),
)
.max(PROCESS_REACTIVE_LIMITS.transitions),
})
.nullable()
.default(null),
event_journal: z
.array(
z.object({

View File

@ -0,0 +1,273 @@
import canonicalize from "canonicalize";
import type { ProcessCapture } from "./processCapture.js";
import {
projectProcessObservation,
type ProcessObservation,
} from "./processObservation.js";
import {
commitProcessReactiveProposal,
createProcessReactiveSnapshot,
reduceProcessReactiveScenario,
type ProcessReactiveDecision,
type ProcessReactiveInput,
type ProcessReactiveSnapshot,
} from "./processReactiveRuntime.js";
import {
processReactiveScenarioSchema,
type ProcessReactiveScenario,
} from "./processReactiveScenario.js";
type RequireInvariant = (
condition: boolean,
path: string,
message: string,
) => void;
type ReactiveRun = NonNullable<ProcessCapture["reactive_run"]>;
type ReactiveTransition = ReactiveRun["transitions"][number];
const admittedCollection = (
collection: NonNullable<
ProcessCapture["event_journal"]
>[number]["collection"],
): boolean =>
[
"frames",
"interaction_events",
"process_samples",
"filesystem_checkpoints",
"shim_events",
"protocol_events",
].includes(collection);
const reactiveObservations = (
capture: ProcessCapture,
): readonly ProcessObservation[] =>
(capture.event_journal ?? []).flatMap((location) => {
if (!admittedCollection(location.collection)) return [];
const observation = projectProcessObservation(capture, location);
return observation === null ? [] : [observation];
});
const validateTransitionDeclaration = (
transition: ReactiveTransition,
next: ReactiveTransition | undefined,
context: {
readonly index: number;
readonly scenario: ProcessReactiveScenario;
readonly require: RequireInvariant;
},
): void => {
const { index, scenario, require } = context;
const path = `reactive_run.transitions[${String(index)}]`;
const declared = scenario.states
.find(({ id }) => id === transition.state_before)
?.on.find(({ id }) => id === transition.transition_id);
require(declared !==
undefined, path, "reactive transition is not declared by its recorded source state");
if (declared !== undefined) {
const stateAfter =
declared.target.kind === "goto" ? declared.target.state : null;
const outcome =
declared.target.kind === "finish" ? declared.target.outcome : null;
require(transition.state_after === stateAfter &&
transition.outcome ===
outcome, path, "reactive transition target or outcome differs from the committed graph");
require(canonicalize(transition.action_types) ===
canonicalize(
declared.actions.map(({ type }) => type),
), `${path}.action_types`, "reactive transition actions differ from the committed graph");
}
require(transition.sequence ===
index, `${path}.sequence`, "reactive transition sequence must be contiguous from zero");
if (next !== undefined)
require(transition.state_after ===
next.state_before, `reactive_run.transitions[${String(index + 1)}].state_before`, "reactive transition states must be contiguous");
};
const validateTransitionDeclarations = (
run: ReactiveRun,
scenario: ProcessReactiveScenario,
require: RequireInvariant,
): void => {
require(run.transitions[0]?.state_before === scenario.initial_state ||
run.transitions.length ===
0, "reactive_run.transitions[0].state_before", "reactive transition journal must start at the declared initial state");
for (const [index, transition] of run.transitions.entries())
validateTransitionDeclaration(transition, run.transitions[index + 1], {
index,
scenario,
require,
});
};
const commitRecordedEffects = (options: {
readonly scenario: ProcessReactiveScenario;
readonly proposal: Extract<
ProcessReactiveDecision,
{ readonly kind: "proposal" }
>;
readonly expected: ReactiveTransition | undefined;
readonly observations: ReadonlyMap<string, ProcessObservation>;
readonly recordedFailure: "action_rejected" | "target_lost" | undefined;
}): ProcessReactiveDecision =>
commitProcessReactiveProposal(
options.scenario,
options.proposal,
options.recordedFailure === undefined
? options.proposal.effects.map((_, index) => {
const eventId = options.expected?.action_event_ids[index];
const observation =
eventId === undefined
? undefined
: options.observations.get(eventId);
return observation === undefined
? { status: "rejected" as const }
: { status: "succeeded" as const, observation };
})
: [
options.recordedFailure === "target_lost"
? { status: "target_lost" as const }
: { status: "rejected" as const },
],
{ committedSensitiveInputs: true },
);
const replayTransitions = (
scenario: ProcessReactiveScenario,
run: ReactiveRun,
observations: readonly ProcessObservation[],
require: RequireInvariant,
): ProcessReactiveSnapshot => {
const observationsById = new Map(
observations.map((observation) => [observation.event_id, observation]),
);
let snapshot = createProcessReactiveSnapshot(scenario);
let transitionIndex = 0;
const advance = (input: ProcessReactiveInput): void => {
let decision = reduceProcessReactiveScenario(scenario, snapshot, input);
if (decision.kind === "proposal") {
const proposal = decision;
decision = commitRecordedEffects({
scenario,
proposal: decision,
expected: run.transitions[transitionIndex],
observations: observationsById,
recordedFailure:
run.transitions[transitionIndex] === undefined &&
decision.effects.length > 0 &&
(run.outcome === "action_rejected" || run.outcome === "target_lost")
? run.outcome
: undefined,
});
require(decision.kind !== "finished" ||
decision.outcome !== "action_rejected" ||
run.outcome ===
"action_rejected", `reactive_run.transitions[${String(transitionIndex)}].action_event_ids`, `recorded action observations do not match proposed effects: ${canonicalize(
{
effects: proposal.effects,
observations: run.transitions[transitionIndex]?.action_event_ids.map(
(eventId) => observationsById.get(eventId),
),
},
)}`);
}
if (decision.kind === "transition") {
const expected = run.transitions[transitionIndex];
require(expected !== undefined &&
canonicalize(decision.record) ===
canonicalize(
expected,
), `reactive_run.transitions[${String(transitionIndex)}]`, "reactive transition differs from deterministic journal replay");
transitionIndex += 1;
}
snapshot = decision.snapshot;
};
const advanceControl = (
control: ReactiveRun["controls"][number],
index: number,
): void => {
require(snapshot.status !== "finished" ||
control.kind ===
"cleanup_failed", `reactive_run.controls[${String(index)}]`, "reactive control cannot follow a finished run");
advance(
control.kind === "state_deadline"
? {
kind: control.kind,
state_id: snapshot.active_state,
state_entry_capture_order: snapshot.state_entry_capture_order,
}
: { kind: control.kind },
);
};
let controlIndex = 0;
for (const observation of observations) {
while (
(run.controls[controlIndex]?.after_capture_order ??
Number.MAX_SAFE_INTEGER) < observation.capture_order
) {
const control = run.controls[controlIndex]!;
advanceControl(control, controlIndex);
controlIndex += 1;
}
advance({ kind: "observation", observation });
}
for (; controlIndex < run.controls.length; controlIndex += 1) {
const control = run.controls[controlIndex]!;
advanceControl(control, controlIndex);
}
require(transitionIndex ===
run.transitions
.length, "reactive_run.transitions", `reactive transition journal is not reproduced by deterministic replay (replayed ${String(transitionIndex)} of ${String(run.transitions.length)}; outcome ${snapshot.outcome})`);
return snapshot;
};
const validateControls = (
run: ReactiveRun,
observations: readonly ProcessObservation[],
require: RequireInvariant,
): void => {
const admittedOrders = new Set([
-1,
...observations.map(({ capture_order }) => capture_order),
]);
for (const [index, control] of run.controls.entries()) {
require(control.sequence ===
index, `reactive_run.controls[${String(index)}].sequence`, "reactive control sequence must be contiguous");
require(admittedOrders.has(
control.after_capture_order,
), `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoff must identify the last admitted observation");
require(index === 0 ||
control.after_capture_order >=
run.controls[index - 1]!
.after_capture_order, `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoffs must be ordered");
}
};
/** Validate one capture's reactive journal by replaying the production reducer. */
export const validateProcessCaptureReactiveRun = (
capture: ProcessCapture,
require: RequireInvariant,
): void => {
const declared = capture.manifest.scenario["reactive"];
const run = capture.reactive_run;
const declaredReactive = declared !== undefined && declared !== null;
require(declaredReactive ===
(run !==
null), "reactive_run", "reactive result presence must match the committed scenario");
if (run === null) return;
const parsed = processReactiveScenarioSchema.safeParse(declared);
require(parsed.success, "manifest.scenario.reactive", "committed reactive scenario does not satisfy its declared schema");
if (!parsed.success) return;
require(run.status === "finished" &&
run.outcome !==
null, "reactive_run.outcome", "completed capture requires a finished reactive outcome");
validateTransitionDeclarations(run, parsed.data, require);
const observations = reactiveObservations(capture);
validateControls(run, observations, require);
const snapshot = replayTransitions(parsed.data, run, observations, require);
require(snapshot.outcome ===
run.outcome, "reactive_run.outcome", "reactive outcome differs from deterministic journal replay");
require(snapshot.active_state ===
run.active_state, "reactive_run.active_state", "reactive active state differs from deterministic journal replay");
};

View File

@ -1,4 +1,5 @@
import type { ProcessCapture } from "./processCapture.js";
import { validateProcessCaptureReactiveRun } from "./processCaptureReactiveValidation.js";
import { replayMachineSchema, type ReplayMachine } from "./replayMachine.js";
import { digestProcessCommitment } from "./processScenario.js";
@ -314,5 +315,6 @@ export const validateProcessCapture = (
validateLifecycle(capture, require);
validateShimEvents(capture, require);
validateReplayEvents(capture, require);
validateProcessCaptureReactiveRun(capture, require);
return issues;
};

View File

@ -109,16 +109,29 @@ const atMilliseconds = (value: unknown): number | null => {
return null;
};
const subjectFor = (
/** Derive the stable capture-local identity for one normalized observation. */
export const processObservationSubject = (
source: ProcessObservationSource,
value: JsonValue,
value: unknown,
): string | null => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
if (source === "process" && typeof value["pid"] === "number")
return `process:${String(value["pid"])}`;
if (source === "filesystem" && typeof value["name"] === "string")
return `checkpoint:${value["name"]}`;
if (source === "process" && "pid" in value && typeof value.pid === "number")
return `process:${String(value.pid)}`;
if (
source === "filesystem" &&
"name" in value &&
typeof value.name === "string"
)
return `checkpoint:${value.name}`;
if (
source === "shim" &&
"command" in value &&
typeof value.command === "string"
)
return "route_index" in value && typeof value.route_index === "number"
? `shim:${value.command}:route:${String(value.route_index)}`
: `shim:${value.command}:unmatched`;
return null;
};
@ -188,7 +201,7 @@ const projectedRecord = (
return {
source: projected.source,
captured_at_ms: atMilliseconds(payload),
subject_id: subjectFor(projected.source, payload),
subject_id: processObservationSubject(projected.source, payload),
payload,
};
};

View File

@ -65,6 +65,7 @@ export type ProcessReactiveInput =
readonly state_entry_capture_order: number;
}
| { readonly kind: "scenario_deadline" }
| { readonly kind: "target_lost" }
| { readonly kind: "cancelled" }
| { readonly kind: "cleanup_failed" };
@ -123,6 +124,33 @@ export type ProcessReactiveDecision =
const counterValue = (counters: readonly Counter[], id: string): number =>
counters.find((counter) => counter.id === id)?.count ?? 0;
type ObservationAdmission =
| { readonly kind: "new"; readonly snapshot: ProcessReactiveSnapshot }
| { readonly kind: "invalid" };
const observeInput = (
snapshot: ProcessReactiveSnapshot,
observation: ProcessObservation,
): ObservationAdmission => {
const duplicate = snapshot.observations.find(
({ event_id }) => event_id === observation.event_id,
);
if (duplicate !== undefined) return { kind: "invalid" };
const prior = snapshot.observations.at(-1);
if (
(prior !== undefined && observation.capture_order <= prior.capture_order) ||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
)
return { kind: "invalid" };
return {
kind: "new",
snapshot: {
...snapshot,
observations: [...snapshot.observations, observation],
},
};
};
/** Create the initial immutable reducer state for one validated scenario. */
export const createProcessReactiveSnapshot = (
scenario: ProcessReactiveScenario,
@ -166,6 +194,8 @@ const reduceControlInput = (
: { kind: "waiting", snapshot, evaluations: [] };
if (input.kind === "scenario_deadline")
return finishedDecision(snapshot, "scenario_deadline");
if (input.kind === "target_lost")
return finishedDecision(snapshot, "target_lost");
return finishedDecision(snapshot, "cancelled");
};
@ -179,23 +209,10 @@ export const reduceProcessReactiveScenario = (
if (controlDecision !== null) return controlDecision;
if (input.kind !== "observation")
return finishedDecision(snapshot, "capture_incomplete");
const prior = snapshot.observations.at(-1);
if (
(prior !== undefined &&
input.observation.capture_order <= prior.capture_order) ||
snapshot.observations.some(
({ event_id }) => event_id === input.observation.event_id,
)
)
const admitted = observeInput(snapshot, input.observation);
if (admitted.kind === "invalid")
return finishedDecision(snapshot, "capture_incomplete");
if (
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
)
return finishedDecision(snapshot, "capture_incomplete");
const observed = {
...snapshot,
observations: [...snapshot.observations, input.observation],
};
const observed = admitted.snapshot;
const state = scenario.states.find(({ id }) => id === observed.active_state);
if (state === undefined) return finishedDecision(observed, "target_lost");
const budget = { remaining: PROCESS_REACTIVE_LIMITS.evaluationWork };

View File

@ -124,6 +124,7 @@ const applyProcessReactiveTransition = (input: {
const effectObservationMatchesAction = (
action: ProcessReactiveScenario["states"][number]["on"][number]["actions"][number],
observation: ProcessObservation,
committedSensitiveInputs: boolean,
): boolean => {
const payload =
typeof observation.payload === "object" &&
@ -153,9 +154,11 @@ const effectObservationMatchesAction = (
return (
payload["type"] === "input" &&
payload["data"] ===
(action.sensitive
? `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`
: action.data)
expectedInputEvidence(
action.data,
action.sensitive,
committedSensitiveInputs,
)
);
if (action.type === "resize")
return (
@ -165,6 +168,16 @@ const effectObservationMatchesAction = (
return payload["type"] === "signal" && payload["data"] === action.signal;
};
const expectedInputEvidence = (
data: string,
sensitive: boolean,
committedSensitiveInput: boolean,
): string =>
!sensitive ||
(committedSensitiveInput && /^<redacted-input:\d+-bytes>$/.test(data))
? data
: `<redacted-input:${String(Buffer.byteLength(data))}-bytes>`;
const failedProposal = (
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
outcome: "action_rejected" | "target_lost",
@ -180,6 +193,7 @@ export const commitProcessReactiveProposal = (
scenario: ProcessReactiveScenario,
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
results: readonly ProcessReactiveEffectResult[],
options: { readonly committedSensitiveInputs?: boolean } = {},
): ProcessReactiveDecision => {
if (results.some(({ status }) => status === "target_lost"))
return failedProposal(proposed, "target_lost");
@ -227,7 +241,11 @@ export const commitProcessReactiveProposal = (
const effectObservation = result.observation;
if (
action === undefined ||
!effectObservationMatchesAction(action, effectObservation) ||
!effectObservationMatchesAction(
action,
effectObservation,
options.committedSensitiveInputs ?? false,
) ||
!Number.isSafeInteger(effectObservation.capture_order) ||
effectObservation.capture_order <= previousOrder ||
knownEventIds.has(effectObservation.event_id)

View File

@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
import { resolve } from "node:path";
import canonicalize from "canonicalize";
import { z } from "zod";
import {
processReactiveScenarioSchema,
type ProcessReactiveScenario,
} from "./processReactiveScenario.js";
import { replayMachineSchema } from "./replayMachine.js";
const positiveBudget = z.number().int().positive();
@ -51,6 +55,29 @@ export const digestProcessCommitment = (value: unknown): string => {
return createHash("sha256").update(serialized).digest("hex");
};
const reactiveScenarioCommitment = (
scenario: ProcessReactiveScenario | null,
): Readonly<Record<string, unknown>> | null =>
scenario === null
? null
: {
...scenario,
states: scenario.states.map((state) => ({
...state,
on: state.on.map((transition) => ({
...transition,
actions: transition.actions.map((action) =>
action.type === "send_input" && action.sensitive
? {
...action,
data: `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`,
}
: action,
),
})),
})),
};
/** Build the secret-safe scenario projection committed by Process Capture v4. */
export const processScenarioCommitment = (
scenario: ProcessScenario,
@ -63,6 +90,7 @@ export const processScenarioCommitment = (
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : value,
]),
),
reactive: reactiveScenarioCommitment(scenario.reactive),
unknown_registry_approved: scenario.unknown_registry_approved === true,
executable_sha256: executableSha256 ?? null,
});
@ -86,6 +114,7 @@ export const processComparisonContract = (
normalization: scenario.normalization,
command_shims: scenario.command_shims,
replay: scenario.replay,
reactive: reactiveScenarioCommitment(scenario.reactive),
});
/**
@ -292,6 +321,7 @@ export const processScenarioSchema = z
websocket_messages: [],
websocket_connections: [],
}),
reactive: processReactiveScenarioSchema.nullable().default(null),
})
.strict()
.superRefine((scenario, context) => {

View File

@ -0,0 +1,26 @@
import { spawn, spawnSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { WebSocket } from "ws";
process.stdout.write("Ready\n");
const worker = spawn(
process.execPath,
["-e", 'process.title = "rea-reactive-worker"; setTimeout(() => {}, 750);'],
{ stdio: "ignore" },
);
process.stdout.write("Collecting\n");
spawnSync(process.argv[2], ["probe"], { stdio: "ignore" });
await writeFile("reactive-result.txt", "created");
await fetch(`${process.env.REA_REPLAY_HTTP_URL}/reactive`);
await new Promise((resolve, reject) => {
const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);
socket.once("open", () => socket.send("reactive-client"));
socket.once("message", () => socket.close());
socket.once("close", resolve);
socket.once("error", reject);
});
await new Promise((resolve, reject) => {
worker.once("exit", resolve);
worker.once("error", reject);
});

View File

@ -103,6 +103,7 @@ const emptyCapture = (): ProcessCapture => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
event_journal: [],
files_before: [],
files_after: [],
@ -187,6 +188,7 @@ describe("process capture domain", () => {
];
const result = buildCaptureResult({
frames: [],
reactiveRun: null,
exit: { exitCode: 0, reason: "exited" },
samples: [],
replay: {
@ -452,6 +454,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@ -696,6 +699,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@ -756,6 +760,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@ -800,6 +805,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@ -873,6 +879,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@ -1507,6 +1514,407 @@ describe("process capture adapter", () => {
}
});
it("drives a process from terminal observations and retains the reactive transition journal", async () => {
const root = await createTestTempDirectory("rea-reactive-harness-test-");
const script = join(root, "reactive.mjs");
await writeFile(
script,
[
'import { createInterface } from "node:readline";',
"const input = createInterface({ input: process.stdin, terminal: false });",
'process.stdout.write("Ready\\n");',
'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });',
].join("\n"),
);
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 5_000,
states: [
{
id: "waiting",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "answer",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [
{ type: "send_input", data: "accepted\n", sensitive: true },
],
target: { kind: "goto", state: "finishing" },
},
],
},
{
id: "finishing",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "complete",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Done",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "state_entry" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
active_state: "finishing",
});
expect(
result.value.reactive_run?.transitions.map(
({ transition_id }) => transition_id,
),
).toEqual(["answer", "complete"]);
expect(result.value.interaction_events).toContainEqual(
expect.objectContaining({
type: "input",
data: "<redacted-input:9-bytes>",
outcome: "dispatched",
}),
);
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"Done",
);
expect(result.value.manifest.comparison_contract).toHaveProperty(
"reactive",
);
expect(
JSON.stringify(result.value.manifest.comparison_contract),
).not.toContain("accepted");
expect(JSON.stringify(result.value.manifest.scenario)).not.toContain(
"accepted",
);
const reactiveRun = result.value.reactive_run;
if (reactiveRun === null) throw new Error("reactive result missing");
const lastReactiveOrder = result.value.event_journal
?.filter(({ collection }) =>
[
"frames",
"interaction_events",
"filesystem_checkpoints",
"shim_events",
].includes(collection),
)
.at(-1)?.capture_order;
if (lastReactiveOrder === undefined)
throw new Error("reactive observation order missing");
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
controls: [
...reactiveRun.controls,
{
sequence: reactiveRun.controls.length,
kind: "target_lost",
after_capture_order: lastReactiveOrder,
},
],
},
}),
).toContainEqual(
expect.objectContaining({
path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`,
message: "reactive control cannot follow a finished run",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? { ...transition, transition_id: "fabricated" }
: transition,
),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0]",
message:
"reactive transition is not declared by its recorded source state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
status: "finished",
outcome: "passed",
active_state: "waiting",
transitions: [],
controls: [],
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
const suffix = reactiveRun.transitions
.slice(1)
.map((transition, index) => ({
...transition,
sequence: index,
}));
expect(
validateProcessCapture({
...result.value,
reactive_run: { ...reactiveRun, transitions: suffix },
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0].state_before",
message:
"reactive transition journal must start at the declared initial state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? {
...transition,
trigger_event_ids: [],
action_event_ids: [],
}
: transition,
),
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "reactive_run.transitions",
message: expect.stringContaining(
"reactive transition journal is not reproduced",
),
}),
expect.objectContaining({
path: "reactive_run.transitions[0].action_event_ids",
message: expect.stringContaining(
"recorded action observations do not match proposed effects",
),
}),
]),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
status: "running",
outcome: null,
},
}),
).toContainEqual(
expect.objectContaining({
message: "completed capture requires a finished reactive outcome",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: [
reactiveRun.transitions[0]!,
{ ...reactiveRun.transitions[0]!, sequence: 1 },
],
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "reactive transition states must be contiguous",
}),
expect.objectContaining({
message:
"reactive transition differs from deterministic journal replay",
}),
]),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("records target loss before post-exit settlement can win the deadline race", async () => {
const root = await createTestTempDirectory(
"rea-reactive-target-loss-test-",
);
const script = join(root, "exit.mjs");
await writeFile(script, 'process.stdout.write("exiting\\n");\n');
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
settle_ms: 500,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 2_000,
states: [
{
id: "waiting",
max_visits: 1,
deadline_ms: 300,
on: [
{
id: "unreachable",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "never-produced",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "target_lost",
active_state: "waiting",
transitions: [],
controls: [
expect.objectContaining({
kind: "target_lost",
}),
],
});
const frame = result.value.frames.at(-1);
const journal = result.value.event_journal;
const reactiveRun = result.value.reactive_run;
if (frame === undefined || journal === undefined || reactiveRun === null)
throw new Error("ordered reactive evidence missing");
const laterCaptureOrder = journal.length;
const withLaterMatch = {
...result.value,
frames: [
...result.value.frames,
{
...frame,
sequence: result.value.frames.length,
at_ms: frame.at_ms + 1,
data: "never-produced",
},
],
event_journal: [
...journal,
{
capture_order: laterCaptureOrder,
collection: "frames" as const,
index: result.value.frames.length,
},
],
};
expect(
validateProcessCapture(withLaterMatch).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
expect(
validateProcessCapture({
...withLaterMatch,
reactive_run: {
...reactiveRun,
controls: reactiveRun.controls.map((control) => ({
...control,
after_capture_order: laterCaptureOrder,
})),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
const root = await createTestTempDirectory("rea-v3-test-");
const script = join(root, "scenario.mjs");
@ -1529,7 +1937,6 @@ describe("process capture adapter", () => {
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
checkpoints: [
{
name: "probe_seen",
@ -1562,6 +1969,75 @@ describe("process capture adapter", () => {
],
},
],
reactive: {
version: 1,
initial_state: "waiting_for_shim",
deadline_ms: 5_000,
states: [
{
id: "waiting_for_shim",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "record_shim",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "shim",
exact: {
command: "codex",
route_index: 0,
arguments: ["--version"],
working_directory: "<working-directory>",
outcome: "matched",
},
ignore_fields: ["sequence", "at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [
{ type: "checkpoint", name: "reactive_shim_seen" },
],
target: {
kind: "goto",
state: "waiting_for_checkpoint",
},
},
],
},
{
id: "waiting_for_checkpoint",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "finish_checkpoint",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "filesystem",
exact: {
name: "reactive_shim_seen",
files: [],
effects: [],
truncated: false,
},
ignore_fields: ["at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
@ -1591,12 +2067,300 @@ describe("process capture adapter", () => {
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(["before", "probe_seen", "after_settlement"]);
).toEqual(
expect.arrayContaining([
"before",
"reactive_shim_seen",
"probe_seen",
"after_settlement",
]),
);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
transitions: [
expect.objectContaining({
transition_id: "record_shim",
trigger_event_ids: ["obs.shim_events.0"],
action_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
expect.objectContaining({
transition_id: "finish_checkpoint",
trigger_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
],
});
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("runs the committed multi-source reactive fixture deterministically", async () => {
const root = await createTestTempDirectory("rea-reactive-e2e-");
const script = fileURLToPath(
new URL("./fixtures/processReactiveScenario.mjs", import.meta.url),
);
const event = (
source: "process" | "filesystem" | "http" | "websocket" | "shim",
exact: Record<string, unknown>,
ignoreFields: readonly string[],
) => ({
kind: "event" as const,
source,
exact,
ignore_fields: ignoreFields,
since: { kind: "scenario_start" as const },
consume: true,
cardinality: { min: 1, max: 1 },
});
const run = () =>
captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script, "codex"],
working_directory: root,
replay: {
http: [
{
method: "GET",
path: "/reactive",
status: 200,
body: "ok",
},
],
websocket_messages: ["reactive-server"],
},
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["probe"],
outputs: [],
termination: { type: "exit", code: 0 },
},
],
},
],
reactive: {
version: 1,
initial_state: "ready",
deadline_ms: 8_000,
states: [
{
id: "ready",
max_visits: 1,
deadline_ms: 3_000,
on: [
{
id: "checkpoint_ready",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [{ type: "checkpoint", name: "ready_snapshot" }],
target: { kind: "goto", state: "collecting" },
},
],
},
{
id: "collecting",
max_visits: 1,
deadline_ms: 5_000,
on: [
{
id: "finish_all_sources",
priority: 0,
max_uses: 1,
when: {
kind: "all",
triggers: [
{
kind: "any",
triggers: [
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Collecting",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "unreachable-alternative",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
],
},
event(
"process",
{
pid: 3,
parent_pid: 1,
command: "rea-reactive-worker",
process_group_id: 1,
session_id: 1,
},
["at_ms"],
),
event(
"filesystem",
{
name: "ready_snapshot",
files: [],
effects: [],
truncated: false,
},
["at_ms"],
),
event(
"shim",
{
command: "codex",
route_index: 0,
arguments: ["probe"],
working_directory: "<working-directory>",
outcome: "matched",
},
["sequence", "at_ms"],
),
{
kind: "sequence",
triggers: [
event(
"http",
{
protocol: "http",
direction: "request",
method: "GET",
path: "/reactive",
data: "",
outcome: "matched",
},
["sequence", "at_ms"],
),
event(
"websocket",
{
protocol: "websocket",
direction: "received",
method: null,
path: "/ws",
data: "reactive-client",
outcome: "matched",
},
["sequence", "at_ms"],
),
],
},
],
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const first = await run();
const second = await run();
if (!first.ok) throw first.error;
if (!second.ok) throw second.error;
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
const trace = (capture: ProcessCapture) =>
capture.reactive_run?.transitions.map((transition) => ({
transition_id: transition.transition_id,
from: transition.state_before,
to: transition.state_after,
outcome: transition.outcome,
trigger_collections: transition.trigger_event_ids.map(
(id) => id.split(".")[1],
),
}));
expect(
first.value.reactive_run?.outcome,
JSON.stringify({
run: first.value.reactive_run,
processes: first.value.process_samples,
checkpoints: first.value.filesystem_checkpoints,
shims: first.value.shim_events,
protocols: first.value.protocol_events,
}),
).toBe("passed");
expect(trace(first.value)).toEqual(trace(second.value));
expect(trace(first.value)).toEqual([
expect.objectContaining({ transition_id: "checkpoint_ready" }),
expect.objectContaining({
transition_id: "finish_all_sources",
trigger_collections: expect.arrayContaining([
"frames",
"process_samples",
"filesystem_checkpoints",
"shim_events",
"protocol_events",
]),
}),
]);
for (const outcome of ["action_rejected", "target_lost"] as const) {
const failedActionCapture = {
...first.value,
reactive_run: {
status: "finished" as const,
outcome,
active_state: "ready",
transitions: [],
controls: [],
},
};
expect(
validateProcessCapture(failedActionCapture).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
}
expect(first.value.truncated).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
it("does not follow or disclose symlink targets outside declared roots", async () => {
const root = await createTestTempDirectory("rea-symlink-test-");
await symlink("/etc/passwd", join(root, "escape"));

View File

@ -3,7 +3,8 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { mkdir, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
@ -106,6 +107,130 @@ describe("process-capture MCP elicitation", () => {
}
});
it.each(["tampered", "expired"] as const)(
"rejects %s signed state through the modern MCP client and server",
async (failure) => {
const startedAt = Date.parse("2026-07-23T00:00:00.000Z");
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(startedAt);
const root = await createTestTempDirectory("rea-elicit-state-");
const authority = new PermissionAuthority(
createPermissionPolicy([
{
capability: "process_capture",
roots: [root],
executables: [process.execPath],
environment_names: [],
network: "external",
mount: false,
},
]),
);
const session = new BinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const client = new Client(
{ name: "process-elicit-state", version: "1" },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: {
mode: {
pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0],
},
},
inputRequired: { autoFulfill: false, maxRounds: 3 },
},
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const server = serveStdio(
() =>
createServer(session, session, {
logger: silentLogger,
permissionAuthority: authority,
processPolicy: {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
}),
{ transport: serverTransport, legacy: "reject" },
);
const call = {
name: "capture_process_scenario",
arguments: {
approved: true,
executable: process.execPath,
arguments: ["-e", "process.exit(0)"],
working_directory: root,
},
};
try {
await client.connect(clientTransport);
const required = await client.callTool(call, {
allowInputRequired: true,
});
expect(isInputRequiredResult(required)).toBe(true);
if (!isInputRequiredResult(required)) return;
const requestState = required.requestState;
expect(requestState).toEqual(expect.any(String));
if (requestState === undefined) return;
if (failure === "expired")
vi.setSystemTime(
startedAt +
(PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1_000,
);
const echoedState =
failure === "tampered"
? `${requestState.slice(0, -1)}${requestState.endsWith("A") ? "B" : "A"}`
: requestState;
const retryMethod: string = "tools/call";
await expect(
client.request(
{
method: retryMethod,
params: {
...call,
requestState: echoedState,
inputResponses: {
process_capture_grant: {
action: "accept",
content: { lifetime: "session" },
},
},
},
},
z.unknown(),
),
).rejects.toMatchObject({
code: -32_602,
data: { reason: "invalid_request_state" },
});
expect(
await authority.authorize(
{
capability: "process_capture",
roots: [root],
executables: [process.execPath],
environment_names: [],
network: "external",
mount: false,
operation_identity: `capture_process_scenario:${process.execPath}`,
},
"read",
),
).toMatchObject({ ok: false });
} finally {
await Promise.allSettled([client.close(), server.close()]);
await rm(root, { recursive: true, force: true });
vi.useRealTimers();
}
},
);
it("grants and reuses a signed session scope on the modern protocol", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;

View File

@ -122,6 +122,10 @@ describe("process observation vocabulary", () => {
captured_at_ms: 4,
subject_id: "process:2",
});
expect(observations[7]).toMatchObject({
event_id: "obs.shim_events.0",
subject_id: "shim:helper:route:0",
});
expect(observations[3]?.captured_at_ms).toBeNull();
expect(observations[4]?.captured_at_ms).toBeNull();
});

View File

@ -9,7 +9,7 @@ import {
unsupportedProcessReactiveFeatures,
} from "../src/application/ProcessReactiveEffects.js";
import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js";
import { subscribeProcessReactiveTerminalObservations } from "../src/application/ProcessReactiveObservations.js";
import { subscribeProcessReactiveObservations } from "../src/application/ProcessReactiveObservations.js";
import type {
FilesystemCheckpoint,
InteractionEvent,
@ -149,9 +149,14 @@ const createHarness = (scenario: ProcessReactiveScenario) => {
},
timerHost: timers.host,
});
const unsubscribe = subscribeProcessReactiveTerminalObservations({
const unsubscribe = subscribeProcessReactiveObservations({
journal,
frames,
interactions,
checkpointAt: (index) => checkpoints[index],
processSampleAt: () => undefined,
protocolEventAt: () => undefined,
shimEventAt: () => undefined,
sink: coordinator,
});
return {

View File

@ -194,6 +194,27 @@ describe("process reactive runtime", () => {
});
});
it("treats redaction-shaped live input as ordinary sensitive data", () => {
const scenario = scenarioWith([
finish("ready", terminalTrigger(), 100, [
{
type: "send_input",
data: "<redacted-input:1-bytes>",
sensitive: true,
},
]),
]);
const decision = offer(
scenario,
createProcessReactiveSnapshot(scenario),
observation("terminal_raw", 0, { data: "Ready" }),
);
expect(decision).toMatchObject({
kind: "transition",
record: { outcome: "passed" },
});
});
it("reports only the minimal terminal chunk span containing the occurrence", () => {
const scenario = scenarioWith([finish("ready", terminalTrigger())]);
let snapshot = createProcessReactiveSnapshot(scenario);
@ -638,5 +659,16 @@ describe("process reactive runtime", () => {
kind: "finished",
outcome: "capture_incomplete",
});
const duplicate = observation("shim", 1, { name: "later" });
expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({
kind: "finished",
outcome: "capture_incomplete",
});
expect(
offer(scenario, first.snapshot, {
...duplicate,
payload: { name: "changed" },
}),
).toMatchObject({ kind: "finished", outcome: "capture_incomplete" });
});
});