mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): close project update races
This commit is contained in:
@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project).not.toBeNull();
|
||||
const readdedProjectId = readded.project?.projectId ?? "";
|
||||
expect(readdedProjectId).not.toBe(workspace.projectId);
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
|
||||
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
|
||||
@@ -46,24 +46,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.filter({ hasText: projectName })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -96,21 +97,24 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: "test-owner/test-repo",
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -65,6 +65,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { createProjectHydrationBuffer } from "@/contexts/session-workspace-hydration";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
@@ -126,6 +127,8 @@ interface WorkspaceHydrationSnapshot {
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
}
|
||||
|
||||
type ProjectUpdatePayload = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
|
||||
async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
@@ -586,6 +589,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const wasConnectedRef = useRef(isConnected);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
const projectHydrationBufferRef = useRef(createProjectHydrationBuffer<ProjectUpdatePayload>());
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
@@ -604,38 +608,74 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
}, [sessionAgents]);
|
||||
|
||||
const applyProjectUpdate = useCallback(
|
||||
(update: ProjectUpdatePayload) => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.applyProjectUpdate(
|
||||
serverId,
|
||||
update.kind === "upsert"
|
||||
? { kind: "upsert", project: normalizeEmptyProjectDescriptor(update.project) }
|
||||
: update,
|
||||
);
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const hydrateWorkspaces = useCallback(
|
||||
async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await fetchWorkspaceHydrationSnapshot({
|
||||
client,
|
||||
serverId,
|
||||
subscribe: options?.subscribe ?? false,
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
if (!snapshot || options?.isCancelled?.()) {
|
||||
return;
|
||||
}
|
||||
const hydration = projectHydrationBufferRef.current.begin();
|
||||
try {
|
||||
const snapshot = await fetchWorkspaceHydrationSnapshot({
|
||||
client,
|
||||
serverId,
|
||||
subscribe: options?.subscribe ?? false,
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
if (!snapshot || options?.isCancelled?.()) {
|
||||
projectHydrationBufferRef.current.cancel(hydration);
|
||||
return;
|
||||
}
|
||||
|
||||
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
|
||||
client,
|
||||
serverId,
|
||||
workspaces: snapshot.workspaces,
|
||||
emptyProjects: snapshot.emptyProjects,
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
if (didBackfillLegacy) {
|
||||
return;
|
||||
}
|
||||
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
|
||||
client,
|
||||
serverId,
|
||||
workspaces: snapshot.workspaces,
|
||||
emptyProjects: snapshot.emptyProjects,
|
||||
isCancelled: options?.isCancelled,
|
||||
});
|
||||
if (didBackfillLegacy) {
|
||||
projectHydrationBufferRef.current.commit(hydration, () => {}, applyProjectUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaces(serverId, snapshot.workspaces);
|
||||
setEmptyProjects(serverId, snapshot.emptyProjects.values());
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
projectHydrationBufferRef.current.commit(
|
||||
hydration,
|
||||
() => {
|
||||
setWorkspaces(serverId, snapshot.workspaces);
|
||||
setEmptyProjects(serverId, snapshot.emptyProjects.values());
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
},
|
||||
applyProjectUpdate,
|
||||
);
|
||||
} catch (error) {
|
||||
projectHydrationBufferRef.current.cancel(hydration);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
|
||||
[
|
||||
applyProjectUpdate,
|
||||
client,
|
||||
isConnected,
|
||||
serverId,
|
||||
setEmptyProjects,
|
||||
setHasHydratedWorkspaces,
|
||||
setWorkspaces,
|
||||
],
|
||||
);
|
||||
|
||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||
@@ -1393,14 +1433,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubProjectUpdate = client.on("project.update", (message) => {
|
||||
const update = message.payload;
|
||||
if (update.kind === "remove") {
|
||||
useSessionStore.getState().applyProjectUpdate(serverId, update);
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().applyProjectUpdate(serverId, {
|
||||
kind: "upsert",
|
||||
project: normalizeEmptyProjectDescriptor(update.project),
|
||||
});
|
||||
if (!projectHydrationBufferRef.current.buffer(update)) applyProjectUpdate(update);
|
||||
});
|
||||
|
||||
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
|
||||
@@ -1832,6 +1865,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notifyAgentAttention,
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
applyProjectUpdate,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
updateSessionServerInfo,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createProjectHydrationBuffer } from "./session-workspace-hydration";
|
||||
|
||||
type ProjectUpdate =
|
||||
| { kind: "upsert"; projectId: string; name: string }
|
||||
| { kind: "remove"; projectId: string };
|
||||
|
||||
function applyProjectUpdate(projects: Map<string, string>, update: ProjectUpdate): void {
|
||||
if (update.kind === "remove") projects.delete(update.projectId);
|
||||
else projects.set(update.projectId, update.name);
|
||||
}
|
||||
|
||||
describe("workspace hydration project updates", () => {
|
||||
it("replays project updates after replacing the stale hydration snapshot", () => {
|
||||
const projects = new Map<string, string>([["project-1", "before hydration"]]);
|
||||
const hydration = createProjectHydrationBuffer<ProjectUpdate>();
|
||||
const lease = hydration.begin();
|
||||
|
||||
hydration.buffer({ kind: "upsert", projectId: "project-2", name: "arrived live" });
|
||||
hydration.buffer({ kind: "remove", projectId: "project-1" });
|
||||
|
||||
hydration.commit(
|
||||
lease,
|
||||
() => {
|
||||
projects.clear();
|
||||
projects.set("project-1", "stale snapshot");
|
||||
},
|
||||
(update) => applyProjectUpdate(projects, update),
|
||||
);
|
||||
|
||||
expect(projects).toEqual(new Map([["project-2", "arrived live"]]));
|
||||
});
|
||||
|
||||
it("does not let an older hydration overwrite the current one", () => {
|
||||
const projects = new Map<string, string>();
|
||||
const hydration = createProjectHydrationBuffer<ProjectUpdate>();
|
||||
const older = hydration.begin();
|
||||
const current = hydration.begin();
|
||||
hydration.buffer({ kind: "upsert", projectId: "project-2", name: "current" });
|
||||
|
||||
expect(
|
||||
hydration.commit(
|
||||
older,
|
||||
() => projects.set("project-1", "stale"),
|
||||
() => {},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hydration.commit(
|
||||
current,
|
||||
() => projects.set("project-1", "current snapshot"),
|
||||
(update) => applyProjectUpdate(projects, update),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(projects).toEqual(
|
||||
new Map([
|
||||
["project-1", "current snapshot"],
|
||||
["project-2", "current"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
31
packages/app/src/contexts/session-workspace-hydration.ts
Normal file
31
packages/app/src/contexts/session-workspace-hydration.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Orders project updates around an in-flight workspace snapshot. */
|
||||
export function createProjectHydrationBuffer<Update>() {
|
||||
let active: { updates: Update[] } | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
const hydration = { updates: [] as Update[] };
|
||||
active = hydration;
|
||||
return hydration;
|
||||
},
|
||||
buffer(update: Update): boolean {
|
||||
if (!active) return false;
|
||||
active.updates.push(update);
|
||||
return true;
|
||||
},
|
||||
commit(
|
||||
hydration: { updates: Update[] },
|
||||
applySnapshot: () => void,
|
||||
applyUpdate: (update: Update) => void,
|
||||
): boolean {
|
||||
if (active !== hydration) return false;
|
||||
active = null;
|
||||
applySnapshot();
|
||||
for (const update of hydration.updates) applyUpdate(update);
|
||||
return true;
|
||||
},
|
||||
cancel(hydration: { updates: Update[] }): void {
|
||||
if (active === hydration) active = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
@@ -46,6 +47,7 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
|
||||
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
|
||||
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import { areEquivalentPaths } from "../../utils/path.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
|
||||
@@ -699,6 +701,24 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) =>
|
||||
areEquivalentPaths(project.rootPath, allocation.rootPath) &&
|
||||
!project.archivedAt,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_test_${projects.size + 1}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
projects.set(record.projectId, record);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
@@ -28,10 +29,21 @@ describe("bootstrap provider availability", () => {
|
||||
tempRoots.push(root);
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
||||
tempRoots.push(binDir);
|
||||
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.split(/\r?\n/)[0]
|
||||
.trim();
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(path.join(binDir, "git.cmd"), `@"${gitPath}" %*\r\n`);
|
||||
} else {
|
||||
await symlink(gitPath, path.join(binDir, "git"));
|
||||
}
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
|
||||
const paseoHome = path.join(root, ".paseo");
|
||||
const staticDir = path.join(root, "static");
|
||||
const agentStoragePath = path.join(paseoHome, "agents");
|
||||
|
||||
@@ -797,13 +797,7 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
reconciliation: workspaceReconciliation,
|
||||
logger,
|
||||
onProjectUpdate: (update) => {
|
||||
if (update.kind === "upsert") {
|
||||
wsServer?.publishProjectUpdate(update.project);
|
||||
} else {
|
||||
wsServer?.publishProjectRemove(update.projectId);
|
||||
}
|
||||
},
|
||||
onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update),
|
||||
onWorkspacesChanged: async (workspaceIds) => {
|
||||
await Promise.all(
|
||||
(wsServer?.listActiveSessions() ?? []).map((session) =>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
@@ -97,12 +98,13 @@ test("repairs a legacy source workspace whose project record is missing", async
|
||||
|
||||
expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
||||
expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId);
|
||||
expect(deps.projects.get(result.workspace.projectId)).toMatchObject({
|
||||
const repairedProject = deps.projects.get(result.workspace.projectId);
|
||||
expect(repairedProject).toMatchObject({
|
||||
projectId: result.workspace.projectId,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
archivedAt: null,
|
||||
});
|
||||
expect(areEquivalentPaths(repairedProject?.rootPath ?? "", repoDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
ProjectGitObserverService,
|
||||
type ProjectGitObserverUpdate,
|
||||
} from "./project-git-observer-service.js";
|
||||
import { ProjectGitObserverService, type ProjectUpdate } from "./project-git-observer-service.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
type PersistedProjectRecord,
|
||||
@@ -344,7 +341,7 @@ class ObservedProjects {
|
||||
private readonly roots = new FakeProjectRoots(this.lifecycleEvents);
|
||||
private readonly registry: FakeProjectRegistry;
|
||||
private readonly gitMetadata = new FakeGitMetadata();
|
||||
private readonly projectEvents: ProjectGitObserverUpdate[] = [];
|
||||
private readonly projectEvents: ProjectUpdate[] = [];
|
||||
private readonly workspaceEvents: string[][] = [];
|
||||
private readonly logRecords: LogRecord[] = [];
|
||||
private readonly service: ProjectGitObserverService;
|
||||
@@ -459,7 +456,7 @@ class ObservedProjects {
|
||||
return [...this.lifecycleEvents];
|
||||
}
|
||||
|
||||
get publishedProjects(): ProjectGitObserverUpdate[] {
|
||||
get publishedProjects(): ProjectUpdate[] {
|
||||
return [...this.projectEvents];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { WorkspaceReconciliationService } from "./workspace-reconciliation-
|
||||
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 100;
|
||||
|
||||
export type ProjectGitObserverUpdate =
|
||||
export type ProjectUpdate =
|
||||
| { kind: "upsert"; project: PersistedProjectRecord }
|
||||
| { kind: "remove"; projectId: string };
|
||||
|
||||
@@ -69,7 +69,7 @@ export class ProjectGitObserverService {
|
||||
projectRegistry: ProjectRegistry;
|
||||
reconciliation: Pick<WorkspaceReconciliationService, "reconcileGitMetadata">;
|
||||
logger: pino.Logger;
|
||||
onProjectUpdate: (update: ProjectGitObserverUpdate) => void;
|
||||
onProjectUpdate: (update: ProjectUpdate) => void;
|
||||
onWorkspacesChanged: (workspaceIds: string[]) => Promise<void>;
|
||||
watch?: ProjectRootWatch;
|
||||
clock?: ObserverClock;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { createPersistedProjectRecord } from "./workspace-registry.js";
|
||||
import type { SessionOptions } from "./session.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
|
||||
import {
|
||||
@@ -352,14 +353,16 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
...options.agentStorage,
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
getOrCreateActiveByRoot: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
...options.projectRegistry,
|
||||
},
|
||||
workspaceRegistry: options.workspaceRegistry ?? {
|
||||
get: vi.fn(),
|
||||
@@ -508,12 +511,20 @@ describe("project command-center RPCs", () => {
|
||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||
const directoryPath = join(parentDirectory, "new-project");
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const projectUpsert = vi.fn().mockResolvedValue(undefined);
|
||||
const projectAllocation = vi.fn(async (input) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: "prj_created_directory",
|
||||
rootPath: input.rootPath,
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
createdAt: input.timestamp,
|
||||
updatedAt: input.timestamp,
|
||||
}),
|
||||
);
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: projectUpsert,
|
||||
getOrCreateActiveByRoot: projectAllocation,
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
@@ -537,7 +548,12 @@ describe("project command-center RPCs", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(directoryPath)).toBe(true);
|
||||
expect(projectUpsert).toHaveBeenCalledOnce();
|
||||
expect(projectAllocation).toHaveBeenCalledWith({
|
||||
rootPath: directoryPath,
|
||||
kind: "non_git",
|
||||
displayName: "new-project",
|
||||
timestamp: expect.any(String),
|
||||
});
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.create_directory.response",
|
||||
@@ -545,7 +561,7 @@ describe("project command-center RPCs", () => {
|
||||
requestId: "req-create-directory",
|
||||
directoryPath,
|
||||
project: {
|
||||
projectId: directoryPath,
|
||||
projectId: "prj_created_directory",
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: directoryPath,
|
||||
@@ -568,8 +584,7 @@ describe("project command-center RPCs", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
|
||||
@@ -61,6 +61,7 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { ProjectUpdate } from "./project-git-observer-service.js";
|
||||
import {
|
||||
CLIENT_SHUTDOWN_RPC_REASON,
|
||||
normalizeClientRestartRpcReason,
|
||||
@@ -970,19 +971,17 @@ export class Session {
|
||||
return this.clientCapabilities.has(capability);
|
||||
}
|
||||
|
||||
emitProjectUpdate(project: PersistedProjectRecord): void {
|
||||
emitProjectUpdate(update: ProjectUpdate): void {
|
||||
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
|
||||
this.emit({
|
||||
type: "project.update",
|
||||
payload: { kind: "upsert", project: this.buildProjectDescriptor(project) },
|
||||
payload:
|
||||
update.kind === "upsert"
|
||||
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
|
||||
: update,
|
||||
});
|
||||
}
|
||||
|
||||
emitProjectRemove(projectId: string): void {
|
||||
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
|
||||
this.emit({ type: "project.update", payload: { kind: "remove", projectId } });
|
||||
}
|
||||
|
||||
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,8 @@ import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import type { ProjectUpdate } from "./project-git-observer-service.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { LoopService } from "./loop-service.js";
|
||||
import type { ScheduleService } from "./schedule/service.js";
|
||||
@@ -769,12 +766,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
public publishProjectUpdate(project: PersistedProjectRecord): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectUpdate(project);
|
||||
}
|
||||
|
||||
public publishProjectRemove(projectId: string): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectRemove(projectId);
|
||||
public publishProjectUpdate(update: ProjectUpdate): void {
|
||||
for (const session of this.listActiveSessions()) session.emitProjectUpdate(update);
|
||||
}
|
||||
|
||||
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
|
||||
|
||||
@@ -348,10 +348,10 @@ describe("wire compatibility", () => {
|
||||
messages: capableMessages,
|
||||
});
|
||||
|
||||
legacy.emitProjectUpdate(project);
|
||||
legacy.emitProjectRemove(project.projectId);
|
||||
capable.emitProjectUpdate(project);
|
||||
capable.emitProjectRemove(project.projectId);
|
||||
legacy.emitProjectUpdate({ kind: "upsert", project });
|
||||
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
|
||||
capable.emitProjectUpdate({ kind: "upsert", project });
|
||||
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
|
||||
|
||||
expect(legacyMessages).toEqual([]);
|
||||
expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -11,10 +11,10 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
|
||||
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
|
||||
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
|
||||
const GIT_PROJECT = path.resolve("/tmp/legacy-git-project");
|
||||
const GIT_WORKTREE = path.resolve("/tmp/legacy-git-project-feature");
|
||||
let NON_GIT_PROJECT: string;
|
||||
let ARCHIVED_PROJECT: string;
|
||||
let GIT_PROJECT: string;
|
||||
let GIT_WORKTREE: string;
|
||||
|
||||
describe("bootstrapWorkspaceRegistries", () => {
|
||||
let tmpDir: string;
|
||||
@@ -27,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
|
||||
NON_GIT_PROJECT = path.join(tmpDir, "non-git-project");
|
||||
ARCHIVED_PROJECT = path.join(tmpDir, "archived-project");
|
||||
GIT_PROJECT = path.join(tmpDir, "legacy-git-project");
|
||||
GIT_WORKTREE = path.join(tmpDir, "legacy-git-project-feature");
|
||||
paseoHome = path.join(tmpDir, ".paseo");
|
||||
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
@@ -38,12 +42,92 @@ describe("bootstrapWorkspaceRegistries", () => {
|
||||
logger,
|
||||
);
|
||||
workspaceGitService = createNoopWorkspaceGitService();
|
||||
for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("skips a legacy agent whose directory no longer exists", async () => {
|
||||
const missingDirectory = path.join(tmpDir, "missing-project");
|
||||
const getCheckout = async () => {
|
||||
throw new Error("Git must not inspect a missing directory");
|
||||
};
|
||||
workspaceGitService = { ...createNoopWorkspaceGitService(), getCheckout };
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-missing-directory",
|
||||
provider: "codex",
|
||||
cwd: missingDirectory,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: null,
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("propagates a Git failure for an existing legacy directory", async () => {
|
||||
const gitFailure = new Error("Git is unavailable");
|
||||
workspaceGitService = {
|
||||
...createNoopWorkspaceGitService(),
|
||||
getCheckout: async () => {
|
||||
throw gitFailure;
|
||||
},
|
||||
};
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-existing-directory",
|
||||
provider: "codex",
|
||||
cwd: NON_GIT_PROJECT,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
lastActivityAt: null,
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
logger,
|
||||
}),
|
||||
).rejects.toBe(gitFailure);
|
||||
});
|
||||
|
||||
test("materializes workspace registries from non-archived agent records", async () => {
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -70,7 +71,10 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
]),
|
||||
);
|
||||
const records = await options.agentStorage.list();
|
||||
const activeRecords = records.filter((record) => !record.archivedAt);
|
||||
// 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.
|
||||
const activeRecords = records.filter((record) => !record.archivedAt && existsSync(record.cwd));
|
||||
const recordsByDirectoryKey = new Map<
|
||||
string,
|
||||
{
|
||||
|
||||
@@ -32,8 +32,13 @@ import {
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
import {
|
||||
createPaseoWorktree as createPaseoWorktreeService,
|
||||
type CreatePaseoWorktreeFn,
|
||||
@@ -280,6 +285,23 @@ function createPaseoWorktreeForTest(options: {
|
||||
: {}),
|
||||
projectRegistry: {
|
||||
get: async (projectId) => projects.get(projectId) ?? null,
|
||||
getOrCreateActiveByRoot: async (allocation) => {
|
||||
const existing = Array.from(projects.values()).find(
|
||||
(project) =>
|
||||
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
|
||||
);
|
||||
if (existing) return existing;
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: `prj_test_${projects.size + 1}`,
|
||||
rootPath: allocation.rootPath,
|
||||
kind: allocation.kind,
|
||||
displayName: allocation.displayName,
|
||||
createdAt: allocation.timestamp,
|
||||
updatedAt: allocation.timestamp,
|
||||
});
|
||||
projects.set(project.projectId, project);
|
||||
return project;
|
||||
},
|
||||
upsert: async (record) => {
|
||||
options.events?.push(`project:${record.projectId}`);
|
||||
projects.set(record.projectId, record);
|
||||
|
||||
Reference in New Issue
Block a user