* fix(process): order interaction differences by their dispatch time first_divergence ranks candidate differences by the earliest recorded time of the differing observations, but eventTime read only `at_ms`. Interaction events record their time as `dispatched_at_ms` (and `scheduled_at_ms`), so a changed interaction always ranked as untimed. An interaction outcome that differed at 6 ms therefore lost to a process sample that differed at 500 ms. Give each comparison dimension its recorded time field. Interactions use the dispatch time, when their outcome was observed; the other dimensions keep `at_ms`. An earlier process difference still wins. Fixes #2645 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(process): require ordered interaction dispatch times in captures Comparison ranks a changed interaction by its dispatch time, while firstCollectionDivergence returns the first differing interaction by sequence. The capture parser checked only that interaction sequences are contiguous, so an imported capture whose dispatch times decrease could make that first-by-sequence difference later than another differing interaction. Interactions are dispatched in sequence and their times come from the monotonic capture clock, so require non-decreasing dispatch times at parsing, as frames, rendered frames, process samples and filesystem checkpoints already require for their timestamps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
7.1 KiB
TypeScript
267 lines
7.1 KiB
TypeScript
import { expect, it } from "vitest";
|
|
|
|
import {
|
|
accountFullyObservedProcessCapture,
|
|
compareUnverifiedProcessCaptures as compareProcessCaptures,
|
|
emptyProcessCapture as emptyCapture,
|
|
} from "./processCapture.fixture.js";
|
|
|
|
const compareFullyObserved = (
|
|
left: Parameters<typeof compareProcessCaptures>[0],
|
|
right: Parameters<typeof compareProcessCaptures>[1],
|
|
options?: Parameters<typeof compareProcessCaptures>[2],
|
|
) =>
|
|
compareProcessCaptures(
|
|
accountFullyObservedProcessCapture(left),
|
|
accountFullyObservedProcessCapture(right),
|
|
options,
|
|
);
|
|
|
|
it("compares normalized terminal facts while retaining different source text for audit", () => {
|
|
const capture = emptyCapture();
|
|
const frame = { sequence: 0, at_ms: 0, data: "listen:<port> score=20" };
|
|
const left = {
|
|
...capture,
|
|
frames: [{ ...frame, raw_data: "listen:8080 score=20" }],
|
|
};
|
|
const right = {
|
|
...capture,
|
|
frames: [{ ...frame, raw_data: "listen:9090 score=20" }],
|
|
};
|
|
expect(compareFullyObserved(left, right)).toMatchObject({
|
|
terminal: "unchanged",
|
|
});
|
|
expect(
|
|
compareFullyObserved(left, {
|
|
...right,
|
|
frames: [{ ...frame, data: "listen:<port> score=21" }],
|
|
}),
|
|
).toMatchObject({ terminal: "changed" });
|
|
});
|
|
|
|
it("distinguishes added evidence from unknown observations", () => {
|
|
const capture = emptyCapture();
|
|
const added = compareFullyObserved(capture, {
|
|
...capture,
|
|
frames: [{ sequence: 0, at_ms: 0, data: "new" }],
|
|
rendered_frames: [
|
|
{
|
|
sequence: 0,
|
|
at_ms: 0,
|
|
columns: 3,
|
|
rows: 1,
|
|
cursor_x: 3,
|
|
cursor_y: 0,
|
|
active_buffer: "normal",
|
|
lines: ["new"],
|
|
serialized_state: "new",
|
|
},
|
|
],
|
|
});
|
|
expect(added).toMatchObject({ terminal: "added", status: "changed" });
|
|
|
|
for (const [scope, dimension] of [
|
|
["process", "process"],
|
|
["interaction", "interaction"],
|
|
] as const) {
|
|
expect(
|
|
compareFullyObserved(
|
|
{
|
|
...capture,
|
|
residual_unknowns: [{ scope, reason: "observation was partial" }],
|
|
},
|
|
capture,
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
[dimension]: "unknown",
|
|
terminal: "unchanged",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("compares raw terminal and process observations while honoring normalization shape", () => {
|
|
const capture = emptyCapture();
|
|
expect(
|
|
compareFullyObserved(capture, {
|
|
...capture,
|
|
normalization: {
|
|
patterns: [],
|
|
time_bucket_ms: 10,
|
|
ports: true,
|
|
pids: true,
|
|
paths: true,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
status: "unchanged",
|
|
first_divergence: { status: "none" },
|
|
});
|
|
|
|
const terminal = compareFullyObserved(
|
|
{ ...capture, frames: [{ sequence: 0, at_ms: 0, data: "bar" }] },
|
|
{ ...capture, frames: [{ sequence: 0, at_ms: 0, data: "foo\rbar" }] },
|
|
);
|
|
expect(terminal).toMatchObject({
|
|
terminal: "changed",
|
|
first_divergence: { status: "found", dimension: "terminal" },
|
|
});
|
|
|
|
expect(
|
|
compareFullyObserved(
|
|
{
|
|
...capture,
|
|
process_samples: [
|
|
{
|
|
at_ms: 10,
|
|
pid: 1,
|
|
parent_pid: 0,
|
|
process_group_id: 1,
|
|
session_id: 1,
|
|
command: "worker",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
...capture,
|
|
process_samples: [
|
|
{
|
|
at_ms: 20,
|
|
pid: 1,
|
|
parent_pid: 2,
|
|
process_group_id: 1,
|
|
session_id: 1,
|
|
command: "worker",
|
|
},
|
|
],
|
|
},
|
|
),
|
|
).toMatchObject({ process: "changed", status: "changed" });
|
|
});
|
|
|
|
it("keeps filesystem comparison unknown without coverage and reports observed changes", () => {
|
|
const capture = emptyCapture();
|
|
const incomplete = {
|
|
...capture,
|
|
residual_unknowns: [
|
|
{ scope: "filesystem" as const, reason: "watcher unavailable" },
|
|
],
|
|
};
|
|
expect(compareFullyObserved(incomplete, incomplete)).toMatchObject({
|
|
filesystem: "unknown",
|
|
status: "unknown",
|
|
});
|
|
|
|
const finalFile = {
|
|
path: "root_0:created.txt",
|
|
type: "file" as const,
|
|
mode: 0o644,
|
|
size: 4,
|
|
sha256: "a".repeat(64),
|
|
symlink_target: null,
|
|
};
|
|
expect(
|
|
compareFullyObserved(capture, {
|
|
...capture,
|
|
filesystem_checkpoints: [
|
|
{ name: "before", at_ms: 0, files: [], effects: [], truncated: false },
|
|
{
|
|
name: "after_settlement",
|
|
at_ms: 10,
|
|
files: [finalFile],
|
|
effects: [
|
|
{
|
|
path: finalFile.path,
|
|
status: "created",
|
|
before: null,
|
|
after: finalFile,
|
|
},
|
|
],
|
|
truncated: false,
|
|
},
|
|
],
|
|
files_after: [finalFile],
|
|
filesystem_effects: [
|
|
{
|
|
path: finalFile.path,
|
|
status: "created",
|
|
before: null,
|
|
after: finalFile,
|
|
},
|
|
],
|
|
}),
|
|
).toMatchObject({ filesystem: "changed", status: "changed" });
|
|
});
|
|
|
|
it.each([
|
|
[500, "interaction", 6],
|
|
[4, "process", 4],
|
|
] as const)(
|
|
"orders an interaction difference by its dispatch time (process sample at %i ms)",
|
|
(processTime, dimension, at) => {
|
|
const capture = (side: "left" | "right") => {
|
|
const base = emptyCapture();
|
|
const journal = [
|
|
{ collection: "filesystem_checkpoints" as const, index: 0 },
|
|
...(processTime < 6
|
|
? [{ collection: "process_samples" as const, index: 0 }]
|
|
: []),
|
|
{ collection: "interaction_events" as const, index: 0 },
|
|
...(processTime >= 6
|
|
? [{ collection: "process_samples" as const, index: 0 }]
|
|
: []),
|
|
{ collection: "lifecycle" as const, index: 0 },
|
|
{ collection: "lifecycle" as const, index: 1 },
|
|
{ collection: "filesystem_checkpoints" as const, index: 1 },
|
|
].map((entry, capture_order) => ({ ...entry, capture_order }));
|
|
return {
|
|
...base,
|
|
manifest: {
|
|
...base.manifest,
|
|
completed_at: "2026-01-01T00:00:01.000Z",
|
|
},
|
|
settlement: { ...base.settlement, elapsed_ms: 1000 },
|
|
filesystem_checkpoints: base.filesystem_checkpoints.map(
|
|
(checkpoint, index) => ({
|
|
...checkpoint,
|
|
at_ms: index === 0 ? 0 : 1000,
|
|
}),
|
|
),
|
|
interaction_events: [
|
|
{
|
|
sequence: 0,
|
|
scheduled_at_ms: 5,
|
|
dispatched_at_ms: 6,
|
|
type: "input" as const,
|
|
data: "hello",
|
|
outcome:
|
|
side === "left" ? ("dispatched" as const) : ("failed" as const),
|
|
},
|
|
],
|
|
process_samples: [
|
|
{
|
|
at_ms: processTime,
|
|
pid: 2,
|
|
parent_pid: 1,
|
|
command: side === "left" ? "worker-a" : "worker-b",
|
|
process_group_id: 1,
|
|
session_id: 1,
|
|
},
|
|
],
|
|
event_journal: journal,
|
|
};
|
|
};
|
|
const comparison = compareFullyObserved(capture("left"), capture("right"));
|
|
expect(comparison).toMatchObject({
|
|
interaction: "changed",
|
|
process: "changed",
|
|
first_divergence: {
|
|
status: "found",
|
|
dimension,
|
|
left_at_ms: at,
|
|
right_at_ms: at,
|
|
},
|
|
});
|
|
},
|
|
);
|