Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e2de050dab fix(server): wait for healthy reconciliation inspection capacity 2026-10-26 15:29:32 +01:00
Mohamed Boudra 776248b2c4 fix(server): keep workspace reconciliation responsive on slow paths
Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan.
2026-10-26 15:23:07 +01:00
15 changed files with 859 additions and 953 deletions
@@ -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"]);
});
+3 -54
View 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);
+3 -1
View File
@@ -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> {