mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2de050dab | ||
|
|
776248b2c4 |
@@ -1,6 +1,4 @@
|
||||
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", () => {
|
||||
@@ -179,10 +177,7 @@ 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", () => {
|
||||
@@ -216,151 +211,3 @@ 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,5 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import {
|
||||
TimelineProjection,
|
||||
selectProjectedTimelinePage,
|
||||
type ProjectedTimelineRow,
|
||||
} from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -39,7 +43,8 @@ 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(rows);
|
||||
const projection = new TimelineProjection();
|
||||
for (const row of rows) projection.append(row);
|
||||
this.states.set(agentId, {
|
||||
epoch: options?.epoch ?? randomUUID(),
|
||||
projection,
|
||||
@@ -92,17 +97,18 @@ export class InMemoryAgentTimelineStore {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const cursor = options?.cursor;
|
||||
const hasRows = state.minSeq !== 0;
|
||||
const rows = state.projection.getRows();
|
||||
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 &&
|
||||
hasRows &&
|
||||
rows.length > 0 &&
|
||||
cursor.seq < state.minSeq - 1;
|
||||
const reset = staleCursor || gap;
|
||||
const page = state.projection.selectPage({
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
bounds: window,
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
@@ -144,11 +150,14 @@ export class InMemoryAgentTimelineStore {
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.projection.getLastItem(state.nextSeq - 1);
|
||||
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
return this.requireState(agentId).projection.getLastAssistantMessage();
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.findLast((candidate) => candidate.item.type === "assistant_message");
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// 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,43 +295,12 @@ 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);
|
||||
@@ -339,8 +308,7 @@ export class TimelineProjection {
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (merged && index !== undefined) {
|
||||
this.rows[index] = { ...merged, seq: merged.seqEnd };
|
||||
this.extendSpan(index);
|
||||
return index;
|
||||
return;
|
||||
}
|
||||
const previous = this.rows.at(-1);
|
||||
const adjacent = previous
|
||||
@@ -349,159 +317,16 @@ export class TimelineProjection {
|
||||
if (previous && adjacent.length === 1) {
|
||||
const mergedText = adjacent[0];
|
||||
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
|
||||
this.extendSpan(this.rows.length - 1);
|
||||
return this.rows.length - 1;
|
||||
return;
|
||||
}
|
||||
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,
|
||||
@@ -619,6 +444,21 @@ 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,
|
||||
@@ -675,6 +515,142 @@ 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
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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,26 +1,10 @@
|
||||
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(
|
||||
@@ -29,48 +13,13 @@ export async function writeFileAtomic(
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tempPath, data, "utf8");
|
||||
await replaceFile(tempPath, filePath, operations);
|
||||
await fs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
@@ -87,53 +87,3 @@ 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,6 +7787,8 @@ 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,
|
||||
@@ -7805,7 +7807,7 @@ export class Session {
|
||||
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction,
|
||||
cursor,
|
||||
limit: msg.limit,
|
||||
limit: pageLimit,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
|
||||
@@ -358,7 +358,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
||||
get: async () => null,
|
||||
update: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
archive: async () => false,
|
||||
remove: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -495,6 +495,10 @@ describe("observed workspace placement", () => {
|
||||
|
||||
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
|
||||
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
|
||||
const archived = await observed.placement("workspace-one");
|
||||
await observed.advanceBy(RESCAN_INTERVAL_MS);
|
||||
expect(await observed.placement("workspace-one")).toEqual(archived);
|
||||
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
|
||||
observed.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ function canonicalLocalProjectKey(rootPath: string): string {
|
||||
}
|
||||
|
||||
function createTestRegistries() {
|
||||
let archiveGate: {
|
||||
started: ReturnType<typeof deferred>;
|
||||
release: ReturnType<typeof deferred>;
|
||||
} | null = null;
|
||||
const holdNextArchive = () => {
|
||||
archiveGate = { started: deferred(), release: deferred() };
|
||||
return archiveGate;
|
||||
};
|
||||
const projects = new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
@@ -55,6 +63,13 @@ function createTestRegistries() {
|
||||
projects.set(record.projectId, record);
|
||||
return record;
|
||||
},
|
||||
update: async (id, updater) => {
|
||||
const current = projects.get(id);
|
||||
if (!current) return null;
|
||||
const updated = updater(current);
|
||||
projects.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
@@ -85,17 +100,23 @@ function createTestRegistries() {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
},
|
||||
archive: async (id: string, archivedAt: string) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (existing) {
|
||||
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
const gate = archiveGate;
|
||||
archiveGate = null;
|
||||
if (gate) {
|
||||
gate.started.resolve();
|
||||
await gate.release.promise;
|
||||
}
|
||||
const existing = workspaces.get(id);
|
||||
if (!existing || existing.archivedAt) return false;
|
||||
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
return true;
|
||||
},
|
||||
remove: async (id: string) => {
|
||||
workspaces.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
return { projects, workspaces, projectRegistry, workspaceRegistry };
|
||||
return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive };
|
||||
}
|
||||
|
||||
function createTestLogger() {
|
||||
@@ -117,6 +138,7 @@ interface CapturedLogRecord {
|
||||
|
||||
function createCapturingLogger() {
|
||||
const infoRecords: CapturedLogRecord[] = [];
|
||||
const warnRecords: CapturedLogRecord[] = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: () => undefined,
|
||||
@@ -124,10 +146,12 @@ function createCapturingLogger() {
|
||||
info: (payload: unknown, message?: string) => {
|
||||
infoRecords.push({ payload, message: message ?? "" });
|
||||
},
|
||||
warn: () => undefined,
|
||||
warn: (payload: unknown, message?: string) => {
|
||||
warnRecords.push({ payload, message: message ?? "" });
|
||||
},
|
||||
error: () => undefined,
|
||||
};
|
||||
return { logger: logger as unknown as pino.Logger, infoRecords };
|
||||
return { logger: logger as unknown as pino.Logger, infoRecords, warnRecords };
|
||||
}
|
||||
|
||||
function createWorkspaceGitServiceStub(
|
||||
@@ -241,6 +265,419 @@ describe("WorkspaceReconciliationService", () => {
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("does not emit a duplicate archive when user archival commits before reconciliation's archive", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-commit-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive } =
|
||||
createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: path.join(rootPath, "missing"),
|
||||
kind: "directory",
|
||||
displayName: "workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const callbacks: string[] = [];
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
onWorkspaceArchived: (id) => {
|
||||
callbacks.push(id);
|
||||
},
|
||||
});
|
||||
const gate = holdNextArchive();
|
||||
try {
|
||||
const scan = service.runOnce();
|
||||
await gate.started.promise;
|
||||
expect(await workspaceRegistry.archive("w1", timestamp)).toBe(true);
|
||||
gate.release.resolve();
|
||||
expect((await scan).changesApplied).toEqual([]);
|
||||
expect(callbacks).toEqual([]);
|
||||
expect(workspaces.get("w1")?.archivedAt).toBe(timestamp);
|
||||
} finally {
|
||||
gate.release.resolve();
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves paths while two inspections saturate capacity, logs once, and recovers after they drain", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-saturated-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
const { logger, warnRecords, infoRecords } = createCapturingLogger();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const ids = ["hung1", "hung2", "healthy"];
|
||||
for (const workspaceId of ids) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: "p1",
|
||||
cwd: path.join(rootPath, workspaceId),
|
||||
kind: "directory",
|
||||
displayName: workspaceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const release = deferred();
|
||||
const calls: string[] = [];
|
||||
let recovered = false;
|
||||
let ticks = 0;
|
||||
const probe = setInterval(() => ticks++, 1);
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
inspectionTimeoutMs: 30,
|
||||
inspectDirectory: async (targetPath) => {
|
||||
calls.push(targetPath);
|
||||
if (!recovered && path.basename(targetPath).startsWith("hung")) await release.promise;
|
||||
return "directory";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.runOnce();
|
||||
expect(ticks).toBeGreaterThan(0);
|
||||
expect(result.durationMs).toBeLessThan(1000);
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(calls).toEqual(ids.slice(0, 2).map((id) => path.join(rootPath, id)));
|
||||
expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([
|
||||
{
|
||||
message: "Workspace directory inspection saturated; preserving uninspected workspaces",
|
||||
payload: { paths: ids.slice(0, 2).map((id) => path.join(rootPath, id)) },
|
||||
},
|
||||
]);
|
||||
recovered = true;
|
||||
release.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(infoRecords).toEqual([
|
||||
{
|
||||
message:
|
||||
"Timed-out workspace directory inspections drained; reconciliation capacity restored",
|
||||
payload: {},
|
||||
},
|
||||
]);
|
||||
calls.length = 0;
|
||||
await service.runOnce();
|
||||
expect(calls).toEqual([...ids.map((id) => path.join(rootPath, id)), rootPath]);
|
||||
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toHaveLength(1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
clearInterval(probe);
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("inspects every healthy path when one hung inspection times out during a longer scan", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-long-hung-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const healthyIds = Array.from({ length: 12 }, (_, i) => `healthy${i}`);
|
||||
for (const workspaceId of ["hung", ...healthyIds]) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: "p1",
|
||||
cwd: path.join(rootPath, workspaceId),
|
||||
kind: "directory",
|
||||
displayName: workspaceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const calls: string[] = [];
|
||||
let outstanding = 0;
|
||||
let maximumOutstanding = 0;
|
||||
const { logger, warnRecords } = createCapturingLogger();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
inspectionTimeoutMs: 100,
|
||||
inspectDirectory: async (targetPath) => {
|
||||
calls.push(targetPath);
|
||||
maximumOutstanding = Math.max(maximumOutstanding, ++outstanding);
|
||||
if (path.basename(targetPath) === "hung") return new Promise(() => {});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
outstanding--;
|
||||
return "directory";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await service.runOnce();
|
||||
expect(result.durationMs).toBeGreaterThan(100);
|
||||
expect(calls).toEqual(
|
||||
["hung", ...healthyIds].map((id) => path.join(rootPath, id)).concat(rootPath),
|
||||
);
|
||||
expect(maximumOutstanding).toBe(2);
|
||||
expect(outstanding).toBe(1);
|
||||
expect(workspaces.get("hung")?.archivedAt).toBeNull();
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("times out a hung inspection without archiving it or blocking healthy paths, and retains its IO slot", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-hung-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
for (const workspaceId of ["hung", "healthy", "healthy2", "healthy3"]) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: "p1",
|
||||
cwd: path.join(rootPath, workspaceId),
|
||||
kind: "directory",
|
||||
displayName: workspaceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const calls: string[] = [];
|
||||
let outstanding = 0;
|
||||
let maximumOutstanding = 0;
|
||||
const healthyInspected = deferred();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
inspectionTimeoutMs: 30,
|
||||
inspectDirectory: async (targetPath) => {
|
||||
calls.push(targetPath);
|
||||
maximumOutstanding = Math.max(maximumOutstanding, ++outstanding);
|
||||
if (path.basename(targetPath) === "hung") return new Promise(() => {});
|
||||
healthyInspected.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
outstanding--;
|
||||
return "directory";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const scan = service.runOnce();
|
||||
await Promise.race([
|
||||
healthyInspected.promise,
|
||||
scan.then(() => {
|
||||
throw new Error("Reconciliation did not use its directory inspection capability");
|
||||
}),
|
||||
]);
|
||||
expect(outstanding).toBeLessThanOrEqual(2);
|
||||
expect(workspaces.get("hung")?.archivedAt).toBeNull();
|
||||
const result = await scan;
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
await service.runOnce();
|
||||
expect(calls.filter((cwd) => path.basename(cwd) === "hung")).toHaveLength(1);
|
||||
expect(calls.filter((cwd) => path.basename(cwd) === "healthy3")).toHaveLength(2);
|
||||
expect(maximumOutstanding).toBe(2);
|
||||
expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["missing", "directory"] as const)(
|
||||
"does not process or archive a workspace archived during %s inspection",
|
||||
async (state) => {
|
||||
const rootPath = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), "reconcile-inspection-archive-")),
|
||||
);
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const cwd = path.join(rootPath, "missing");
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "workspace",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const archivedCallbacks: string[] = [];
|
||||
const checkouts = new TestCheckouts();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: checkouts,
|
||||
onWorkspaceArchived: (id) => {
|
||||
archivedCallbacks.push(id);
|
||||
},
|
||||
inspectDirectory: async (targetPath) => {
|
||||
if (targetPath === cwd) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return state;
|
||||
}
|
||||
return "directory";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const scan = service.runOnce();
|
||||
await Promise.race([
|
||||
started.promise,
|
||||
scan.then(() => {
|
||||
throw new Error("Reconciliation did not use its directory inspection capability");
|
||||
}),
|
||||
]);
|
||||
await workspaceRegistry.archive("w1", "2026-01-01T00:00:00.000Z");
|
||||
const archived = workspaces.get("w1");
|
||||
release.resolve();
|
||||
const result = await scan;
|
||||
expect(workspaces.get("w1")).toEqual(archived);
|
||||
expect(archivedCallbacks).toEqual([]);
|
||||
expect(checkouts.reads).toEqual([rootPath]);
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
} finally {
|
||||
release.resolve();
|
||||
service.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => {
|
||||
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-")));
|
||||
tempDirs.push(rootPath);
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath,
|
||||
projectKey: canonicalLocalProjectKey(rootPath),
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const workspaceId = `w${i}`;
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: "p1",
|
||||
cwd: path.join(rootPath, workspaceId),
|
||||
kind: "directory",
|
||||
displayName: workspaceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
let last = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - last);
|
||||
last = now;
|
||||
}, 1);
|
||||
let result;
|
||||
try {
|
||||
result = await service.runOnce();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
service.dispose();
|
||||
}
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
expect(result.changesApplied).toHaveLength(20000);
|
||||
expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test("preserves workspace archival that lands during boot reconciliation", async () => {
|
||||
const workspaceRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-race-")));
|
||||
tempDirs.push(workspaceRoot);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { statSync, watch as watchPath } from "node:fs";
|
||||
import { watch as watchPath } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { setImmediate as yieldToLoop } from "node:timers/promises";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
@@ -19,6 +21,8 @@ import { deriveProjectKey } from "./project-key.js";
|
||||
|
||||
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 100;
|
||||
const INSPECTION_CONCURRENCY = 2;
|
||||
const DEFAULT_INSPECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
export type ProjectUpdate =
|
||||
| { kind: "upsert"; project: PersistedProjectRecord }
|
||||
@@ -95,6 +99,8 @@ export interface WorkspaceReconciliationServiceOptions {
|
||||
clock?: ReconciliationClock;
|
||||
rescanIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
inspectDirectory?: (targetPath: string) => Promise<DirectoryState>;
|
||||
inspectionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface ProjectReconciliationInput {
|
||||
@@ -110,9 +116,17 @@ interface CachedCheckoutRead {
|
||||
checkout: Promise<ProjectCheckoutLitePayload>;
|
||||
}
|
||||
|
||||
type DirectoryState = "directory" | "missing" | "unreadable";
|
||||
export type DirectoryState = "directory" | "missing" | "unreadable";
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
// Timed-out fs operations retain their slot until the OS returns. Never use
|
||||
// the deadline to admit more work into libuv's shared threadpool.
|
||||
private readonly timedOutPaths = new Set<string>();
|
||||
private inspectionSaturated = false;
|
||||
private readonly inspectionWaiters = new Set<() => void>();
|
||||
private readonly pendingInspections = new Map<string, Promise<DirectoryState>>();
|
||||
private readonly directoryInspection: (targetPath: string) => Promise<DirectoryState>;
|
||||
private readonly inspectionTimeoutMs: number;
|
||||
private readonly serverId: string | undefined;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
@@ -136,6 +150,10 @@ export class WorkspaceReconciliationService {
|
||||
private reconcileQueuedMode: "metadata" | "full" | null = null;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.directoryInspection =
|
||||
options.inspectDirectory ??
|
||||
(async (targetPath) => ((await stat(targetPath)).isDirectory() ? "directory" : "missing"));
|
||||
this.inspectionTimeoutMs = options.inspectionTimeoutMs ?? DEFAULT_INSPECTION_TIMEOUT_MS;
|
||||
this.serverId = options.serverId;
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
@@ -180,6 +198,7 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.wakeInspectionWaiters();
|
||||
this.unsubscribeRegistry?.();
|
||||
this.unsubscribeRegistry = null;
|
||||
if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer);
|
||||
@@ -197,16 +216,23 @@ export class WorkspaceReconciliationService {
|
||||
this.workspaceRegistry.list(),
|
||||
]);
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue;
|
||||
const workspaceStates = await mapBounded(
|
||||
workspaces.filter((w) => !w.archivedAt),
|
||||
async (workspace) => ({ workspace, state: await this.inspectDirectory(workspace.cwd) }),
|
||||
);
|
||||
for (const { workspace, state } of workspaceStates) {
|
||||
if (state !== "directory") continue;
|
||||
if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue;
|
||||
const siblings = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
siblings.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, siblings);
|
||||
}
|
||||
const projectStates = await mapBounded(
|
||||
projects.filter((p) => !p.archivedAt),
|
||||
async (project) => ({ project, state: await this.inspectDirectory(project.rootPath) }),
|
||||
);
|
||||
await this.reconcileGitMetadataForProjects(
|
||||
projects.filter(
|
||||
(project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory",
|
||||
),
|
||||
projectStates.filter(({ state }) => state === "directory").map(({ project }) => project),
|
||||
workspacesByProject,
|
||||
changes,
|
||||
);
|
||||
@@ -222,23 +248,30 @@ export class WorkspaceReconciliationService {
|
||||
const allWorkspaces = await this.workspaceRegistry.list();
|
||||
|
||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||
const activeProjectsById = new Map(
|
||||
activeProjects.map((project) => [project.projectId, project]),
|
||||
);
|
||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
|
||||
const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({
|
||||
const workspaceDirectoryStates = await mapBounded(activeWorkspaces, async (workspace) => ({
|
||||
workspace,
|
||||
state: this.inspectDirectory(workspace.cwd),
|
||||
state: await this.inspectDirectory(workspace.cwd),
|
||||
}));
|
||||
// Read project roots after workspaces: losing a volume can withhold an
|
||||
// archive, never turn a workspace's absence into proof of deletion.
|
||||
const projectStates = await mapBounded(activeProjects, async (project) => ({
|
||||
project,
|
||||
state: await this.inspectDirectory(project.rootPath),
|
||||
}));
|
||||
// Project roots are read after the workspace directories, so a volume that
|
||||
// goes away mid-pass leaves its project unreachable rather than its workspaces
|
||||
// alone. The skew can only withhold an archive, never produce one.
|
||||
const reachableProjectIds = new Set(
|
||||
activeProjects
|
||||
.filter((project) => this.inspectDirectory(project.rootPath) === "directory")
|
||||
.map((project) => project.projectId),
|
||||
projectStates
|
||||
.filter(({ state }) => state === "directory")
|
||||
.map(({ project }) => project.projectId),
|
||||
);
|
||||
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
for (const { workspace, state } of workspaceDirectoryStates) {
|
||||
if (state !== "directory") continue;
|
||||
if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue;
|
||||
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
list.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, list);
|
||||
@@ -255,26 +288,29 @@ export class WorkspaceReconciliationService {
|
||||
state === "missing" && reachableProjectIds.has(workspace.projectId),
|
||||
)
|
||||
.map(({ workspace }) => workspace);
|
||||
await Promise.all(
|
||||
missingWorkspaces.map(async (workspace) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.archive(workspace.workspaceId, timestamp);
|
||||
await this.onWorkspaceArchived?.(workspace.workspaceId);
|
||||
changes.push({
|
||||
kind: "workspace_archived",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
reason: "directory_missing",
|
||||
});
|
||||
|
||||
// Update the in-memory list for the project orphan check below
|
||||
const siblings = workspacesByProject.get(workspace.projectId);
|
||||
if (siblings) {
|
||||
const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId);
|
||||
workspacesByProject.set(workspace.projectId, updated);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await mapBounded(missingWorkspaces, async (workspace) => {
|
||||
const current = await this.workspaceRegistry.get(workspace.workspaceId);
|
||||
const project = await this.projectRegistry.get(workspace.projectId);
|
||||
if (
|
||||
!current ||
|
||||
current.archivedAt ||
|
||||
current.cwd !== workspace.cwd ||
|
||||
current.projectId !== workspace.projectId ||
|
||||
!project ||
|
||||
project.archivedAt ||
|
||||
project.rootPath !== activeProjectsById.get(project.projectId)?.rootPath
|
||||
)
|
||||
return;
|
||||
const timestamp = new Date().toISOString();
|
||||
if (!(await this.workspaceRegistry.archive(workspace.workspaceId, timestamp))) return;
|
||||
await this.onWorkspaceArchived?.(workspace.workspaceId);
|
||||
changes.push({
|
||||
kind: "workspace_archived",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
reason: "directory_missing",
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Reconcile mutable git metadata without changing identity or membership.
|
||||
// Projects persist until explicitly removed, even when they currently have
|
||||
@@ -351,13 +387,17 @@ export class WorkspaceReconciliationService {
|
||||
}
|
||||
|
||||
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
|
||||
const { project, siblings, currentGit, readCheckout, changes } = input;
|
||||
const { siblings, currentGit, readCheckout, changes } = input;
|
||||
const workspaceCheckouts = await Promise.all(
|
||||
siblings.map(async (workspace) => ({
|
||||
workspace,
|
||||
checkout: await readCheckout(workspace.cwd),
|
||||
})),
|
||||
siblings.map(async (workspace) => {
|
||||
const current = await this.workspaceRegistry.get(workspace.workspaceId);
|
||||
if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return null;
|
||||
return { workspace: current, checkout: await readCheckout(current.cwd) };
|
||||
}),
|
||||
);
|
||||
const project = await this.projectRegistry.get(input.project.projectId);
|
||||
if (!project || project.archivedAt || project.rootPath !== input.project.rootPath) return;
|
||||
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">> = {};
|
||||
const mappedKind = deriveProjectKind(currentGit);
|
||||
const projectKey = deriveProjectKey({
|
||||
@@ -377,40 +417,47 @@ export class WorkspaceReconciliationService {
|
||||
|
||||
if (Object.keys(projectUpdates).length > 0) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.upsert({
|
||||
...project,
|
||||
...projectUpdates,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
fields: projectUpdates,
|
||||
let applied = false;
|
||||
await this.projectRegistry.update(project.projectId, (current) => {
|
||||
if (current.archivedAt || current.rootPath !== project.rootPath) return current;
|
||||
applied = true;
|
||||
return { ...current, ...projectUpdates, updatedAt: timestamp };
|
||||
});
|
||||
if (applied)
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: project.projectId,
|
||||
directory: project.rootPath,
|
||||
fields: projectUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => {
|
||||
workspaceCheckouts.map(async (entry) => {
|
||||
if (!entry) return;
|
||||
const { workspace, checkout: wsGit } = entry;
|
||||
const timestamp = new Date().toISOString();
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout: wsGit,
|
||||
updatedAt: timestamp,
|
||||
if (!reconcileWorkspacePlacement({ workspace, checkout: wsGit, updatedAt: timestamp }))
|
||||
return;
|
||||
let fields: Partial<MutableWorkspacePlacement> | null = null;
|
||||
await this.workspaceRegistry.update(workspace.workspaceId, (current) => {
|
||||
if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return current;
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace: current,
|
||||
checkout: wsGit,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
if (!update) return current;
|
||||
fields = update.fields;
|
||||
return { ...current, ...fields, updatedAt: timestamp };
|
||||
});
|
||||
if (!update) return;
|
||||
|
||||
const updated = await this.workspaceRegistry.update(workspace.workspaceId, (current) => ({
|
||||
...current,
|
||||
...update.fields,
|
||||
updatedAt: timestamp,
|
||||
}));
|
||||
if (!updated) return;
|
||||
if (!fields) return;
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.workspaceId,
|
||||
directory: workspace.cwd,
|
||||
fields: update.fields,
|
||||
fields,
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -537,16 +584,74 @@ export class WorkspaceReconciliationService {
|
||||
return this.workspaceGitService.getCheckout(cwd);
|
||||
}
|
||||
|
||||
private inspectDirectory(targetPath: string): DirectoryState {
|
||||
private wakeInspectionWaiters(): void {
|
||||
for (const wake of this.inspectionWaiters) wake();
|
||||
this.inspectionWaiters.clear();
|
||||
}
|
||||
|
||||
private async inspectDirectory(targetPath: string): Promise<DirectoryState> {
|
||||
// A healthy in-flight inspection owns capacity temporarily. Wait for it;
|
||||
// only unresolved operations that have all timed out justify skipping paths.
|
||||
while (
|
||||
!this.disposed &&
|
||||
!this.pendingInspections.has(targetPath) &&
|
||||
this.pendingInspections.size >= INSPECTION_CONCURRENCY
|
||||
) {
|
||||
if (this.timedOutPaths.size === INSPECTION_CONCURRENCY) return "unreadable";
|
||||
await new Promise<void>((resolve) => this.inspectionWaiters.add(resolve));
|
||||
}
|
||||
if (this.disposed) return "unreadable";
|
||||
let pending = this.pendingInspections.get(targetPath);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve()
|
||||
.then(() => this.directoryInspection(targetPath))
|
||||
.catch((error) => {
|
||||
if (isMissingPathError(error)) return "missing" as const;
|
||||
this.logger.warn(
|
||||
{ err: error, targetPath },
|
||||
"Skipped workspace reconciliation after directory inspection failed",
|
||||
);
|
||||
return "unreadable" as const;
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingInspections.delete(targetPath);
|
||||
this.timedOutPaths.delete(targetPath);
|
||||
this.wakeInspectionWaiters();
|
||||
if (this.inspectionSaturated && this.timedOutPaths.size === 0) {
|
||||
this.inspectionSaturated = false;
|
||||
this.logger.info(
|
||||
{},
|
||||
"Timed-out workspace directory inspections drained; reconciliation capacity restored",
|
||||
);
|
||||
}
|
||||
});
|
||||
this.pendingInspections.set(targetPath, pending);
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return statSync(targetPath).isDirectory() ? "directory" : "missing";
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) return "missing";
|
||||
this.logger.warn(
|
||||
{ err: error, targetPath },
|
||||
"Skipped workspace reconciliation after directory inspection failed",
|
||||
);
|
||||
return "unreadable";
|
||||
return await Promise.race([
|
||||
pending,
|
||||
new Promise<DirectoryState>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
this.timedOutPaths.add(targetPath);
|
||||
this.wakeInspectionWaiters();
|
||||
if (!this.inspectionSaturated && this.timedOutPaths.size === INSPECTION_CONCURRENCY) {
|
||||
this.inspectionSaturated = true;
|
||||
this.logger.warn(
|
||||
{ paths: [...this.timedOutPaths] },
|
||||
"Workspace directory inspection saturated; preserving uninspected workspaces",
|
||||
);
|
||||
}
|
||||
this.logger.warn(
|
||||
{ targetPath },
|
||||
"Skipped workspace reconciliation after directory inspection timed out",
|
||||
);
|
||||
resolve("unreadable");
|
||||
}, this.inspectionTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,3 +660,21 @@ function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
// Two workers bound admission; yielding also bounds passes whose adapters settle
|
||||
// immediately (including unavailable paths after both IO slots time out).
|
||||
async function mapBounded<T, R>(items: T[], visit: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
let next = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(INSPECTION_CONCURRENCY, items.length) }, async () => {
|
||||
let count = 0;
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
results[index] = await visit(items[index]!);
|
||||
if (++count % 32 === 0) await yieldToLoop();
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("workspace registries", () => {
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("refreshes workspace archive timestamps when an archive is repeated", async () => {
|
||||
test("preserves the first workspace archive and reports whether a queued archive applied", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
@@ -488,12 +488,24 @@ describe("workspace registries", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z");
|
||||
await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z");
|
||||
|
||||
expect(await workspaceRegistry.get("workspace-one")).toMatchObject({
|
||||
archivedAt: "2026-03-03T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
const events: WorkspaceMutation[] = [];
|
||||
workspaceRegistry.subscribeToMutations((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
expect(
|
||||
await Promise.all([
|
||||
workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"),
|
||||
workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"),
|
||||
]),
|
||||
).toEqual([true, false]);
|
||||
expect(events.map((event) => event.kind)).toEqual(["archive"]);
|
||||
const reloaded = new FileBackedWorkspaceRegistry(
|
||||
path.join(tmpDir, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
expect(await reloaded.get("workspace-one")).toMatchObject({
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export interface WorkspaceRegistry {
|
||||
workspaceId: string,
|
||||
archivedAt: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
): Promise<void>;
|
||||
): Promise<boolean>;
|
||||
remove(workspaceId: string): Promise<void>;
|
||||
/** Central lifecycle seam for daemon-global workspace observers. */
|
||||
subscribeToMutations?(
|
||||
@@ -247,20 +247,6 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
});
|
||||
}
|
||||
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.archiveIfPresent(id, archivedAt);
|
||||
}
|
||||
|
||||
protected async archiveIfPresent(id: string, archivedAt: string): Promise<TRecord | null> {
|
||||
return this.mutateCache((records) => {
|
||||
const existing = records.get(id);
|
||||
if (!existing) return null;
|
||||
const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt });
|
||||
records.set(id, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
protected async archiveIfActive(id: string, archivedAt: string): Promise<TRecord | null> {
|
||||
return this.mutateCache((records) => {
|
||||
const existing = records.get(id);
|
||||
@@ -485,7 +471,7 @@ export class FileBackedProjectRegistry
|
||||
return project;
|
||||
}
|
||||
|
||||
override async archive(projectId: string, archivedAt: string): Promise<void> {
|
||||
async archive(projectId: string, archivedAt: string): Promise<void> {
|
||||
const project = await this.archiveIfActive(projectId, archivedAt);
|
||||
if (!project) return;
|
||||
await this.notifyMutation({ kind: "archive", projectId, project });
|
||||
@@ -565,21 +551,28 @@ export class FileBackedWorkspaceRegistry
|
||||
});
|
||||
}
|
||||
|
||||
override async archive(
|
||||
async archive(
|
||||
workspaceId: string,
|
||||
archivedAt: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
): Promise<void> {
|
||||
const workspace = await super.update(workspaceId, (existing) => ({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
...(context?.autoArchivedChangeRequestUrl
|
||||
? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl }
|
||||
: {}),
|
||||
}));
|
||||
if (!workspace) return;
|
||||
): Promise<boolean> {
|
||||
const [workspace] = await this.mutateMany((records) => {
|
||||
const existing = records.get(workspaceId);
|
||||
if (!existing || existing.archivedAt) return [];
|
||||
return [
|
||||
{
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
...(context?.autoArchivedChangeRequestUrl
|
||||
? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!workspace) return false;
|
||||
await this.notifyMutation({ kind: "archive", workspaceId, workspace });
|
||||
return true;
|
||||
}
|
||||
|
||||
override async remove(workspaceId: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user