mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839af91be9 | ||
|
|
129fd8fd36 | ||
|
|
28834a91ae |
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
@@ -177,7 +179,10 @@ describe("projected sequence ownership", () => {
|
||||
store.append("a", { type: "assistant_message", text: "Answer" });
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.initialize("b", { rows: store.getRows("a") });
|
||||
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
|
||||
expect(store.getLastAssistantMessage("b")).toBe("Answer");
|
||||
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
|
||||
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
|
||||
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
|
||||
});
|
||||
it("returns the last projected assistant message without joining different messages", () => {
|
||||
@@ -211,3 +216,151 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Generation and its GC are outside the measured request window.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const started = performance.now();
|
||||
const probe = new Promise<number>((resolve) =>
|
||||
setImmediate(() => resolve(performance.now() - started)),
|
||||
);
|
||||
let page = store.fetch("large", { limit: 200 });
|
||||
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
|
||||
const duration = performance.now() - started;
|
||||
const loopGap = await probe;
|
||||
console.info({ duration, loopGap });
|
||||
expect(page.rows).toHaveLength(200);
|
||||
expect(page.startSeq).toBe(99_801);
|
||||
expect(page.endSeq).toBe(100_000);
|
||||
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
|
||||
// is measured separately. Eight requests amplify the old full-history work.
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(loopGap).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
|
||||
let seed = 0x76aabbcc;
|
||||
const random = (bound: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 0x1_0000_0000) * bound);
|
||||
};
|
||||
for (let scenario = 0; scenario < 100; scenario++) {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
for (let seq = 1; seq <= 100; seq++) {
|
||||
const identity = String(random(8));
|
||||
const text = String(seq);
|
||||
const items: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text },
|
||||
{ type: "assistant_message", text },
|
||||
{ type: "assistant_message", text, messageId: identity },
|
||||
{ type: "reasoning", text },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: identity,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: text },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: identity,
|
||||
pluginId: "test",
|
||||
kind: "state",
|
||||
version: 1,
|
||||
data: { text },
|
||||
},
|
||||
];
|
||||
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
|
||||
}
|
||||
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
|
||||
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
|
||||
for (const agentId of ["a", "b"]) {
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
store.append(agentId, { type: "assistant_message", text: "later" });
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
|
||||
for (const direction of ["tail", "before", "after"] as const) {
|
||||
for (const limit of [0, 1, 7, 200]) {
|
||||
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
|
||||
const actual = store.fetch(agentId, {
|
||||
direction,
|
||||
limit,
|
||||
cursor: { epoch: "e", seq: cursorSeq },
|
||||
});
|
||||
const expected = selectReferenceProjectedTimelinePage({
|
||||
rows: store.getRows(agentId),
|
||||
bounds: actual.window,
|
||||
direction,
|
||||
limit,
|
||||
cursorSeq,
|
||||
});
|
||||
expect({
|
||||
...expected,
|
||||
entries: undefined,
|
||||
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
|
||||
}).toEqual({
|
||||
entries: undefined,
|
||||
rows: actual.rows,
|
||||
startSeq: actual.startSeq,
|
||||
endSeq: actual.endSeq,
|
||||
hasOlder: actual.hasOlder,
|
||||
hasNewer: actual.hasNewer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
const tool: AgentTimelineItem = {
|
||||
type: "tool_call",
|
||||
callId: "t",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "work" },
|
||||
};
|
||||
store.append("a", tool);
|
||||
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
|
||||
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
|
||||
expect(first.endSeq).toBe(2);
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.append("a", { type: "assistant_message", text: "Done" });
|
||||
expect(first.rows[0].item).toMatchObject({ status: "running" });
|
||||
const second = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: first.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
|
||||
expect(second.endSeq).toBe(4);
|
||||
const third = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: second.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
|
||||
expect(third.rows[0].item).toMatchObject({ status: "completed" });
|
||||
expect(third.endSeq).toBe(6);
|
||||
const last = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: third.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
|
||||
expect(last.endSeq).toBe(7);
|
||||
expect(last.hasNewer).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
TimelineProjection,
|
||||
selectProjectedTimelinePage,
|
||||
type ProjectedTimelineRow,
|
||||
} from "./timeline-projection.js";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore {
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
|
||||
const projection = new TimelineProjection();
|
||||
for (const row of rows) projection.append(row);
|
||||
const projection = new TimelineProjection(rows);
|
||||
this.states.set(agentId, {
|
||||
epoch: options?.epoch ?? randomUUID(),
|
||||
projection,
|
||||
@@ -97,18 +92,17 @@ export class InMemoryAgentTimelineStore {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const cursor = options?.cursor;
|
||||
const rows = state.projection.getRows();
|
||||
const hasRows = state.minSeq !== 0;
|
||||
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
|
||||
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
|
||||
const gap =
|
||||
!staleCursor &&
|
||||
direction === "after" &&
|
||||
cursor !== undefined &&
|
||||
rows.length > 0 &&
|
||||
hasRows &&
|
||||
cursor.seq < state.minSeq - 1;
|
||||
const reset = staleCursor || gap;
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
const page = state.projection.selectPage({
|
||||
bounds: window,
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
@@ -150,14 +144,11 @@ export class InMemoryAgentTimelineStore {
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
|
||||
return state.projection.getLastItem(state.nextSeq - 1);
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.findLast((candidate) => candidate.item.type === "assistant_message");
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
return this.requireState(agentId).projection.getLastAssistantMessage();
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Frozen pre-index page selection: differential oracle for retained projection tests.
|
||||
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
|
||||
import {
|
||||
TimelineProjection,
|
||||
projectTimelineRows,
|
||||
type TimelineProjectionEntry,
|
||||
type ProjectedTimelineRow,
|
||||
type TimelineLimitDirection,
|
||||
type ProjectedTimelinePageSelection,
|
||||
} from "../timeline-projection.js";
|
||||
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
|
||||
return rows.map((row) => {
|
||||
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
|
||||
return {
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
endSeq: number,
|
||||
): number | null {
|
||||
for (const range of entry.sourceSeqRanges) {
|
||||
const firstSeq = Math.max(range.startSeq, startSeq);
|
||||
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ProjectedEntryCandidate {
|
||||
entry: TimelineProjectionEntry;
|
||||
index: number;
|
||||
firstSourceSeq: number;
|
||||
}
|
||||
|
||||
function selectProjectedEntriesAfter(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
startSeq: number;
|
||||
maxSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
|
||||
const eligible = input.entries
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
index,
|
||||
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
|
||||
}))
|
||||
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
|
||||
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
|
||||
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
|
||||
const selectedEntries = selected
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate.entry);
|
||||
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
|
||||
|
||||
const selectedRanges = selectedEntries
|
||||
.flatMap((entry) => entry.sourceSeqRanges)
|
||||
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
|
||||
// Wide projected entries can include future, discontiguous source ranges. The
|
||||
// page cursor advances only through source rows covered without a gap.
|
||||
let endSeq = input.startSeq - 1;
|
||||
for (const range of selectedRanges) {
|
||||
if (range.endSeq <= endSeq) continue;
|
||||
if (range.startSeq > endSeq + 1) break;
|
||||
endSeq = Math.min(range.endSeq, input.maxSeq);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: selectedEntries,
|
||||
endSeq: endSeq >= input.startSeq ? endSeq : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectReferenceProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
// Existing selection fixtures exercise the retained owner through its public API.
|
||||
export function selectProjectedTimelinePage(
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
|
||||
) {
|
||||
const projection = new TimelineProjection(input.rows);
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
|
||||
const page = projection.selectPage({ ...input, bounds });
|
||||
return {
|
||||
...page,
|
||||
entries: page.entries.map((row) => {
|
||||
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
import {
|
||||
projectTimelineRows,
|
||||
selectProjectedTimelinePage,
|
||||
selectTimelineWindowByProjectedLimit,
|
||||
} from "./timeline-projection.js";
|
||||
|
||||
|
||||
@@ -295,12 +295,43 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
/** A retained item. `seq` is its latest source event, not its display anchor. */
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
interface ProjectedTimelinePageOptions {
|
||||
bounds: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Owns projected state; source events are consumed, never retained. */
|
||||
export class TimelineProjection {
|
||||
private readonly rows: ProjectedTimelineRow[] = [];
|
||||
private readonly identities = new Map<string, number>();
|
||||
private lastAssistantRowIndex: number | null = null;
|
||||
// Disjoint source-coverage spans in display order. A tail starting inside a
|
||||
// span must include its first anchor to cover interleaved lifecycle updates.
|
||||
private readonly spans: { start: number; endSeq: number }[] = [];
|
||||
|
||||
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
|
||||
|
||||
constructor(rows: readonly AgentTimelineRow[] = []) {
|
||||
for (const row of rows) this.appendProjected(row);
|
||||
for (const [rowIndex, row] of this.rows.entries()) {
|
||||
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
|
||||
}
|
||||
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
|
||||
}
|
||||
|
||||
append(row: AgentTimelineRow): void {
|
||||
const rowIndex = this.appendProjected(row);
|
||||
const last = this.sourceRanges.at(-1);
|
||||
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
|
||||
last.endSeq = row.seq;
|
||||
} else {
|
||||
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
|
||||
}
|
||||
}
|
||||
|
||||
private appendProjected(row: AgentTimelineRow): number {
|
||||
const entry = makeCanonicalEntries([row])[0];
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
const index = identity === null ? undefined : this.identities.get(identity);
|
||||
@@ -308,7 +339,8 @@ export class TimelineProjection {
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (merged && index !== undefined) {
|
||||
this.rows[index] = { ...merged, seq: merged.seqEnd };
|
||||
return;
|
||||
this.extendSpan(index);
|
||||
return index;
|
||||
}
|
||||
const previous = this.rows.at(-1);
|
||||
const adjacent = previous
|
||||
@@ -317,16 +349,159 @@ export class TimelineProjection {
|
||||
if (previous && adjacent.length === 1) {
|
||||
const mergedText = adjacent[0];
|
||||
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
|
||||
return;
|
||||
this.extendSpan(this.rows.length - 1);
|
||||
return this.rows.length - 1;
|
||||
}
|
||||
if (identity !== null) this.identities.set(identity, this.rows.length);
|
||||
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
|
||||
this.rows.push({ ...entry, seq: entry.seqEnd });
|
||||
const last = this.spans.at(-1);
|
||||
if (last && last.endSeq >= entry.seqStart) {
|
||||
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
|
||||
} else {
|
||||
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
|
||||
}
|
||||
return this.rows.length - 1;
|
||||
}
|
||||
|
||||
private spanIndex(rowIndex: number): number {
|
||||
let low = 0;
|
||||
let high = this.spans.length;
|
||||
while (low + 1 < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.spans[middle].start <= rowIndex) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private extendSpan(rowIndex: number): void {
|
||||
const index = this.spanIndex(rowIndex);
|
||||
const span = this.spans[index];
|
||||
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
|
||||
let end = index + 1;
|
||||
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
|
||||
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
|
||||
end++;
|
||||
}
|
||||
this.spans.splice(index + 1, end - index - 1);
|
||||
}
|
||||
|
||||
getRows(): ProjectedTimelineRow[] {
|
||||
return this.rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
if (this.rows.length === 0) {
|
||||
const cursor =
|
||||
input.cursorSeq ??
|
||||
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder:
|
||||
input.direction === "after"
|
||||
? cursor >= input.bounds.minSeq
|
||||
: input.direction === "before" && cursor > input.bounds.minSeq,
|
||||
hasNewer:
|
||||
input.direction === "after"
|
||||
? cursor < input.bounds.maxSeq
|
||||
: input.direction === "before" && cursor <= input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
switch (input.direction) {
|
||||
case "tail":
|
||||
return this.selectTail(input);
|
||||
case "after":
|
||||
return this.selectAfter(input);
|
||||
case "before":
|
||||
return this.selectBefore(input);
|
||||
}
|
||||
}
|
||||
|
||||
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
|
||||
const start = this.spans[this.spanIndex(requestedStart)].start;
|
||||
return {
|
||||
entries: this.rows.slice(start),
|
||||
startSeq: this.rows[start].seqStart,
|
||||
endSeq: input.bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const startSeq = Math.max(
|
||||
input.bounds.minSeq,
|
||||
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
|
||||
);
|
||||
let low = 0;
|
||||
let high = this.sourceRanges.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const indexes = new Set<number>();
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
for (let i = low; i < this.sourceRanges.length; i++) {
|
||||
const range = this.sourceRanges[i];
|
||||
if (range.startSeq > input.bounds.maxSeq) break;
|
||||
indexes.add(range.rowIndex);
|
||||
if (limit > 0 && indexes.size === limit) break;
|
||||
}
|
||||
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries,
|
||||
startSeq,
|
||||
maxSeq: input.bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > input.bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
|
||||
if (endSeq >= input.bounds.minSeq) {
|
||||
let low = 0;
|
||||
let high = this.rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const start = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
endSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: endSeq < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
}
|
||||
|
||||
getLastItem(seq: number): AgentTimelineItem | null {
|
||||
const last = this.sourceRanges.at(-1);
|
||||
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(): string | null {
|
||||
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
enrichSubmittedUserMessage(
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
@@ -444,21 +619,6 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
@@ -515,142 +675,6 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
|
||||
* Used by callers that only have items in hand (e.g. MCP tools reading
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"commits a completed receipt after transient Windows %s replace failures",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
let failures = 3;
|
||||
let elapsed = 0;
|
||||
await writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async (source, destination) => {
|
||||
if (failures-- > 0) {
|
||||
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
|
||||
state: "pending",
|
||||
});
|
||||
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
|
||||
throw Object.assign(new Error("destination held open"), { code });
|
||||
}
|
||||
await fs.rename(source, destination);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"preserves the previous file and removes the temp file when Windows %s persists",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("destination held open"), { code });
|
||||
const waits: number[] = [];
|
||||
let elapsed = 0;
|
||||
const attempts: number[] = [];
|
||||
await expect(
|
||||
writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async () => {
|
||||
attempts.push(elapsed);
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
expect(elapsed).toBe(1000);
|
||||
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
|
||||
expect(Math.max(...waits)).toBe(100);
|
||||
expect(attempts.every((time) => time < 1000)).toBe(true);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["linux", "EPERM"],
|
||||
["darwin", "EACCES"],
|
||||
["linux", "EBUSY"],
|
||||
["win32", "ENOENT"],
|
||||
["win32", "ENOSPC"],
|
||||
["win32", "EXDEV"],
|
||||
] as const)("does not retry %s %s failures", async (platform, code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("rename failed"), { code });
|
||||
let attempts = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform,
|
||||
now: () => 0,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
attempts++;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(attempts).toBe(1);
|
||||
expect(waits).toEqual([]);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
});
|
||||
|
||||
it("does not retry after a rename consumes the Windows retry budget", async () => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
|
||||
let elapsed = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
elapsed += 1000;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(waits).toEqual([]);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
|
||||
const file = path.join(directory, "nested", "file");
|
||||
const bytes = Buffer.from([0, 255, 128, 1]);
|
||||
await writeFileAtomic(file, bytes);
|
||||
expect(await fs.readFile(file)).toEqual(bytes);
|
||||
await writeJsonFileAtomic(file, { state: "completed" });
|
||||
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
|
||||
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
|
||||
});
|
||||
@@ -1,10 +1,26 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
interface AtomicFileRuntime {
|
||||
platform: NodeJS.Platform;
|
||||
rename: (source: string, destination: string) => Promise<void>;
|
||||
now: () => number;
|
||||
sleep: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const runtime: AtomicFileRuntime = {
|
||||
platform: process.platform,
|
||||
rename: fs.rename,
|
||||
now: () => performance.now(),
|
||||
sleep,
|
||||
};
|
||||
|
||||
export async function writeFileAtomic(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = path.join(
|
||||
@@ -13,13 +29,48 @@ export async function writeFileAtomic(
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tempPath, data, "utf8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
await replaceFile(tempPath, filePath, operations);
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
||||
async function replaceFile(
|
||||
source: string,
|
||||
destination: string,
|
||||
operations: AtomicFileRuntime,
|
||||
): Promise<void> {
|
||||
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
|
||||
// but keep interactive persistence failures bounded to a one-second retry budget.
|
||||
const deadline = operations.now() + 1000;
|
||||
let delay = 10;
|
||||
for (;;) {
|
||||
try {
|
||||
await operations.rename(source, destination);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
operations.platform !== "win32" ||
|
||||
!(error instanceof Error) ||
|
||||
!("code" in error) ||
|
||||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const remaining = deadline - operations.now();
|
||||
if (remaining <= 0) throw error;
|
||||
await operations.sleep(Math.min(delay, remaining));
|
||||
if (operations.now() >= deadline) throw error;
|
||||
delay = Math.min(delay * 2, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomic(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
|
||||
}
|
||||
|
||||
@@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
|
||||
for (let index = 0; index < 451; index++) {
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: String(index),
|
||||
});
|
||||
}
|
||||
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
|
||||
expect(first.entries).toHaveLength(200);
|
||||
expect(first.hasNewer).toBe(true);
|
||||
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
|
||||
const second = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: first.endCursor!,
|
||||
});
|
||||
const third = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: second.endCursor!,
|
||||
});
|
||||
expect(second.entries).toHaveLength(200);
|
||||
expect(third.entries).toHaveLength(51);
|
||||
expect(third.hasNewer).toBe(false);
|
||||
expect(
|
||||
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
|
||||
).toEqual(
|
||||
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
|
||||
);
|
||||
const allAfter = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor,
|
||||
limit: 0,
|
||||
});
|
||||
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(allAfter.entries).toHaveLength(451);
|
||||
expect(allAfter.hasNewer).toBe(false);
|
||||
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -7787,8 +7787,6 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
||||
const projection = "projected" as const;
|
||||
const requestedLimit = msg.limit;
|
||||
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
|
||||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||||
? {
|
||||
epoch: msg.cursor.epoch,
|
||||
@@ -7807,7 +7805,7 @@ export class Session {
|
||||
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction,
|
||||
cursor,
|
||||
limit: pageLimit,
|
||||
limit: msg.limit,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
|
||||
Reference in New Issue
Block a user