rea/src/application/ProcessReactiveHarness.ts

185 lines
6.3 KiB
TypeScript

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,
type ProcessReactiveTerminal,
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 reactiveTerminalFor = (
terminal: IPty | undefined,
): ProcessReactiveTerminal | undefined => {
if (terminal === undefined) return undefined;
return {
write: (data) => terminal.write(data),
resize: (columns, rows) => terminal.resize(columns, rows),
closeInput: () => {
const end: unknown = Reflect.get(terminal, "end");
if (typeof end !== "function")
throw new ProcessCaptureError(
"PTY backend does not expose an stdin-close operation",
);
Reflect.apply(end, terminal, []);
},
kill: (signal) => terminal.kill(signal),
};
};
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: () => reactiveTerminalFor(options.terminal()),
renderer: options.renderer,
checkpoints: options.checkpoints,
interactions: options.capture.interactions,
journal: options.capture.journal,
startedAtMs: options.started,
now: Date.now,
timeBucketMs: options.scenario.normalization.time_bucket_ms,
},
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"] => {
if (harness === undefined) return null;
const snapshot = harness.coordinator.snapshot;
const completion =
snapshot.status === "running"
? { status: snapshot.status, outcome: null }
: { status: snapshot.status, outcome: snapshot.outcome };
return {
...completion,
active_state: snapshot.active_state,
transitions: snapshot.transitions,
controls: harness.controls,
};
};