diff --git a/src/domain/analysisView/environmentStateWriteExplain.test.ts b/src/domain/analysisView/environmentStateWriteExplain.test.ts index 101a18087db..2cc663d8467 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.test.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.test.ts @@ -645,6 +645,115 @@ it.each([ expect(randomIdentities(members, "id", ["at"])).toBe(random); }); +it.each([ + [undefined, undefined, undefined], + ["2026-03-01T00:00:00Z", undefined, undefined], + [undefined, "2026-03-02T00:00:00Z", "2026-03-03T00:00:00Z"], +])( + "does not treat missing creation clocks as identity inversions: %s", + (...clocks) => { + const members = clocks.map((at, index) => { + const member: JsonObject = { id: `id-${String(index)}` }; + if (at !== undefined) member.at = at; + return member; + }); + expect(randomIdentities(members, "id", ["at"])).toBe(false); + }, +); + +it("still recognizes a witnessed identity inversion when another clock value is missing", () => { + expect( + randomIdentities( + [ + { id: "id-0", at: "2026-03-02T00:00:00Z" }, + { id: "id-1", at: "2026-03-01T00:00:00Z" }, + { id: "id-2" }, + ], + "id", + ["at"], + ), + ).toBe(true); +}); + +it.each([undefined, null, false, {}] as const)( + "recognizes a real inversion across a noncomparable clock: %s", + (gap) => { + const middle: JsonObject = { id: "id-1" }; + if (gap !== undefined) middle.at = gap; + expect( + randomIdentities( + [ + { id: "id-0", at: "2026-03-02T00:00:00Z" }, + middle, + { id: "id-2", at: "2026-03-01T00:00:00Z" }, + ], + "id", + ["at"], + ), + ).toBe(true); + }, +); + +it("keeps a known ordered clock from classifying IDs as random across another clock's gap", () => { + expect( + randomIdentities( + [ + { + id: "id-0", + at: "2026-03-02T00:00:00Z", + other: "2026-03-01T00:00:00Z", + }, + { id: "id-1" }, + { + id: "id-2", + at: "2026-03-01T00:00:00Z", + other: "2026-03-02T00:00:00Z", + }, + ], + "id", + ["at", "other"], + ), + ).toBe(false); +}); + +it.each([ + [1772409602, "not-a-clock", 1772409601], + ["2026-03-02T00:00:00Z", "not-a-clock", "2026-03-01T00:00:00Z"], + [1772409602, "1772409600", 1772409601], +])( + "keeps invalid clock text from hiding a known inversion: %s", + (first, gap, last) => { + expect( + randomIdentities( + [ + { id: "id-0", at: first! }, + { id: "id-1", at: gap! }, + { id: "id-2", at: last! }, + ], + "id", + ["at"], + ), + ).toBe(true); + }, +); + +it.each([ + [1772409602, 1772409601000, true], + [1772409602000, 1772409602, false], + ["2026-03-01T00:00:00.123000002Z", "2026-03-01T00:00:00.123000001Z", true], +] as const)( + "compares creation clocks by their instants across gaps and units: %s", + (first, last, random) => { + expect( + randomIdentities( + [{ id: "id-0", at: first }, { id: "id-1" }, { id: "id-2", at: last }], + "id", + ["at"], + ), + ).toBe(random); + }, +); + it("uses retained submillisecond precision to recognize and place sorted timestamps", () => { const members = [ { at: "2026-03-04T05:00:00.123000002Z" }, diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index bc8aa8ad25a..80f519afa20 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -32,6 +32,7 @@ import { slugSpellings, sortedStrings, type ClockFormat, + type ClockReading, } from "./environmentStateWriteValues.js"; import { composedValue, @@ -664,6 +665,11 @@ export const responseShapeOf = ( return { ordinal, shape: "recorded", pointer: null }; }; +const compareClockReadings = ( + left: ClockReading, + right: ClockReading, +): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond; + /** Order two field values as numbers, instants or text; undefined when they are not comparable. */ export const compareValues = ( left: JsonValue | undefined, @@ -672,8 +678,7 @@ export const compareValues = ( const [a, b] = [integerOf(left), integerOf(right)]; if (a !== undefined && b !== undefined) return a - b; const [c, d] = [readClock(left), readClock(right)]; - if (c !== undefined && d !== undefined) - return c.ms - d.ms || c.subMillisecond - d.subMillisecond; + if (c !== undefined && d !== undefined) return compareClockReadings(c, d); return typeof left === "string" && typeof right === "string" ? compare(left, right) : undefined; @@ -702,7 +707,8 @@ const UUID_V4 = /** * Whether a collection's identities order nothing: version 4 UUIDs, or values every creation clock - * lists in another order. Time-ordered ids (ObjectId, ULID, TSID, version 7 UUIDs) follow a clock. + * lists in another order. Only recognized clock observations establish that order. + * Time-ordered ids (ObjectId, ULID, TSID, version 7 UUIDs) follow a clock. */ export const randomIdentities = ( members: readonly JsonObject[], @@ -718,16 +724,16 @@ export const randomIdentities = ( (left, right) => compareValues(ownMember(left, idField), ownMember(right, idField)) ?? 0, ); - return clocks.every((clock) => - byId.some( - (member, index) => - index > 0 && - (compareValues( - ownMember(byId[index - 1]!, clock), - ownMember(member, clock), - ) ?? 1) > 0, - ), - ); + return clocks.every((clock) => { + const values = byId.flatMap((member) => { + const reading = readClock(ownMember(member, clock)); + return reading === undefined ? [] : [reading]; + }); + return values.some((value, index) => { + if (index === 0) return false; + return compareClockReadings(values[index - 1]!, value) > 0; + }); + }); }; /** diff --git a/tests/acceptance/environmentSparseClockWritesMcp.test.ts b/tests/acceptance/environmentSparseClockWritesMcp.test.ts new file mode 100644 index 00000000000..f24a5e5330c --- /dev/null +++ b/tests/acceptance/environmentSparseClockWritesMcp.test.ts @@ -0,0 +1,144 @@ +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { expect } from "vitest"; +import { z } from "zod"; +import { jsonObjectSchema } from "../../src/domain/jsonValue.js"; +import { + timedCaptureEvidence, + type TimedCaptureRow, +} from "../fixtures/analysisView.js"; +import { + environmentStateServeFixture, + writeEnvironmentServeFiles, +} from "../fixtures/environmentServe.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { startListeningProcess } from "../support/mcp/streamableHttpProcess.js"; +import { processTest } from "../support/process/processFixture.js"; + +const rowsWithClocks = ( + mode: + | "sparse" + | "complete" + | "no-clock" + | "gapped-inversion" + | "invalid-clock-gap" + | "mixed-unit-gap", +): TimedCaptureRow[] => { + const members: { id: string; name: string; created_at?: string | number }[] = + ["24", "25", "26"].map((suffix, index) => ({ + id: `6ad47b491023b500010871${suffix}`, + name: ["Delta", "Alpha", "Charlie"][index]!, + ...(mode === "complete" || (mode === "sparse" && index === 0) + ? { + created_at: `2026-10-01T${String(9 + index).padStart(2, "0")}:00:00Z`, + } + : {}), + })); + if (mode === "gapped-inversion") { + members[0]!.created_at = "2026-10-01T11:00:00Z"; + members[2]!.created_at = "2026-10-01T09:00:00Z"; + } + if (mode === "invalid-clock-gap") { + members[0]!.created_at = 1772409602; + members[1]!.created_at = "not-a-clock"; + members[2]!.created_at = 1772409601; + } + if (mode === "mixed-unit-gap") { + members[0]!.created_at = 1772409602; + members[2]!.created_at = 1772409601000; + } + const created = { + id: "6ad47b491023b50001087130", + name: "Echo", + ...(mode === "no-clock" ? {} : { created_at: "2026-10-17T18:00:04Z" }), + }; + return [ + ["GET", "/api/items", members], + ...members.map((member): TimedCaptureRow => [ + "GET", + `/api/items/${member.id}`, + member, + ]), + ["POST", "/api/items", created, 201, { name: "Echo" }], + ["GET", `/api/items/${created.id}`, created], + ]; +}; + +const checkCreatedItems = async ( + client: Client, + appended: boolean, +): Promise => { + for (const name of ["First new item", "Second new item", "Third new item"]) { + const created = await client.callTool({ + name: "post_items", + arguments: { body: { name } }, + }); + expect(created.isError).toBeFalsy(); + } + const reply = await client.callTool({ name: "get_items", arguments: {} }); + expect(reply.isError).toBeFalsy(); + const items = z + .array(z.object({ id: z.string(), name: z.string() })) + .parse(jsonObjectSchema.parse(reply.structuredContent).body); + expect(items).toHaveLength(6); + if (appended) + expect(items.map(({ name }) => name)).toEqual([ + "Delta", + "Alpha", + "Charlie", + "First new item", + "Second new item", + "Third new item", + ]); + else { + const ids = items.map(({ id }) => id); + expect(ids).toEqual([...ids].sort()); + } +}; + +for (const [mode, appended] of [ + ["sparse", false], + ["complete", false], + ["no-clock", false], + ["gapped-inversion", true], + ["invalid-clock-gap", true], + ["mixed-unit-gap", true], +] as const) + processTest( + `${appended ? "appends writes" : "keeps ID-sorted writes"} with ${mode} creation clocks over HTTP MCP`, + async ({ processes }) => { + const fixture = environmentStateServeFixture( + timedCaptureEvidence(rowsWithClocks(mode)), + ); + expect(fixture.state.normalized_result).toMatchObject({ + writes: expect.arrayContaining([ + expect.objectContaining({ + tool: "post_items", + served: true, + insert: appended + ? { rule: "last" } + : { rule: "sorted", field: "id", direction: "ascending" }, + }), + ]), + }); + const root = await createTestTempDirectory("rea-sparse-clock-writes-"); + const files = await writeEnvironmentServeFiles(root, fixture); + const started = await startListeningProcess(processes, [ + "environment-serve", + files.manifestPath, + "--evidence", + files.capturePath, + "--port", + "0", + ]); + const client = new Client({ name: "sparse-clock-writes", version: "1" }); + try { + await client.connect(new StreamableHTTPClientTransport(started.url)); + await checkCreatedItems(client, appended); + } finally { + await client.close(); + } + }, + );