mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
Keep workspace updates independent of archived history (#8735)
* perf(server): scope workspace updates to indexed records * fix(server): preserve parent context in scoped workspace queries * test(server): update remaining scoped store adapters * fix(server): preserve archived exact paths in archive requests * fix(server): prefer active exact archive targets
This commit is contained in:
@@ -4092,7 +4092,7 @@ test("createAgent rejects a disabled provider without creating a session", async
|
||||
),
|
||||
).rejects.toThrow("Provider 'codex' is disabled");
|
||||
expect(disabledClient.createSessionCalls).toBe(0);
|
||||
expect(await storage.list()).toHaveLength(0);
|
||||
expect(await storage.listAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("updateProviderRegistry re-enables a previously disabled provider", async () => {
|
||||
@@ -8273,7 +8273,7 @@ test("archiveAgent closes an internal agent without writing a record", async ()
|
||||
expect(Number.isNaN(Date.parse(archivedAt))).toBe(false);
|
||||
expect(manager.getAgent(internalAgentId)).toBeNull();
|
||||
expect(await storage.get(internalAgentId)).toBeNull();
|
||||
expect(await storage.list()).toEqual([]);
|
||||
expect(await storage.listAll()).toEqual([]);
|
||||
expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({
|
||||
record: expect.objectContaining({
|
||||
id: internalAgentId,
|
||||
@@ -9379,8 +9379,8 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async (
|
||||
class ChildOpensAfterCascadeListStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async list(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.list();
|
||||
override async listActive(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.listAll();
|
||||
const parentIsArchived = records.some(
|
||||
(record) => record.id !== this.childId && Boolean(record.archivedAt),
|
||||
);
|
||||
|
||||
@@ -2065,7 +2065,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
const parentAgentId = parent.id;
|
||||
const records = await registry.list();
|
||||
const records = await registry.listActive();
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
@@ -2531,7 +2531,8 @@ export class AgentManager {
|
||||
|
||||
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
||||
const registry = this.requireRegistry();
|
||||
const records = await registry.list();
|
||||
// Restoring an archived provider session is an explicit recovery operation.
|
||||
const records = await registry.listAll();
|
||||
const matched = records.find(
|
||||
(record) =>
|
||||
record.persistence?.provider === handle.provider &&
|
||||
|
||||
@@ -3,6 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { Session as InspectorSession } from "node:inspector/promises";
|
||||
import type { HeapProfiler } from "node:inspector";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
@@ -148,6 +150,101 @@ describe("AgentStorage", () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("workspace and active queries follow updates, removal, and reload", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: tmpDir,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
labels: {},
|
||||
};
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "a",
|
||||
workspaceId: "one",
|
||||
lastUserMessageAt: base.createdAt,
|
||||
});
|
||||
await storage.upsert({ ...base, id: "b", workspaceId: "two", archivedAt: base.createdAt });
|
||||
expect((await storage.listActive()).map((r) => r.id)).toEqual(["a"]);
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "a",
|
||||
workspaceId: "two",
|
||||
lastUserMessageAt: base.updatedAt,
|
||||
});
|
||||
expect(await storage.listByWorkspaces(["one"])).toEqual([]);
|
||||
expect((await storage.listByWorkspaces(["two"])).map((r) => r.id)).toEqual(["a"]);
|
||||
expect(
|
||||
(await storage.listByWorkspaces(["two"], { includeArchived: true })).map((r) => r.id).sort(),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(await storage.lastUserMessageAtByWorkspace(["one", "two"])).toEqual(
|
||||
new Map([["two", base.createdAt]]),
|
||||
);
|
||||
await storage.flush();
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
expect((await reloaded.listActive()).map((r) => r.id)).toEqual(["a"]);
|
||||
expect(
|
||||
await reloaded.lastUserMessageAtByWorkspace(["two"], { excludeAgentIds: ["a"] }),
|
||||
).toEqual(new Map());
|
||||
await reloaded.remove("a");
|
||||
expect(await reloaded.listActive()).toEqual([]);
|
||||
expect(await reloaded.lastUserMessageAtByWorkspace(["two"])).toEqual(new Map());
|
||||
});
|
||||
|
||||
test("single-agent lookups keep allocations bounded with a large stored history", async () => {
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const record = {
|
||||
id: "history-0",
|
||||
provider: "codex",
|
||||
cwd: tmpDir,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
};
|
||||
for (let offset = 0; offset < 2048; offset += 64) {
|
||||
await Promise.all(
|
||||
Array.from({ length: 64 }, (_, index) => {
|
||||
const id = `history-${offset + index}`;
|
||||
return fs.writeFile(
|
||||
path.join(storagePath, `${id}.json`),
|
||||
JSON.stringify({ ...record, id }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await storage.initialize();
|
||||
expect(await storage.listAll()).toHaveLength(2048);
|
||||
const expected = await storage.get(record.id);
|
||||
expect(expected).toMatchObject(record);
|
||||
|
||||
const inspector = new InspectorSession();
|
||||
inspector.connect();
|
||||
let allocatedBytes = 0;
|
||||
let matches = 0;
|
||||
try {
|
||||
await inspector.post("HeapProfiler.startSampling", {
|
||||
samplingInterval: 16384,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
if ((await storage.get(record.id)) === expected) matches++;
|
||||
}
|
||||
const { profile } = await inspector.post("HeapProfiler.stopSampling");
|
||||
const sum = (node: HeapProfiler.SamplingHeapProfileNode): number =>
|
||||
node.selfSize + node.children.reduce((bytes, child) => bytes + sum(child), 0);
|
||||
allocatedBytes = sum(profile.head);
|
||||
} finally {
|
||||
inspector.disconnect();
|
||||
}
|
||||
|
||||
expect(matches).toBe(1000);
|
||||
// Copying this history on every lookup allocates >16 MiB. Allow generous
|
||||
// sampling/runtime overhead without relying on the host's execution speed.
|
||||
expect(allocatedBytes).toBeLessThan(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("applySnapshot persists configs and snapshot metadata", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
@@ -172,7 +269,7 @@ describe("AgentStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await storage.list();
|
||||
const records = await storage.listAll();
|
||||
expect(records).toHaveLength(1);
|
||||
const [record] = records;
|
||||
expect(record.provider).toBe("claude");
|
||||
@@ -189,8 +286,8 @@ describe("AgentStorage", () => {
|
||||
expect(record.lastModeId).toBe("coding");
|
||||
expect(record.lastStatus).toBe("idle");
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const [persisted] = await reloaded.list();
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const [persisted] = await reloaded.listAll();
|
||||
expect(persisted.cwd).toBe("/tmp/project");
|
||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
||||
});
|
||||
@@ -210,7 +307,7 @@ describe("AgentStorage", () => {
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const persisted = await reloaded.get("agent-feature-values");
|
||||
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||
@@ -223,7 +320,7 @@ describe("AgentStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const persisted = await reloaded.get("agent-no-feature-values");
|
||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||
@@ -312,7 +409,7 @@ describe("AgentStorage", () => {
|
||||
const current = await storage.get("agent-2");
|
||||
expect(current?.title).toBe("Fix Login Bug");
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const persisted = await reloaded.get("agent-2");
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
});
|
||||
@@ -415,7 +512,7 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Registry should return all agents - filtering is done at the manager level
|
||||
const records = await storage.list();
|
||||
const records = await storage.listAll();
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -486,12 +583,12 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Reload the registry from disk
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const record = await reloaded.get("internal-agent");
|
||||
expect(record?.internal).toBe(true);
|
||||
|
||||
// Registry returns all agents - filtering happens at manager level
|
||||
const records = await reloaded.list();
|
||||
const records = await reloaded.listAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.internal).toBe(true);
|
||||
});
|
||||
@@ -543,8 +640,8 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Force a reload so the registry has to discover from disk (and may choose either copy).
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const before = await reloaded.list();
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const before = await reloaded.listAll();
|
||||
expect(before.map((r) => r.id)).toContain(agentId);
|
||||
|
||||
await reloaded.remove(agentId);
|
||||
@@ -571,8 +668,8 @@ describe("AgentStorage", () => {
|
||||
|
||||
expect(await hasAnyRecordFile()).toBe(false);
|
||||
|
||||
const afterReload = new AgentStorage(storagePath, logger);
|
||||
const after = await afterReload.list();
|
||||
const afterReload = new AgentStorage(storagePath, createTestLogger());
|
||||
const after = await afterReload.listAll();
|
||||
expect(after.some((r) => r.id === agentId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,9 +105,14 @@ export class AgentStorage {
|
||||
private deleting: Set<string> = new Set();
|
||||
private daemonAgentIdsByExecution: Map<string, string> = new Map();
|
||||
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
|
||||
private readonly idsByWorkspace = new Map<string, Set<string>>();
|
||||
private readonly workspaceById = new Map<string, string>();
|
||||
private readonly activeIds = new Set<string>();
|
||||
private readonly promptTimeByAgent = new Map<string, string>();
|
||||
private readonly promptTimes = new Map<string, string>();
|
||||
private loaded = false;
|
||||
private baseDir: string;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
private loadPromise: Promise<void> | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(baseDir: string, logger: Logger) {
|
||||
@@ -119,11 +124,119 @@ export class AgentStorage {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async list(): Promise<StoredAgentRecord[]> {
|
||||
async listAll(): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(
|
||||
options?.includeArchived ? this.cache.keys() : this.activeIds,
|
||||
(id) => this.cache.get(id)!,
|
||||
);
|
||||
}
|
||||
|
||||
async listByWorkspaces(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { includeArchived?: boolean },
|
||||
): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
const records: StoredAgentRecord[] = [];
|
||||
for (const workspaceId of new Set(workspaceIds)) {
|
||||
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
||||
const record = this.cache.get(id)!;
|
||||
if (options?.includeArchived || !record.archivedAt) records.push(record);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async lastUserMessageAtByWorkspace(
|
||||
workspaceIds?: Iterable<string>,
|
||||
options?: { excludeAgentIds?: Iterable<string> },
|
||||
): Promise<ReadonlyMap<string, string>> {
|
||||
await this.load();
|
||||
const times = new Map<string, string>();
|
||||
const excluded = new Set(options?.excludeAgentIds);
|
||||
const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id)));
|
||||
for (const id of workspaceIds ?? this.promptTimes.keys()) {
|
||||
let at = this.promptTimes.get(id);
|
||||
if (affected.has(id)) {
|
||||
at = undefined;
|
||||
for (const agentId of this.idsByWorkspace.get(id) ?? []) {
|
||||
const record = this.cache.get(agentId)!;
|
||||
if (
|
||||
!excluded.has(agentId) &&
|
||||
!record.internal &&
|
||||
!record.background &&
|
||||
record.lastUserMessageAt &&
|
||||
record.lastUserMessageAt > (at ?? "")
|
||||
) {
|
||||
at = record.lastUserMessageAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (at) times.set(id, at);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
private indexWorkspace(record: StoredAgentRecord): void {
|
||||
const previous = this.workspaceById.get(record.id);
|
||||
if (previous) this.idsByWorkspace.get(previous)?.delete(record.id);
|
||||
this.workspaceById.delete(record.id);
|
||||
this.activeIds.delete(record.id);
|
||||
if (!record.archivedAt) this.activeIds.add(record.id);
|
||||
if (record.workspaceId) {
|
||||
const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set<string>();
|
||||
ids.add(record.id);
|
||||
this.idsByWorkspace.set(record.workspaceId, ids);
|
||||
this.workspaceById.set(record.id, record.workspaceId);
|
||||
}
|
||||
this.indexPromptTime(record, previous);
|
||||
}
|
||||
|
||||
private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void {
|
||||
const previousTime = this.promptTimeByAgent.get(record.id);
|
||||
const nextTime =
|
||||
!record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined;
|
||||
if (nextTime) this.promptTimeByAgent.set(record.id, nextTime);
|
||||
else this.promptTimeByAgent.delete(record.id);
|
||||
if (previous === record.workspaceId && previousTime === nextTime) return;
|
||||
if (
|
||||
previous &&
|
||||
previousTime &&
|
||||
this.promptTimes.get(previous) === previousTime &&
|
||||
(previous !== record.workspaceId || !nextTime || nextTime < previousTime)
|
||||
) {
|
||||
this.refreshPromptTime(previous);
|
||||
}
|
||||
if (
|
||||
record.workspaceId &&
|
||||
nextTime &&
|
||||
nextTime > (this.promptTimes.get(record.workspaceId) ?? "")
|
||||
) {
|
||||
this.promptTimes.set(record.workspaceId, nextTime);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshPromptTime(workspaceId: string): void {
|
||||
this.promptTimes.delete(workspaceId);
|
||||
let latest = "";
|
||||
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
||||
const record = this.cache.get(id)!;
|
||||
if (
|
||||
!record.internal &&
|
||||
!record.background &&
|
||||
record.lastUserMessageAt &&
|
||||
record.lastUserMessageAt > latest
|
||||
)
|
||||
latest = record.lastUserMessageAt;
|
||||
}
|
||||
if (latest) this.promptTimes.set(workspaceId, latest);
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(agentId) ?? null;
|
||||
@@ -143,8 +256,7 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
|
||||
return this.listByWorkspaces([workspaceId], { includeArchived: true });
|
||||
}
|
||||
|
||||
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
|
||||
@@ -234,6 +346,14 @@ export class AgentStorage {
|
||||
}),
|
||||
);
|
||||
|
||||
const workspaceId = this.workspaceById.get(agentId);
|
||||
this.workspaceById.delete(agentId);
|
||||
this.promptTimeByAgent.delete(agentId);
|
||||
this.activeIds.delete(agentId);
|
||||
if (workspaceId) {
|
||||
this.idsByWorkspace.get(workspaceId)?.delete(agentId);
|
||||
this.refreshPromptTime(workspaceId);
|
||||
}
|
||||
this.cache.delete(agentId);
|
||||
this.removeOwnerIndex(agentId);
|
||||
this.pathById.delete(agentId);
|
||||
@@ -286,9 +406,9 @@ export class AgentStorage {
|
||||
await Promise.allSettled(writes);
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredAgentRecord[]> {
|
||||
private async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return Array.from(this.cache.values());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.loadPromise) {
|
||||
@@ -298,7 +418,7 @@ export class AgentStorage {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||
private async doLoad(): Promise<void> {
|
||||
this.cache.clear();
|
||||
this.pathById.clear();
|
||||
this.pathsById.clear();
|
||||
@@ -306,28 +426,25 @@ export class AgentStorage {
|
||||
this.daemonExecutionKeysByAgentId.clear();
|
||||
|
||||
try {
|
||||
const records = await this.scanDisk();
|
||||
await this.scanDisk();
|
||||
this.loaded = true;
|
||||
return records;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.loaded = true;
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
this.logger.error({ err: error }, "Failed to load agents");
|
||||
this.loaded = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async scanDisk(): Promise<StoredAgentRecord[]> {
|
||||
const records: StoredAgentRecord[] = [];
|
||||
private async scanDisk(): Promise<void> {
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -364,14 +481,11 @@ export class AgentStorage {
|
||||
for (const item of loaded) {
|
||||
if (!item) continue;
|
||||
const { record, filePath } = item;
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
this.indexOwner(record);
|
||||
this.pathById.set(record.id, filePath);
|
||||
this.addIndexedPath(record.id, filePath);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
|
||||
@@ -408,6 +522,7 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
private indexOwner(record: StoredAgentRecord): void {
|
||||
this.indexWorkspace(record);
|
||||
this.removeOwnerIndex(record.id);
|
||||
if (record.owner?.kind === "daemon") {
|
||||
const key = daemonExecutionKey(record.owner);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveWorkspaceRootAgent } from "../workspace-directory.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -50,7 +51,6 @@ beforeEach(async () => {
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: {
|
||||
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
|
||||
list: async () => workspaces,
|
||||
},
|
||||
projectLive: () => {
|
||||
throw new Error("No live agents in this suite");
|
||||
@@ -112,3 +112,28 @@ test("prompt recency ignores background agents", async () => {
|
||||
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
|
||||
);
|
||||
});
|
||||
|
||||
test("scoped status queries retain cross-workspace parent context", async () => {
|
||||
const base = { provider: "codex", cwd: home, createdAt: NOW, updatedAt: NOW };
|
||||
await storage.upsert({ ...base, id: "parent", workspaceId: "open" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "child",
|
||||
workspaceId: "hidden",
|
||||
labels: { "paseo.parent-agent-id": "parent" },
|
||||
lastStatus: "running",
|
||||
});
|
||||
const agents = await directory.list({
|
||||
workspaceIds: ["hidden"],
|
||||
includeBackground: true,
|
||||
includeAncestors: true,
|
||||
});
|
||||
const child = agents.find((agent) => agent.id === "child")!;
|
||||
expect(
|
||||
resolveWorkspaceRootAgent(child, new Map(agents.map((agent) => [agent.id, agent])))?.id,
|
||||
).toBe("child");
|
||||
expect(child.status).toBe("running");
|
||||
expect(
|
||||
(await directory.list({ workspaceIds: ["hidden"], includeBackground: true })).map((a) => a.id),
|
||||
).not.toContain("parent");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type { WorkspaceRegistry } from "../workspace-registry.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
@@ -26,8 +27,11 @@ export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
manager: Pick<AgentManager, "listAgents">;
|
||||
storage: Pick<AgentStorage, "list">;
|
||||
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
|
||||
storage: Pick<
|
||||
AgentStorage,
|
||||
"get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace"
|
||||
>;
|
||||
workspaces: Pick<WorkspaceRegistry, "get">;
|
||||
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
|
||||
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
isProviderVisible(provider: string): boolean;
|
||||
@@ -36,22 +40,22 @@ export class AgentDirectory {
|
||||
) {}
|
||||
|
||||
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
|
||||
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
|
||||
async workspaceLastUserMessageTimes(
|
||||
workspaceIds?: Iterable<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const requested = workspaceIds ? new Set(workspaceIds) : undefined;
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const times = new Map<string, string>();
|
||||
const record = (workspaceId: string | undefined, at: string | null | undefined) => {
|
||||
if (!workspaceId || !at) return;
|
||||
const previous = times.get(workspaceId);
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !agent.background && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
const times = new Map(
|
||||
await this.deps.storage.lastUserMessageAtByWorkspace(requested, {
|
||||
excludeAgentIds: live.map((agent) => agent.id),
|
||||
}),
|
||||
);
|
||||
for (const agent of live) {
|
||||
if (!agent.internal && !agent.background)
|
||||
record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
if (agent.internal || agent.background || !agent.workspaceId || !agent.lastUserMessageAt)
|
||||
continue;
|
||||
if (requested && !requested.has(agent.workspaceId)) continue;
|
||||
const at = agent.lastUserMessageAt.toISOString();
|
||||
if (at > (times.get(agent.workspaceId) ?? "")) times.set(agent.workspaceId, at);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
@@ -64,23 +68,67 @@ export class AgentDirectory {
|
||||
return admits(agent, workspace?.background === true, visibility);
|
||||
}
|
||||
|
||||
private async appendAncestors(
|
||||
live: ManagedAgent[],
|
||||
selectedLive: ManagedAgent[],
|
||||
records: StoredAgentRecord[],
|
||||
): Promise<void> {
|
||||
const selectedIds = new Set([...selectedLive, ...records].map((agent) => agent.id));
|
||||
const liveById = new Map(live.map((agent) => [agent.id, agent]));
|
||||
const pending = [...selectedLive, ...records];
|
||||
for (const agent of pending) {
|
||||
const parentId = getParentAgentIdFromLabels(agent.labels);
|
||||
if (!parentId || selectedIds.has(parentId)) continue;
|
||||
selectedIds.add(parentId);
|
||||
const liveParent = liveById.get(parentId);
|
||||
if (liveParent) {
|
||||
pending.push(liveParent);
|
||||
selectedLive.push(liveParent);
|
||||
} else {
|
||||
const storedParent = await this.deps.storage.get(parentId);
|
||||
if (storedParent) {
|
||||
pending.push(storedParent);
|
||||
records.push(storedParent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async list(filter?: {
|
||||
workspaceIds?: Iterable<string>;
|
||||
/** Include parent chains by ID for workspace root classification. */
|
||||
includeAncestors?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
includeArchived?: boolean;
|
||||
includeBackground?: BackgroundVisibility;
|
||||
includeUnavailablePersisted?: boolean;
|
||||
}): Promise<AgentSnapshotPayload[]> {
|
||||
const requested = filter?.workspaceIds ? new Set(filter.workspaceIds) : null;
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const labels = Object.entries(filter?.labels ?? {});
|
||||
const visibility = filter?.includeBackground ?? false;
|
||||
const backgroundWorkspaceIds = new Set(
|
||||
visibility === true
|
||||
? []
|
||||
: (await this.deps.workspaces.list())
|
||||
.filter((workspace) => workspace.background)
|
||||
.map((workspace) => workspace.workspaceId),
|
||||
const records = requested
|
||||
? await this.deps.storage.listByWorkspaces(requested, filter)
|
||||
: await this.deps.storage.listActive(filter);
|
||||
const selectedLive = requested
|
||||
? live.filter((agent) => requested.has(agent.workspaceId ?? ""))
|
||||
: live;
|
||||
if (filter?.includeAncestors && requested) {
|
||||
await this.appendAncestors(live, selectedLive, records);
|
||||
}
|
||||
|
||||
const workspaceIds = new Set(
|
||||
[...selectedLive, ...records].flatMap((agent) =>
|
||||
agent.workspaceId ? [agent.workspaceId] : [],
|
||||
),
|
||||
);
|
||||
const backgroundWorkspaceIds = new Set<string>();
|
||||
if (visibility !== true) {
|
||||
for (const id of workspaceIds) {
|
||||
if ((await this.deps.workspaces.get(id))?.background) backgroundWorkspaceIds.add(id);
|
||||
}
|
||||
}
|
||||
const matches = (
|
||||
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
|
||||
) =>
|
||||
@@ -91,7 +139,7 @@ export class AgentDirectory {
|
||||
visibility,
|
||||
) &&
|
||||
labels.every(([key, value]) => agent.labels?.[key] === value);
|
||||
const stored = (await this.deps.storage.list())
|
||||
const stored = records
|
||||
.filter((record) => !liveIds.has(record.id) && matches(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
@@ -99,7 +147,7 @@ export class AgentDirectory {
|
||||
)
|
||||
.map((record) => this.deps.projectStored(record));
|
||||
const active = await Promise.all(
|
||||
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
selectedLive.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
);
|
||||
return [...active, ...stored]
|
||||
.filter(matches)
|
||||
|
||||
@@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
listImportableSessions,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
const agentStorage = {
|
||||
list: async () => [
|
||||
listAll: async () => [
|
||||
{
|
||||
provider: "codex",
|
||||
persistence: {
|
||||
@@ -231,7 +231,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
},
|
||||
} as StoredAgentRecord,
|
||||
],
|
||||
} satisfies Pick<AgentStorage, "list">;
|
||||
} satisfies Pick<AgentStorage, "listAll">;
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({
|
||||
@@ -303,7 +303,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th
|
||||
listImportableSessions,
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
listAll: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
persistence: { provider: "claude", sessionId: "already-imported" },
|
||||
@@ -360,7 +360,7 @@ test("listImportableProviderSessions requests a bounded deep scan for search res
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ query: "INVOICE", limit: 10 }),
|
||||
agentManager: { listAgents: () => [], listImportableSessions },
|
||||
agentStorage: { list: async () => [] },
|
||||
agentStorage: { listAll: async () => [] },
|
||||
providerSnapshotManager: { getProviderLabel: (provider) => provider },
|
||||
});
|
||||
|
||||
@@ -397,7 +397,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo
|
||||
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
listAll: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
archivedAt: "2026-04-30T12:01:00.000Z",
|
||||
@@ -441,7 +441,7 @@ test("listImportableProviderSessions includes an archived provider session still
|
||||
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
listAll: async () => [
|
||||
{
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
@@ -489,8 +489,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
||||
listImportableSessions: async () => makeImportableSessionsResult(sessions),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "Codex" },
|
||||
});
|
||||
|
||||
@@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
|
||||
]),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "Pi" },
|
||||
});
|
||||
|
||||
@@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
|
||||
listImportableSessions: async () => makeImportableSessionsResult([]),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "" },
|
||||
}),
|
||||
).rejects.toMatchObject(
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error {
|
||||
export interface ListImportableProviderSessionsInput {
|
||||
request: FetchRecentProviderSessionsRequestMessage;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
agentStorage: Pick<AgentStorage, "listAll">;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
|
||||
}
|
||||
|
||||
@@ -342,12 +342,13 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
|
||||
async function collectImportedProviderSessions(
|
||||
agentManager: Pick<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
agentStorage: Pick<AgentStorage, "listAll">,
|
||||
providerFilter: Set<string> | undefined,
|
||||
): Promise<{ handles: Set<string>; count: number }> {
|
||||
const handles = new Set<string>();
|
||||
const sessions = new Set<string>();
|
||||
const records = await agentStorage.list();
|
||||
// Explicit provider-history import deduplicates against archived as well as active sessions.
|
||||
const records = await agentStorage.listAll();
|
||||
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
|
||||
|
||||
const collect = (
|
||||
|
||||
@@ -292,7 +292,9 @@ function buildAgentStorageSpies() {
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
applySnapshot: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
listActive: vi.fn().mockResolvedValue([]),
|
||||
listByWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -639,7 +641,16 @@ class BoundaryAgentManagerFake {
|
||||
}
|
||||
|
||||
class BoundaryAgentStorageFake {
|
||||
public async list(): Promise<StoredAgentRecord[]> {
|
||||
public async listActive(): Promise<StoredAgentRecord[]> {
|
||||
return [];
|
||||
}
|
||||
async listByWorkspaces() {
|
||||
return [];
|
||||
}
|
||||
async lastUserMessageAtByWorkspace() {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
async listAll() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -772,7 +783,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
@@ -894,7 +906,11 @@ async function createAgentMcpServer(
|
||||
const server = await createAgentMcpServerWithDependencies({
|
||||
agentMessages: () => messages,
|
||||
finishSubscriptions: () => subscriptions,
|
||||
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
|
||||
workspaceRegistry: {
|
||||
get: async () => null,
|
||||
listActive: async () => [],
|
||||
upsert: async () => {},
|
||||
},
|
||||
...options,
|
||||
});
|
||||
serverSubscriptionRecords.set(server, records);
|
||||
@@ -2697,7 +2713,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => workspace),
|
||||
list: vi.fn(async () => [workspace]),
|
||||
listActive: vi.fn(async () => [workspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
logger,
|
||||
@@ -2730,7 +2746,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => backgroundWorkspace),
|
||||
list: vi.fn(async () => [backgroundWorkspace]),
|
||||
listActive: vi.fn(async () => [backgroundWorkspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
|
||||
@@ -6326,7 +6342,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }),
|
||||
createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }),
|
||||
]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-in-cwd",
|
||||
cwd: "/tmp/workspace",
|
||||
@@ -6385,7 +6401,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
updatedAt: new Date(old),
|
||||
}),
|
||||
]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||
createStoredRecord({
|
||||
@@ -6426,7 +6442,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
archivedAt: new Date(now - index * 60 * 1000).toISOString(),
|
||||
}),
|
||||
);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
...recentArchivedRecords,
|
||||
createStoredRecord({
|
||||
id: "old-archived",
|
||||
@@ -6457,7 +6473,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
it("returns compact list items for stored archived agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const now = new Date().toISOString();
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-archived-compact",
|
||||
cwd: REPO_CWD,
|
||||
@@ -6576,7 +6592,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const now = new Date().toISOString();
|
||||
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-non-archived",
|
||||
updatedAt: now,
|
||||
|
||||
@@ -98,7 +98,7 @@ test("opt-out consumes pending recovery and excludes archived, private, owned an
|
||||
enabled = true;
|
||||
recovery.resumePending();
|
||||
expect(dispatched).toEqual([]);
|
||||
expect((await storage.list()).every((record) => !record.resumeOnStartup)).toBe(true);
|
||||
expect((await storage.listAll()).every((record) => !record.resumeOnStartup)).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export function createRestartRecovery(deps: Dependencies) {
|
||||
return {
|
||||
/** Choose candidates before anything else reads or repairs persisted running status. */
|
||||
async prepareStartup() {
|
||||
for (const record of await deps.storage.list()) {
|
||||
// Startup recovery repairs persisted running status once before agents are restored.
|
||||
for (const record of await deps.storage.listAll()) {
|
||||
const pending =
|
||||
deps.settings().enabled &&
|
||||
(record.resumeOnStartup === true || record.lastStatus === "running") &&
|
||||
@@ -45,7 +46,8 @@ export function createRestartRecovery(deps: Dependencies) {
|
||||
},
|
||||
async captureRunningAgents() {
|
||||
await deps.storage.flush();
|
||||
for (const record of await deps.storage.list()) {
|
||||
// Shutdown recovery captures running state once before the daemon exits.
|
||||
for (const record of await deps.storage.listAll()) {
|
||||
if (record.lastStatus === "running" && (await eligible(record)))
|
||||
await deps.storage.upsert({ ...record, resumeOnStartup: deps.settings().enabled });
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface PaseoToolHostDependencies {
|
||||
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
|
||||
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "listActive" | "upsert">;
|
||||
projectRegistry?: Pick<ProjectRegistry, "get" | "list">;
|
||||
createDirectoryWorkspace?: (
|
||||
cwd: string,
|
||||
@@ -1547,7 +1547,7 @@ function createToolCatalog(
|
||||
if (!options.workspaceRegistry) {
|
||||
throw new Error("Workspace registry is not configured");
|
||||
}
|
||||
const workspaces = (await options.workspaceRegistry.list())
|
||||
const workspaces = (await options.workspaceRegistry.listActive())
|
||||
.filter(
|
||||
(workspace) => !workspace.archivedAt && (includeBackground || !workspace.background),
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("bootstrap provider availability", () => {
|
||||
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
try {
|
||||
await expect(daemon.agentStorage.list()).resolves.toHaveLength(1);
|
||||
await expect(daemon.agentStorage.listAll()).resolves.toHaveLength(1);
|
||||
await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({
|
||||
provider: "codex",
|
||||
available: false,
|
||||
|
||||
@@ -1139,11 +1139,13 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
: record.lastStatus,
|
||||
}));
|
||||
expect(
|
||||
(await handle.daemon.agentStorage.list()).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
(await handle.daemon.agentStorage.listAll()).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
).toEqual(expected.sort((a, b) => a.id.localeCompare(b.id)));
|
||||
// A fresh reader proves the repaired states were written, rather than projected only in memory.
|
||||
expect(
|
||||
(await new AgentStorage(storagePath, logger).list()).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
(await new AgentStorage(storagePath, logger).listAll()).sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
).toEqual(expected);
|
||||
expect(handle.daemon.agentManager.listAgents()).toEqual([]);
|
||||
expect(sessionsCreated).toBe(0);
|
||||
|
||||
@@ -964,7 +964,7 @@ export async function createPaseoDaemon(
|
||||
workspaceRegistry.subscribeToMutations(async (mutation) => {
|
||||
if (mutation.kind === "remove" && mutation.workspace) {
|
||||
const cwd = path.resolve(mutation.workspace.cwd);
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listActive();
|
||||
const hasActiveWorkspace = workspaces.some(
|
||||
(workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd,
|
||||
);
|
||||
@@ -1114,7 +1114,8 @@ export async function createPaseoDaemon(
|
||||
// external path→workspace adapter, not ownership: archive-by-path requests that
|
||||
// arrive with a worktree path and no workspaceId (old clients / CLI).
|
||||
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
|
||||
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list());
|
||||
// Explicit archive administration must prefer an archived exact match to an active ancestor.
|
||||
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.listAll());
|
||||
};
|
||||
const ensureWorkspaceForCreateExternal = async (
|
||||
cwd: string,
|
||||
@@ -1137,7 +1138,7 @@ export async function createPaseoDaemon(
|
||||
return workspace.workspaceId;
|
||||
};
|
||||
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listActive();
|
||||
return workspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => ({
|
||||
@@ -1179,7 +1180,7 @@ export async function createPaseoDaemon(
|
||||
return workspaceId;
|
||||
};
|
||||
const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => {
|
||||
const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd);
|
||||
const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.listActive(), cwd);
|
||||
await emitWorkspaceUpdatesExternal(workspaceIds);
|
||||
};
|
||||
const emitExternalSessionMessage = (message: SessionOutboundMessage) => {
|
||||
@@ -1465,7 +1466,8 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
|
||||
logger.info({ elapsed: elapsed() }, "Loading persisted agent registry");
|
||||
const persistedRecords = await agentStorage.list();
|
||||
// Startup loads persisted agent history once before restoring the registry.
|
||||
const persistedRecords = await agentStorage.listAll();
|
||||
logger.info(
|
||||
{ elapsed: elapsed() },
|
||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`,
|
||||
|
||||
@@ -840,7 +840,7 @@ export class HubRelationshipHarness {
|
||||
}
|
||||
|
||||
async durableOwnedAgentIds(): Promise<string[]> {
|
||||
return (await this.daemon!.agentStorage.list())
|
||||
return (await this.daemon!.agentStorage.listAll())
|
||||
.filter((record) => record.owner?.kind === "daemon")
|
||||
.map((record) => record.id);
|
||||
}
|
||||
@@ -850,7 +850,7 @@ export class HubRelationshipHarness {
|
||||
path.join(this.paseoHome, "agents"),
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
return (await storage.list())
|
||||
return (await storage.listAll())
|
||||
.filter((record) => record.owner?.kind === "daemon")
|
||||
.map((record) => record.id);
|
||||
}
|
||||
@@ -1272,7 +1272,7 @@ export class HubRelationshipHarness {
|
||||
});
|
||||
const executions = this.executionsForReconstruction(manager, storage);
|
||||
const replay = await executions.create(this.ownedCreateInput(executionId));
|
||||
const durableAgentCount = (await storage.list()).filter(
|
||||
const durableAgentCount = (await storage.listAll()).filter(
|
||||
(record) => record.owner?.kind === "daemon",
|
||||
).length;
|
||||
return { replay, durableAgentCount };
|
||||
@@ -1285,8 +1285,9 @@ export class HubRelationshipHarness {
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
return {
|
||||
durableAgentCount: (await storage.list()).filter((record) => record.owner?.kind === "daemon")
|
||||
.length,
|
||||
durableAgentCount: (await storage.listAll()).filter(
|
||||
(record) => record.owner?.kind === "daemon",
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,8 +68,10 @@ export async function backfillWorkspaceIdForLegacyAgents(options: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: Logger;
|
||||
}): Promise<number> {
|
||||
const workspaceRecords = await options.workspaceRegistry.list();
|
||||
const records = await options.agentStorage.list();
|
||||
// One-time migration resolves ownership against archived workspace history.
|
||||
const workspaceRecords = await options.workspaceRegistry.listAll();
|
||||
// One-time migration updates archived agents as well as active agents.
|
||||
const records = await options.agentStorage.listAll();
|
||||
let migrated = 0;
|
||||
|
||||
for (const record of records) {
|
||||
|
||||
@@ -1092,7 +1092,8 @@ function createDeps(options?: {
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () => Array.from(workspaces.values()).filter((record) => !record.archivedAt),
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
|
||||
@@ -18,7 +18,7 @@ function getLogger(logger: LoggerLike): LoggerLike {
|
||||
return logger.child({ module: "persistence" });
|
||||
}
|
||||
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
interface BuildSessionConfigOptions {
|
||||
|
||||
@@ -46,6 +46,30 @@ describe("resolveWorkspaceIdForPath", () => {
|
||||
).toEqual("ws-archived");
|
||||
});
|
||||
|
||||
test("prefers the reopened active workspace to archived history at the same path", () => {
|
||||
const archived = createWorkspaceRecord("/workspace/project", "old", {
|
||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||
});
|
||||
const active = createWorkspaceRecord("/workspace/project", "current");
|
||||
for (const records of [
|
||||
[archived, active],
|
||||
[active, archived],
|
||||
]) {
|
||||
expect(resolveWorkspaceIdForPath("/workspace/project", records)).toBe("current");
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers an archived exact match to an active enclosing directory", () => {
|
||||
expect(
|
||||
resolveWorkspaceIdForPath("/workspace/project/sub", [
|
||||
createWorkspaceRecord("/workspace/project", "parent"),
|
||||
createWorkspaceRecord("/workspace/project/sub", "archived-child", {
|
||||
archivedAt: "2026-03-05T00:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
).toBe("archived-child");
|
||||
});
|
||||
|
||||
test("resolves the deepest enclosing workspace for a subdirectory path", () => {
|
||||
expect(
|
||||
resolveWorkspaceIdForPath("/workspace/project/packages/app", [
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
// agents under a workspace — those are keyed by `workspaceId`, and git facts
|
||||
// derive from a workspace's OWN cwd (id → cwd).
|
||||
//
|
||||
// Resolution: an exact directory match wins; otherwise the deepest enclosing
|
||||
// Resolution: active exact match, archived exact match, then the deepest enclosing
|
||||
// workspace directory, never the home directory; null when nothing encloses it.
|
||||
export function resolveWorkspaceIdForPath(
|
||||
cwd: string,
|
||||
@@ -20,7 +20,10 @@ export function resolveWorkspaceIdForPath(
|
||||
): string | null {
|
||||
const workspaceRecords = Array.from(workspaces);
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd);
|
||||
const exactMatches = workspaceRecords.filter(
|
||||
(workspace) => resolve(workspace.cwd) === resolvedCwd,
|
||||
);
|
||||
const exactMatch = exactMatches.find((workspace) => !workspace.archivedAt) ?? exactMatches[0];
|
||||
if (exactMatch) {
|
||||
return exactMatch.workspaceId;
|
||||
}
|
||||
|
||||
@@ -257,9 +257,9 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
agentManager,
|
||||
agentStorage,
|
||||
findWorkspaceIdForCwd: async (cwd) =>
|
||||
resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list()),
|
||||
resolveWorkspaceIdForPath(cwd, await workspaceRegistry.listAll()),
|
||||
listActiveWorkspaces: async () =>
|
||||
(await workspaceRegistry.list())
|
||||
(await workspaceRegistry.listAll())
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -828,7 +828,7 @@ describe("ScheduleService", () => {
|
||||
model: "test-model",
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([]);
|
||||
});
|
||||
|
||||
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
|
||||
@@ -885,7 +885,7 @@ describe("ScheduleService", () => {
|
||||
expect(firstAgent?.workspaceId).not.toBe(secondAgent?.workspaceId);
|
||||
expect(firstAgent?.archivedAt ?? null).toBeNull();
|
||||
expect(secondAgent?.archivedAt ?? null).toBeNull();
|
||||
expect(await workspaceRegistry.list()).toEqual([
|
||||
expect(await workspaceRegistry.listAll()).toEqual([
|
||||
expect.objectContaining({
|
||||
workspaceId: firstAgent?.workspaceId,
|
||||
cwd: tempDir,
|
||||
@@ -1012,7 +1012,7 @@ describe("ScheduleService", () => {
|
||||
(service as unknown as ScheduleServiceInternals).executeSchedule(created, "run-create-fails"),
|
||||
).rejects.toThrow("provider misconfigured");
|
||||
|
||||
expect(await workspaceRegistry.list()).toEqual([
|
||||
expect(await workspaceRegistry.listAll()).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: tempDir,
|
||||
archivedAt: expect.any(String),
|
||||
@@ -1883,7 +1883,7 @@ describe("ScheduleService", () => {
|
||||
agentId: expect.any(String),
|
||||
error: expect.stringContaining("start turn exploded"),
|
||||
});
|
||||
const storedAgents = await agentStorage.list();
|
||||
const storedAgents = await agentStorage.listAll();
|
||||
expect(storedAgents).toHaveLength(1);
|
||||
expect(inspected.runs[0]?.agentId).toBe(storedAgents[0]?.id);
|
||||
expect(storedAgents[0]).toMatchObject({
|
||||
|
||||
@@ -4239,7 +4239,7 @@ describe("session workspace descriptors", () => {
|
||||
),
|
||||
},
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
workspaceRegistry: { get: vi.fn(), listActive: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([project]),
|
||||
get: vi.fn().mockResolvedValue(project),
|
||||
@@ -4319,7 +4319,7 @@ describe("session workspace descriptors", () => {
|
||||
),
|
||||
},
|
||||
messages,
|
||||
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
||||
workspaceRegistry: { get: vi.fn(), listActive: vi.fn().mockResolvedValue([workspace]) },
|
||||
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(),
|
||||
|
||||
@@ -1129,7 +1129,8 @@ export class Session {
|
||||
getTerminalsUnavailable: () => terminalManager?.getUnavailableError?.()?.message ?? null,
|
||||
listAgents: () => this.agentManager.listAgents(),
|
||||
listProjects: () => this.projectRegistry.list(),
|
||||
listWorkspaces: () => this.workspaceRegistry.list(),
|
||||
// Diagnostics report both the active count and total archived history.
|
||||
listWorkspaces: () => this.workspaceRegistry.listAll(),
|
||||
logger: this.sessionLogger,
|
||||
hubRelationships: options.hubRelationships,
|
||||
reloadConfig: () => daemonConfigStore.reload(),
|
||||
@@ -1260,8 +1261,10 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }),
|
||||
workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(),
|
||||
listAgentPayloads: (workspaceIds) =>
|
||||
this.listAgentPayloads({ workspaceIds, includeBackground: true, includeAncestors: true }),
|
||||
workspaceLastUserMessageTimes: (workspaceIds) =>
|
||||
this.agentDirectory.workspaceLastUserMessageTimes(workspaceIds),
|
||||
listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(),
|
||||
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
|
||||
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
||||
@@ -1875,11 +1878,19 @@ export class Session {
|
||||
if (this.isCleanedUp) {
|
||||
return;
|
||||
}
|
||||
const projectWorkspaceIds = (await this.workspaceRegistry.list())
|
||||
const projectWorkspaceIds = (await this.workspaceRegistry.listActive())
|
||||
.filter((workspace) => workspace.projectId === mutation.projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
|
||||
if (mutation.kind === "remove") {
|
||||
// Archived workspaces can still represent an empty project in this subscription.
|
||||
for (const [workspaceId, update] of subscription.lastEmittedByWorkspaceId) {
|
||||
const projectId =
|
||||
update.kind === "upsert" ? update.workspace.projectId : update.emptyProject?.projectId;
|
||||
if (projectId === mutation.projectId && !projectWorkspaceIds.includes(workspaceId)) {
|
||||
projectWorkspaceIds.push(workspaceId);
|
||||
}
|
||||
}
|
||||
const visibleWorkspaceIds = projectWorkspaceIds.filter((workspaceId) => {
|
||||
const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId);
|
||||
return (
|
||||
@@ -3805,7 +3816,7 @@ export class Session {
|
||||
|
||||
// Re-emit descriptors for every workspace under this project so the new
|
||||
// resolved name lands in the UI immediately.
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaces = await this.workspaceRegistry.listActive();
|
||||
const affectedWorkspaceIds = workspaces
|
||||
.filter((workspace) => workspace.projectId === existing.projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
@@ -3857,7 +3868,7 @@ export class Session {
|
||||
});
|
||||
await this.emitProjectUpdate({ kind: "upsert", project: updated });
|
||||
|
||||
const affectedWorkspaceIds = (await this.workspaceRegistry.list())
|
||||
const affectedWorkspaceIds = (await this.workspaceRegistry.listActive())
|
||||
.filter((workspace) => workspace.projectId === projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
if (affectedWorkspaceIds.length > 0) {
|
||||
@@ -3903,7 +3914,7 @@ export class Session {
|
||||
try {
|
||||
const project = await this.projectRegistry.get(projectId);
|
||||
const resolvedProjectId = project?.projectId ?? projectId;
|
||||
const projectWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
const projectWorkspaces = (await this.workspaceRegistry.listActive()).filter(
|
||||
(workspace) => workspace.projectId === resolvedProjectId,
|
||||
);
|
||||
const activeWorkspaceIds = projectWorkspaces
|
||||
@@ -5471,7 +5482,8 @@ export class Session {
|
||||
return { ok: true, agentId: trimmed };
|
||||
}
|
||||
|
||||
const stored = await this.agentStorage.list();
|
||||
// Explicit identifier resolution admits archived agents by ID prefix.
|
||||
const stored = await this.agentStorage.listAll();
|
||||
const storedRecords = stored.filter((record) => !record.internal);
|
||||
const knownIds = new Set<string>();
|
||||
for (const record of storedRecords) {
|
||||
@@ -5583,7 +5595,7 @@ export class Session {
|
||||
Map<string, ProjectPlacementPayload>
|
||||
> {
|
||||
const [persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.workspaceRegistry.list(),
|
||||
this.workspaceRegistry.listActive(),
|
||||
this.projectRegistry.list(),
|
||||
]);
|
||||
const activeProjects = new Map(
|
||||
@@ -5982,7 +5994,8 @@ export class Session {
|
||||
// than a workspaceId. This is a directory lookup for an archive target, not a
|
||||
// status/ownership attribution.
|
||||
private async findWorkspaceIdForCwd(cwd: string): Promise<string | null> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
// Explicit archive administration must prefer an archived exact match to an active ancestor.
|
||||
const workspaces = await this.workspaceRegistry.listAll();
|
||||
return resolveWorkspaceIdForPath(cwd, workspaces);
|
||||
}
|
||||
|
||||
@@ -6173,7 +6186,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async listActiveWorkspaceRefs(): Promise<ActiveWorkspaceRef[]> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaces = await this.workspaceRegistry.listActive();
|
||||
return workspaces
|
||||
.filter((workspace) => !workspace.archivedAt)
|
||||
.map((workspace) => ({
|
||||
@@ -6199,7 +6212,7 @@ export class Session {
|
||||
}
|
||||
|
||||
if (!existingWorkspace.archivedAt) {
|
||||
const activeSiblings = (await this.workspaceRegistry.list()).filter(
|
||||
const activeSiblings = (await this.workspaceRegistry.listActive()).filter(
|
||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||
);
|
||||
this.sessionLogger.info(
|
||||
@@ -6404,7 +6417,7 @@ export class Session {
|
||||
// path. See `workspaceIdsOnCheckout`.
|
||||
private async emitWorkspaceUpdateForCwd(cwd: string, options?: {}): Promise<void> {
|
||||
if (this.workspaceUpdatesSubscriptions.size === 0) return;
|
||||
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd);
|
||||
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.listActive(), cwd);
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -7158,7 +7171,7 @@ export class Session {
|
||||
projectsBefore.set(project.projectId, project);
|
||||
}
|
||||
const workspacesBefore = new Map<string, PersistedWorkspaceRecord>();
|
||||
for (const workspaceRecord of await this.workspaceRegistry.list()) {
|
||||
for (const workspaceRecord of await this.workspaceRegistry.listActive()) {
|
||||
workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord);
|
||||
}
|
||||
const workspace = await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
@@ -7787,7 +7800,10 @@ export class Session {
|
||||
const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId];
|
||||
let agents: AgentSnapshotPayload[];
|
||||
try {
|
||||
agents = await this.listAgentPayloads({ includeBackground: true });
|
||||
agents = await this.listAgentPayloads({
|
||||
workspaceIds: requestedWorkspaceIds,
|
||||
includeBackground: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({
|
||||
@@ -7927,9 +7943,13 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
const agents = (await this.listAgentPayloads({ includeBackground: true })).filter((agent) =>
|
||||
this.isProviderVisibleToClient(agent.provider),
|
||||
);
|
||||
const agents = (
|
||||
await this.listAgentPayloads({
|
||||
workspaceIds: [workspaceId],
|
||||
includeBackground: true,
|
||||
includeAncestors: true,
|
||||
})
|
||||
).filter((agent) => this.isProviderVisibleToClient(agent.provider));
|
||||
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
|
||||
const candidates = agents
|
||||
.filter((agent) => !agent.archivedAt && !agent.background)
|
||||
|
||||
@@ -208,7 +208,10 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
getAgent: () => null,
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
listAll: async () => [],
|
||||
listActive: async () => [],
|
||||
listByWorkspaces: async () => [],
|
||||
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
|
||||
get: async () => null,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
@@ -233,7 +236,9 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
subscribeToMutations: () => () => {},
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () =>
|
||||
Array.from(workspaces.values()).filter((record) => !record.archivedAt),
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
upsert: async (record: ReturnType<typeof createPersistedWorkspaceRecord>) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
|
||||
@@ -115,7 +115,10 @@ function createHarness(input: {
|
||||
notifyAgentState: () => {},
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
listAll: async () => [],
|
||||
listActive: async () => [],
|
||||
listByWorkspaces: async () => [],
|
||||
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
|
||||
get: async () => null,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
@@ -155,7 +158,9 @@ function createHarness(input: {
|
||||
subscribeToMutations: () => () => {},
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () =>
|
||||
Array.from(workspaces.values()).filter((record) => !record.archivedAt),
|
||||
get: async (id: string) => workspaces.get(id) ?? null,
|
||||
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+17
-16
@@ -132,7 +132,7 @@ test("fresh git repo creates a workspace at the canonical worktree root", async
|
||||
const workspace = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(workspace.cwd).toBe(repo);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -192,7 +192,7 @@ test("re-opening an active workspace by exact path returns the same record witho
|
||||
const second = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => {
|
||||
@@ -202,12 +202,12 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
|
||||
|
||||
const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(active.workspaceId).toBe(created.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
|
||||
@@ -361,14 +361,15 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
|
||||
const archived = (await workspaceRegistry.list()).filter(
|
||||
const archived = (await workspaceRegistry.listAll()).filter(
|
||||
(workspace) => workspace.workspaceId === created.workspaceId,
|
||||
);
|
||||
let reads = 0;
|
||||
const snapshotRegistry: WorkspaceRegistry = {
|
||||
initialize: () => workspaceRegistry.initialize(),
|
||||
existsOnDisk: () => workspaceRegistry.existsOnDisk(),
|
||||
list: async () => (reads++ === 0 ? archived : []),
|
||||
listAll: async () => (reads++ === 0 ? archived : []),
|
||||
listActive: () => workspaceRegistry.listActive(),
|
||||
get: (workspaceId) => workspaceRegistry.get(workspaceId),
|
||||
upsert: (workspace) => workspaceRegistry.upsert(workspace),
|
||||
archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt),
|
||||
@@ -384,7 +385,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reopening an archived workspace refreshes placement without renaming it", async () => {
|
||||
@@ -540,7 +541,7 @@ test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id w
|
||||
});
|
||||
|
||||
expect(id).toBe("ws-from-worktree");
|
||||
expect(await workspaceRegistry.list()).toHaveLength(0);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("resolveOrCreateWorkspaceIdForCreateAgent honors an explicitly requested workspace id", async () => {
|
||||
@@ -552,7 +553,7 @@ test("resolveOrCreateWorkspaceIdForCreateAgent honors an explicitly requested wo
|
||||
});
|
||||
|
||||
expect(id).toBe("ws-requested");
|
||||
expect(await workspaceRegistry.list()).toHaveLength(0);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("resolveOrCreateWorkspaceIdForCreateAgent creates a titled workspace when nothing is provided", async () => {
|
||||
@@ -577,7 +578,7 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
|
||||
const second = await provisioning.createWorkspaceForDirectory(repo);
|
||||
|
||||
expect(second.workspaceId).not.toBe(first.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("directory creation persists the live branch and a trimmed title", async () => {
|
||||
@@ -677,7 +678,7 @@ test("runInImportWorkspace uses an active requested workspace without creating a
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null });
|
||||
expect(await workspaceRegistry.list()).toEqual([workspace]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([workspace]);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace reuses an active workspace for an untargeted import", async () => {
|
||||
@@ -691,7 +692,7 @@ test("runInImportWorkspace reuses an active workspace for an untargeted import",
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null });
|
||||
expect(await workspaceRegistry.list()).toEqual([workspace]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([workspace]);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace unarchives a workspace for an untargeted import", async () => {
|
||||
@@ -710,7 +711,7 @@ test("runInImportWorkspace unarchives a workspace for an untargeted import", asy
|
||||
workspaceId: workspace.workspaceId,
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("runInImportWorkspace leaves a reused workspace intact when import fails", async () => {
|
||||
@@ -725,7 +726,7 @@ test("runInImportWorkspace leaves a reused workspace intact when import fails",
|
||||
}),
|
||||
).rejects.toThrow("provider session is unavailable");
|
||||
|
||||
expect(await workspaceRegistry.list()).toEqual([workspace]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([workspace]);
|
||||
expect(await projectRegistry.get(workspace.projectId)).toEqual(project);
|
||||
});
|
||||
|
||||
@@ -823,7 +824,7 @@ test("runInImportWorkspace creates one fresh workspace for an untargeted import"
|
||||
);
|
||||
|
||||
expect(result.value).toBe(result.createdWorkspace?.workspaceId);
|
||||
expect(await workspaceRegistry.list()).toEqual([result.createdWorkspace]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([result.createdWorkspace]);
|
||||
});
|
||||
|
||||
test.each(["missing", "archived"] as const)(
|
||||
@@ -844,7 +845,7 @@ test.each(["missing", "archived"] as const)(
|
||||
}),
|
||||
).rejects.toThrow("provider session is unavailable");
|
||||
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([]);
|
||||
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
|
||||
},
|
||||
);
|
||||
|
||||
+6
-4
@@ -149,7 +149,8 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
|
||||
const [projectsBeforeImport, workspacesBeforeImport] = await Promise.all([
|
||||
projectRegistry.list(),
|
||||
workspaceRegistry.list(),
|
||||
// Import rollback must distinguish restored records from newly allocated workspaces.
|
||||
workspaceRegistry.listAll(),
|
||||
]);
|
||||
const workspace = await findOrCreateWorkspaceForDirectory(input.cwd);
|
||||
const createdWorkspace = workspacesBeforeImport.some(
|
||||
@@ -179,7 +180,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
): Promise<void> {
|
||||
try {
|
||||
await workspaceRegistry.remove(workspace.workspaceId);
|
||||
const projectHasActiveWorkspace = (await workspaceRegistry.list()).some(
|
||||
const projectHasActiveWorkspace = (await workspaceRegistry.listActive()).some(
|
||||
(candidate) => candidate.projectId === workspace.projectId && !candidate.archivedAt,
|
||||
);
|
||||
if (projectHasActiveWorkspace) {
|
||||
@@ -321,7 +322,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
return refreshProjectKind(await requireActiveProject(input.projectId));
|
||||
}
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listActive();
|
||||
const sourceWorkspace =
|
||||
workspaces.find(
|
||||
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd),
|
||||
@@ -356,7 +357,8 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
// Path-based discovery selects public work; explicit-ID recovery bypasses this selector.
|
||||
const workspaces = (await workspaceRegistry.list())
|
||||
// Opening a directory explicitly recovers an archived workspace at that path.
|
||||
const workspaces = (await workspaceRegistry.listAll())
|
||||
.filter(
|
||||
(workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd),
|
||||
)
|
||||
|
||||
@@ -64,7 +64,8 @@ export function createTestRegistries() {
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
|
||||
get: async (id: string) => workspaces.get(id) ?? null,
|
||||
update: async (id, updater) => {
|
||||
const existing = workspaces.get(id);
|
||||
|
||||
@@ -45,6 +45,19 @@ export function asAgentStorage(stub: {
|
||||
[K in keyof SessionOptions["agentStorage"]]?: unknown;
|
||||
}): SessionOptions["agentStorage"] {
|
||||
return createStub<SessionOptions["agentStorage"]>({
|
||||
listAll: async () => [],
|
||||
listActive: async function (options) {
|
||||
return (await this.listAll()).filter(
|
||||
(record) => options?.includeArchived || !record.archivedAt,
|
||||
);
|
||||
},
|
||||
listByWorkspaces: async function (ids, options) {
|
||||
const selected = new Set(ids);
|
||||
return (await this.listActive(options)).filter((record) =>
|
||||
selected.has(record.workspaceId ?? ""),
|
||||
);
|
||||
},
|
||||
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
|
||||
listByProviderSession: async () => [],
|
||||
listByWorkspace: async () => [],
|
||||
...stub,
|
||||
|
||||
@@ -358,7 +358,8 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
||||
return {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
listAll: async () => [],
|
||||
listActive: async () => [],
|
||||
get: async () => null,
|
||||
update: async () => null,
|
||||
upsert: async () => {},
|
||||
|
||||
@@ -128,13 +128,22 @@ class InMemoryAgentManager {
|
||||
}
|
||||
|
||||
class EmptyAgentStorage {
|
||||
async list() {
|
||||
async listActive() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async get() {
|
||||
return null;
|
||||
}
|
||||
async listByWorkspaces() {
|
||||
return [];
|
||||
}
|
||||
async lastUserMessageAtByWorkspace() {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
async listAll() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyProjectRegistry {
|
||||
@@ -156,6 +165,12 @@ class EmptyProjectRegistry {
|
||||
}
|
||||
|
||||
class EmptyWorkspaceRegistry {
|
||||
listActive() {
|
||||
return [];
|
||||
}
|
||||
listAll() {
|
||||
return [];
|
||||
}
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
@@ -753,7 +768,7 @@ test("setup observation includes a transition that happens during its initial re
|
||||
readStarted();
|
||||
return pendingRead;
|
||||
},
|
||||
list: () => [],
|
||||
listActive: () => [],
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
});
|
||||
session.updateClientCapabilities(
|
||||
|
||||
@@ -973,8 +973,9 @@ describe("archiveByScope", () => {
|
||||
}),
|
||||
};
|
||||
deps.agentStorage = {
|
||||
list: async () => [{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[],
|
||||
} as Pick<AgentStorage, "list">;
|
||||
listByWorkspace: async () =>
|
||||
[{ id: agentId, workspaceId, archivedAt: null }] as StoredAgentRecord[],
|
||||
} as Pick<AgentStorage, "listByWorkspace">;
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { buildStoredAgentPayload } from "./agent/agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { Session as InspectorSession } from "node:inspector/promises";
|
||||
import type { HeapProfiler } from "node:inspector";
|
||||
import {
|
||||
FileBackedWorkspaceRegistry,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
@@ -73,7 +81,10 @@ class WorkspaceStatus {
|
||||
private readonly directory = new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => [this.project] },
|
||||
workspaceRegistry: { list: async () => this.workspaces },
|
||||
workspaceRegistry: {
|
||||
listActive: async () => this.workspaces.filter((w) => !w.archivedAt),
|
||||
get: async (id) => this.workspaces.find((w) => w.workspaceId === id) ?? null,
|
||||
},
|
||||
listAgentPayloads: async () => this.agents,
|
||||
workspaceLastUserMessageTimes: async () => {
|
||||
const times = new Map<string, string>();
|
||||
@@ -586,7 +597,10 @@ describe("WorkspaceDirectory empty projects", () => {
|
||||
return new WorkspaceDirectory({
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => input.projects },
|
||||
workspaceRegistry: { list: async () => input.workspaces },
|
||||
workspaceRegistry: {
|
||||
listActive: async () => input.workspaces.filter((w) => !w.archivedAt),
|
||||
get: async (id) => input.workspaces.find((w) => w.workspaceId === id) ?? null,
|
||||
},
|
||||
listAgentPayloads: async () => [],
|
||||
workspaceLastUserMessageTimes: async () => new Map(),
|
||||
listProviderSubagentActivity: async () => [],
|
||||
@@ -745,9 +759,9 @@ test("Git observation targets exclude archived records without hydrating app des
|
||||
logger: createTestLogger(),
|
||||
projectRegistry: { list: async () => [project("active"), project("archived", NOW)] },
|
||||
workspaceRegistry: {
|
||||
list: async () => [
|
||||
get: async () => null,
|
||||
listActive: async () => [
|
||||
workspace("observed", "active"),
|
||||
workspace("hidden", "active", NOW),
|
||||
workspace("hidden-project", "archived"),
|
||||
],
|
||||
},
|
||||
@@ -810,13 +824,22 @@ test("prompt recency reads archived records without projecting agents or exposin
|
||||
lastUserMessageAt: "2026-03-05T00:00:00.000Z",
|
||||
});
|
||||
await storage.upsert({ ...base, id: "output", lastActivityAt: "2026-03-06T00:00:00.000Z" });
|
||||
await storage.upsert({ ...base, id: "live", lastUserMessageAt: "2026-03-09T00:00:00.000Z" });
|
||||
const unexpectedProjection = (): never => {
|
||||
throw new Error("Prompt sorting projected an agent payload");
|
||||
};
|
||||
const directory = new AgentDirectory({
|
||||
storage,
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: { get: async () => null, list: async () => [] },
|
||||
manager: {
|
||||
listAgents: () => [
|
||||
{
|
||||
id: "live",
|
||||
workspaceId: "w",
|
||||
lastUserMessageAt: new Date("2026-03-02T00:00:00.000Z"),
|
||||
} as ManagedAgent,
|
||||
],
|
||||
},
|
||||
workspaces: { get: async () => null },
|
||||
projectLive: unexpectedProjection,
|
||||
projectStored: unexpectedProjection,
|
||||
isProviderVisible: () => false,
|
||||
@@ -829,3 +852,148 @@ test("prompt recency reads archived records without projecting agents or exposin
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("scoped descriptor builds allocate independently of archived history", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "descriptor-history-"));
|
||||
try {
|
||||
const logger = createTestLogger();
|
||||
const agentsPath = join(home, "agents");
|
||||
await mkdir(agentsPath);
|
||||
const records = Array.from({ length: 10001 }, (_, i) =>
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: `w${i}`,
|
||||
projectId: "p",
|
||||
cwd: home,
|
||||
kind: "directory",
|
||||
displayName: "workspace",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: i ? NOW : null,
|
||||
}),
|
||||
);
|
||||
await writeFile(join(home, "workspaces.json"), JSON.stringify(records));
|
||||
for (let offset = 0; offset < 10000; offset += 100)
|
||||
await Promise.all(
|
||||
Array.from({ length: 100 }, (_, i) => {
|
||||
const id = `a${offset + i}`;
|
||||
return writeFile(
|
||||
join(agentsPath, `${id}.json`),
|
||||
JSON.stringify({
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "w1",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
archivedAt: NOW,
|
||||
lastUserMessageAt: NOW,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const storage = new AgentStorage(agentsPath, logger);
|
||||
const registry = new FileBackedWorkspaceRegistry(join(home, "workspaces.json"), logger);
|
||||
await storage.initialize();
|
||||
await storage.upsert({
|
||||
id: "parent",
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "w2",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
});
|
||||
await storage.upsert({
|
||||
id: "child",
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "w0",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
lastStatus: "running",
|
||||
labels: { "paseo.parent-agent-id": "parent" },
|
||||
});
|
||||
await registry.initialize();
|
||||
const agents = new AgentDirectory({
|
||||
storage,
|
||||
workspaces: registry,
|
||||
manager: { listAgents: () => [] },
|
||||
projectLive: async () => {
|
||||
throw new Error("Unexpected live projection");
|
||||
},
|
||||
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
|
||||
isProviderVisible: () => true,
|
||||
isStoredProviderAvailable: () => true,
|
||||
});
|
||||
const directory = new WorkspaceDirectory({
|
||||
logger,
|
||||
workspaceRegistry: registry,
|
||||
projectRegistry: {
|
||||
list: async () => [
|
||||
{
|
||||
projectId: "p",
|
||||
rootPath: home,
|
||||
kind: "non_git",
|
||||
displayName: "project",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
listAgentPayloads: (workspaceIds) =>
|
||||
agents.list({ workspaceIds, includeBackground: true, includeAncestors: true }),
|
||||
workspaceLastUserMessageTimes: (workspaceIds) =>
|
||||
agents.workspaceLastUserMessageTimes(workspaceIds),
|
||||
listProviderSubagentActivity: async () => [],
|
||||
listTerminalActivityContributions: async () => [],
|
||||
isProviderVisibleToClient: () => true,
|
||||
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: "p",
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: home,
|
||||
projectKind: "non_git",
|
||||
workspaceDirectory: home,
|
||||
workspaceKind: "directory",
|
||||
name: "workspace",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
}),
|
||||
});
|
||||
const scoped = await directory.buildDescriptorMap({
|
||||
workspaceIds: new Set(["w0"]).values(),
|
||||
includeGitData: false,
|
||||
});
|
||||
expect(scoped.get("w0")?.status).toBe("running");
|
||||
const inspector = new InspectorSession();
|
||||
inspector.connect();
|
||||
let bytes = 0;
|
||||
try {
|
||||
await inspector.post("HeapProfiler.startSampling", {
|
||||
samplingInterval: 16384,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
for (let i = 0; i < 100; i++)
|
||||
await directory.buildDescriptorMap({
|
||||
workspaceIds: new Set(["w0"]).values(),
|
||||
includeGitData: false,
|
||||
});
|
||||
const { profile } = await inspector.post("HeapProfiler.stopSampling");
|
||||
function sum(node: HeapProfiler.SamplingHeapProfileNode): number {
|
||||
return node.selfSize + node.children.reduce((total, child) => total + sum(child), 0);
|
||||
}
|
||||
bytes = sum(profile.head);
|
||||
} finally {
|
||||
inspector.disconnect();
|
||||
}
|
||||
expect(bytes).toBeLessThan(8 * 1024 * 1024);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
@@ -74,10 +74,13 @@ export interface WorkspaceDirectoryDeps {
|
||||
list(): Promise<PersistedProjectRecord[]>;
|
||||
};
|
||||
workspaceRegistry: {
|
||||
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||
listActive(): Promise<PersistedWorkspaceRecord[]>;
|
||||
get(id: string): Promise<PersistedWorkspaceRecord | null>;
|
||||
};
|
||||
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||
workspaceLastUserMessageTimes(): Promise<ReadonlyMap<string, string>>;
|
||||
listAgentPayloads(workspaceIds?: Iterable<string>): Promise<AgentSnapshotPayload[]>;
|
||||
workspaceLastUserMessageTimes(
|
||||
workspaceIds?: Iterable<string>,
|
||||
): Promise<ReadonlyMap<string, string>>;
|
||||
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
|
||||
listTerminalActivityContributions(): Promise<
|
||||
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
||||
@@ -217,6 +220,7 @@ export class WorkspaceDirectory {
|
||||
includeGitData: boolean;
|
||||
workspaceIds?: Iterable<string>;
|
||||
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||
const requestedIds = options.workspaceIds ? new Set(options.workspaceIds) : undefined;
|
||||
const [
|
||||
agents,
|
||||
lastUserMessageTimes,
|
||||
@@ -225,10 +229,15 @@ export class WorkspaceDirectory {
|
||||
persistedProjects,
|
||||
terminalContributions,
|
||||
] = await Promise.all([
|
||||
this.deps.listAgentPayloads(),
|
||||
this.deps.workspaceLastUserMessageTimes(),
|
||||
this.deps.listAgentPayloads(requestedIds),
|
||||
this.deps.workspaceLastUserMessageTimes(requestedIds),
|
||||
this.deps.listProviderSubagentActivity(),
|
||||
this.deps.workspaceRegistry.list(),
|
||||
requestedIds
|
||||
? Promise.all(Array.from(requestedIds, (id) => this.deps.workspaceRegistry.get(id))).then(
|
||||
(records) =>
|
||||
records.filter((record): record is PersistedWorkspaceRecord => record !== null),
|
||||
)
|
||||
: this.deps.workspaceRegistry.listActive(),
|
||||
this.deps.projectRegistry.list(),
|
||||
this.deps.listTerminalActivityContributions(),
|
||||
]);
|
||||
@@ -240,7 +249,7 @@ export class WorkspaceDirectory {
|
||||
);
|
||||
const activeRecords = activeWorkspaceRecords(persistedWorkspaces, persistedProjects);
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
|
||||
const workspaceIds = requestedIds ? new Set(requestedIds) : null;
|
||||
const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId));
|
||||
const includedWorkspaces = activeRecords.filter(
|
||||
(workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId),
|
||||
@@ -570,7 +579,7 @@ export class WorkspaceDirectory {
|
||||
includeBackground?: boolean;
|
||||
}): Promise<WorkspaceProjectDescriptor[]> {
|
||||
const [persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.deps.workspaceRegistry.list(),
|
||||
this.deps.workspaceRegistry.listActive(),
|
||||
this.deps.projectRegistry.list(),
|
||||
]);
|
||||
// A project whose only workspaces are hidden reads as empty to that caller.
|
||||
@@ -601,7 +610,7 @@ export class WorkspaceDirectory {
|
||||
Pick<WorkspaceDescriptorPayload, "id" | "workspaceDirectory" | "workspaceKind">[]
|
||||
> {
|
||||
const [workspaces, projects] = await Promise.all([
|
||||
this.deps.workspaceRegistry.list(),
|
||||
this.deps.workspaceRegistry.listActive(),
|
||||
this.deps.projectRegistry.list(),
|
||||
]);
|
||||
return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({
|
||||
|
||||
@@ -80,7 +80,7 @@ class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry {
|
||||
this.nextError = error;
|
||||
}
|
||||
|
||||
override async list() {
|
||||
override async listActive() {
|
||||
if (this.nextRead) {
|
||||
const pending = this.nextRead;
|
||||
this.nextRead = null;
|
||||
@@ -91,7 +91,7 @@ class ObservedWorkspaceRegistry extends FileBackedWorkspaceRegistry {
|
||||
this.nextError = null;
|
||||
throw error;
|
||||
}
|
||||
return super.list();
|
||||
return super.listActive();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export class WorkspaceReconciliationService {
|
||||
const changes: ReconciliationChange[] = [];
|
||||
const [projects, workspaces] = await Promise.all([
|
||||
this.projectRegistry.list(),
|
||||
this.workspaceRegistry.list(),
|
||||
this.workspaceRegistry.listActive(),
|
||||
]);
|
||||
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
|
||||
const workspaceStates = await mapBounded(
|
||||
@@ -245,7 +245,7 @@ export class WorkspaceReconciliationService {
|
||||
const changes: ReconciliationChange[] = [];
|
||||
|
||||
const allProjects = await this.projectRegistry.list();
|
||||
const allWorkspaces = await this.workspaceRegistry.list();
|
||||
const allWorkspaces = await this.workspaceRegistry.listActive();
|
||||
|
||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||
const activeProjectsById = new Map(
|
||||
@@ -547,7 +547,7 @@ export class WorkspaceReconciliationService {
|
||||
if (change.kind === "project_updated") projectIds.add(change.projectId);
|
||||
}
|
||||
if (projectIds.size > 0) {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const workspaces = await this.workspaceRegistry.listActive();
|
||||
for (const workspaceId of workspaceIdsForProjects(workspaces, projectIds)) {
|
||||
workspaceIds.add(workspaceId);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a legacy agent whose cwd is a file", async () => {
|
||||
@@ -128,7 +128,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([]);
|
||||
});
|
||||
|
||||
test("propagates a Git failure for an existing legacy directory", async () => {
|
||||
@@ -233,7 +233,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listAll();
|
||||
expect(workspaces).toHaveLength(1);
|
||||
expect(workspaces[0]?.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT);
|
||||
@@ -307,8 +307,8 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(1);
|
||||
expect((await workspaceRegistry.listAll())[0]?.workspaceId).toBe("ws-existing");
|
||||
});
|
||||
|
||||
test("materializes legacy remote worktrees into one readable project", async () => {
|
||||
@@ -366,7 +366,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
displayName: "acme/legacy-project",
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listAll();
|
||||
expect(
|
||||
workspaces
|
||||
.map(({ projectId, cwd, kind, displayName }) => ({ projectId, cwd, kind, displayName }))
|
||||
@@ -449,7 +449,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
});
|
||||
|
||||
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older");
|
||||
expect(await workspaceRegistry.list()).toHaveLength(2);
|
||||
expect(await workspaceRegistry.listAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("migrated legacy agents stay owned by the deterministic workspace when a same-cwd workspace is added later", async () => {
|
||||
@@ -568,7 +568,7 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
const workspaces = await workspaceRegistry.listAll();
|
||||
expect(workspaces).toHaveLength(1);
|
||||
expect(workspaces[0]?.workspaceId).toBe("ws-existing");
|
||||
expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT);
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
// COMPAT(worktree-branch-identity): added in v0.4.0 on 2026-08-15; remove after
|
||||
// 2027-02-15. Older worktrees did not pin branch-off/check-out branch identity.
|
||||
// Seed it from the registry value clients already display, never from live Git.
|
||||
for (const workspace of await options.workspaceRegistry.list()) {
|
||||
for (const workspace of await options.workspaceRegistry.listActive()) {
|
||||
if (
|
||||
workspace.archivedAt ||
|
||||
!workspace.isPaseoOwnedWorktree ||
|
||||
@@ -89,12 +89,14 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
}
|
||||
|
||||
const existingWorkspaceIdsByCwd = new Map(
|
||||
(await options.workspaceRegistry.list()).map((workspace) => [
|
||||
// Startup backfill resolves workspace ownership across archived records too.
|
||||
(await options.workspaceRegistry.listAll()).map((workspace) => [
|
||||
path.resolve(workspace.cwd),
|
||||
workspace.workspaceId,
|
||||
]),
|
||||
);
|
||||
const records = await options.agentStorage.list();
|
||||
// Startup backfill repairs agents without workspace membership, including archived history.
|
||||
const records = await options.agentStorage.listAll();
|
||||
// A legacy agent can outlive its working directory. Reconciliation treats a
|
||||
// missing directory as absent rather than asking Git about it; bootstrap must
|
||||
// do the same before materializing its first workspace record.
|
||||
|
||||
@@ -500,7 +500,7 @@ describe("workspace registries", () => {
|
||||
workspace: archived,
|
||||
});
|
||||
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.listAll()).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves the first workspace archive and reports whether a queued archive applied", async () => {
|
||||
|
||||
@@ -156,7 +156,8 @@ export interface ProjectRegistry {
|
||||
export interface WorkspaceRegistry {
|
||||
initialize(): Promise<void>;
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||
listAll(): Promise<PersistedWorkspaceRecord[]>;
|
||||
listActive(): Promise<PersistedWorkspaceRecord[]>;
|
||||
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
|
||||
update(
|
||||
workspaceId: string,
|
||||
@@ -182,6 +183,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
protected readonly logger: Logger;
|
||||
private readonly schema: z.ZodType<TRecord, unknown>;
|
||||
private readonly getId: (record: TRecord) => string;
|
||||
private readonly activeIds = new Set<string>();
|
||||
private loaded = false;
|
||||
private readonly cache = new Map<string, TRecord>();
|
||||
private mutationQueue: Promise<void> = Promise.resolve();
|
||||
@@ -219,11 +221,16 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<TRecord[]> {
|
||||
async listAll(): Promise<TRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async listActive(): Promise<TRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.activeIds, (id) => this.cache.get(id)!);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(id) ?? null;
|
||||
@@ -288,6 +295,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
|
||||
}
|
||||
}
|
||||
this.rebuildActiveIndex();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
@@ -335,6 +343,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
this.cache.clear();
|
||||
for (const [id, record] of staged) this.cache.set(id, record);
|
||||
}
|
||||
this.rebuildActiveIndex();
|
||||
hooks?.afterCommit?.();
|
||||
return result;
|
||||
} finally {
|
||||
@@ -342,6 +351,11 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildActiveIndex(): void {
|
||||
this.activeIds.clear();
|
||||
for (const [id, record] of this.cache) if (!record.archivedAt) this.activeIds.add(id);
|
||||
}
|
||||
|
||||
protected freezeMutationsUntilRestart(): void {
|
||||
this.mutationsBlockedUntilRestart = true;
|
||||
}
|
||||
@@ -359,6 +373,10 @@ export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
async list(): Promise<PersistedProjectRecord[]> {
|
||||
return this.listAll();
|
||||
}
|
||||
|
||||
private allocationQueue: Promise<void> = Promise.resolve();
|
||||
private readonly projectIdFactory: () => string;
|
||||
private readonly mutationListeners = new Set<
|
||||
|
||||
@@ -331,7 +331,8 @@ function createPaseoWorktreeForTest(options: {
|
||||
const workspaceRegistry: WorkspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
|
||||
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
@@ -477,9 +478,9 @@ describe("create-agent worktree setup boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createAgentStorageStub(): Pick<AgentStorage, "list"> {
|
||||
function createAgentStorageStub(): Pick<AgentStorage, "listAll"> {
|
||||
return {
|
||||
list: async (): Promise<StoredAgentRecord[]> => [],
|
||||
listAll: async (): Promise<StoredAgentRecord[]> => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user