Add projects without creating workspaces

Keep the legacy open-project request creating a workspace for old clients; new clients now add projects and create workspaces through separate flows.
This commit is contained in:
Mohamed Boudra
2026-06-20 11:56:28 +07:00
parent d1481833e6
commit 4534754617
36 changed files with 1653 additions and 1723 deletions
@@ -1,22 +1,26 @@
import { execSync } from "node:child_process";
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
} from "../workspace-registry.js";
import { type PersistedProjectRecord } from "../workspace-registry.js";
const cleanupPaths = new Set<string>();
const cleanupDaemons = new Set<TestPaseoDaemon>();
const cleanupClients = new Set<DaemonClient>();
function restoreEnv(name: string, previous: string | undefined): void {
if (previous === undefined) {
delete process.env[name];
return;
}
process.env[name] = previous;
}
afterEach(async () => {
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
cleanupClients.clear();
@@ -28,9 +32,58 @@ afterEach(async () => {
cleanupPaths.clear();
});
// Unit 2.2: a project persists as a first-class empty project after its last
// workspace is archived, and is exposed to clients via fetch_workspaces.
test("archiving the last workspace leaves the project as an empty project parent", async () => {
test("project.add creates a project without creating a workspace", async () => {
const previousSupervised = process.env.PASEO_SUPERVISED;
process.env.PASEO_SUPERVISED = "0";
try {
const repoRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-add-project-repo-")));
const paseoHomeRoot = realpathSync(
mkdtempSync(path.join(os.tmpdir(), "paseo-add-project-home-")),
);
cleanupPaths.add(repoRoot);
cleanupPaths.add(paseoHomeRoot);
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@getpaseo.dev'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Paseo Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "README.md"), "# repo\n", "utf8");
execSync("git add README.md", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgSign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
const added = await client.addProject(repoRoot);
expect(added.error).toBeNull();
expect(added.project).not.toBeNull();
const project = added.project!;
expect(project).toMatchObject({
projectId: repoRoot,
projectRootPath: repoRoot,
projectKind: "git",
});
const workspaces = await client.fetchWorkspaces({
filter: { projectId: project.projectId },
});
expect(workspaces.entries).toEqual([]);
expect(workspaces.emptyProjects).toEqual([
expect.objectContaining({
projectId: repoRoot,
projectRootPath: repoRoot,
projectKind: "git",
}),
]);
} finally {
restoreEnv("PASEO_SUPERVISED", previousSupervised);
}
}, 30_000);
test("archiving the last workspace leaves the project parent with no workspaces", async () => {
const previousSupervised = process.env.PASEO_SUPERVISED;
process.env.PASEO_SUPERVISED = "0";
try {
@@ -50,47 +103,6 @@ test("archiving the last workspace leaves the project as an empty project parent
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const projectsPath = path.join(paseoHome, "projects", "projects.json");
const workspacesPath = path.join(paseoHome, "projects", "workspaces.json");
const timestamp = "2026-04-24T09:46:43.146Z";
await mkdir(path.dirname(projectsPath), { recursive: true });
await writeFile(
projectsPath,
JSON.stringify(
[
createPersistedProjectRecord({
projectId: repoRoot,
rootPath: repoRoot,
kind: "git",
displayName: "repo",
createdAt: timestamp,
updatedAt: timestamp,
}),
],
null,
2,
),
"utf8",
);
await writeFile(
workspacesPath,
JSON.stringify(
[
createPersistedWorkspaceRecord({
workspaceId: repoRoot,
projectId: repoRoot,
cwd: repoRoot,
kind: "local_checkout",
displayName: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
],
null,
2,
),
"utf8",
);
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
@@ -99,23 +111,33 @@ test("archiving the last workspace leaves the project as an empty project parent
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } });
const beforeArchive = await client.fetchWorkspaces();
expect(beforeArchive.entries.map((entry) => entry.id)).toContain(repoRoot);
expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(repoRoot);
const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } });
expect(created.error).toBeNull();
expect(created.workspace).not.toBeNull();
const workspaceId = created.workspace!.id;
const projectId = created.workspace!.projectId;
await client.archiveWorkspace(repoRoot);
const beforeArchive = await client.fetchWorkspaces();
expect(beforeArchive.entries.map((entry) => entry.id)).toContain(workspaceId);
expect(beforeArchive.emptyProjects.map((project) => project.projectId)).not.toContain(
projectId,
);
const archiveResponse = await client.archiveWorkspace(workspaceId);
expect(archiveResponse.error).toBeNull();
expect(archiveResponse.archivedAt).not.toBeNull();
const afterArchive = await client.fetchWorkspaces();
expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(repoRoot);
expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(repoRoot);
expect(afterArchive.entries.map((entry) => entry.id)).not.toContain(workspaceId);
expect(afterArchive.emptyProjects.map((project) => project.projectId)).toContain(projectId);
const persistedProjects = JSON.parse(
await readFile(projectsPath, "utf8"),
) as PersistedProjectRecord[];
expect(
persistedProjects.find((project) => project.projectId === repoRoot)?.archivedAt,
persistedProjects.find((project) => project.projectId === projectId)?.archivedAt,
).toBeNull();
} finally {
process.env.PASEO_SUPERVISED = previousSupervised;
restoreEnv("PASEO_SUPERVISED", previousSupervised);
}
}, 30_000);
+96 -7
View File
@@ -2247,6 +2247,8 @@ export class Session {
return this.handleLegacyOpenInEditorRequest(msg);
case "open_project_request":
return this.handleOpenProjectRequest(msg);
case "project.add.request":
return this.handleProjectAddRequest(msg);
case "archive_workspace_request":
return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request":
@@ -7248,6 +7250,30 @@ export class Session {
return workspaceRecord;
}
private async findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
const normalizedCwd = resolve(cwd);
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp: new Date().toISOString(),
});
await this.projectRegistry.upsert(projectRecord);
return projectRecord;
}
private buildProjectDescriptor(
project: PersistedProjectRecord,
): WorkspaceProjectDescriptorPayload {
return {
projectId: project.projectId,
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectRootPath: project.rootPath,
projectKind: project.kind,
};
}
private async reclassifyOrUnarchiveWorkspaceForDirectory(input: {
workspace: PersistedWorkspaceRecord;
project: PersistedProjectRecord | null;
@@ -7662,24 +7688,24 @@ export class Session {
return {
kind: "remove",
id: workspaceId,
...(await this.resolveEmptyProjectForArchivedWorkspace(workspaceId)),
...(await this.resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(workspaceId)),
};
}
// When a workspace is archived its project may become empty. Resolve the
// now-empty project parent so the `remove` update can carry it, keeping the
// sidebar's empty project row in sync without a full re-hydration.
private async resolveEmptyProjectForArchivedWorkspace(
// When a workspace is archived its project may have no active workspaces left.
// Resolve that project parent so the `remove` update can carry it, keeping the
// sidebar in sync without a full re-hydration.
private async resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(
workspaceId: string,
): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> {
const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
if (!archivedWorkspace) {
return null;
}
const emptyProject = (await this.workspaceDirectory.listEmptyProjects()).find(
const projectWithoutActiveWorkspaces = (await this.workspaceDirectory.listEmptyProjects()).find(
(project) => project.projectId === archivedWorkspace.projectId,
);
return emptyProject ? { emptyProject } : null;
return projectWithoutActiveWorkspaces ? { emptyProject: projectWithoutActiveWorkspaces } : null;
}
private async emitWorkspaceUpdateForTerminalContribution(
@@ -8233,6 +8259,69 @@ export class Session {
}
}
private async handleProjectAddRequest(
request: Extract<SessionInboundMessage, { type: "project.add.request" }>,
): Promise<void> {
const requestedCwd = request.cwd;
const cwd = expandTilde(requestedCwd);
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
if (!directoryExists) {
this.sessionLogger.info(
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
"Add project rejected",
);
this.emit({
type: "project.add.response",
payload: {
requestId: request.requestId,
project: null,
error: `Directory not found: ${cwd}`,
errorCode: "directory_not_found",
},
});
return;
}
try {
const projectsBefore = new Map<string, PersistedProjectRecord>();
for (const project of await this.projectRegistry.list()) {
projectsBefore.set(project.projectId, project);
}
const project = await this.findOrCreateProjectForDirectory(cwd);
this.sessionLogger.info(
{
requestedCwd,
resolvedCwd: cwd,
projectId: project.projectId,
projectKind: project.kind,
projectTransition: describeRegistryTransition(
projectsBefore.get(project.projectId) ?? null,
),
},
"Project added",
);
this.emit({
type: "project.add.response",
payload: {
requestId: request.requestId,
project: this.buildProjectDescriptor(project),
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to add project";
this.sessionLogger.error({ err: error, cwd }, "Failed to add project");
this.emit({
type: "project.add.response",
payload: {
requestId: request.requestId,
project: null,
error: message,
},
});
}
}
private buildWorkspaceScriptPayloadSnapshot(
workspaceId: string,
workspaceDirectory: string,
@@ -1166,6 +1166,8 @@ export class VoiceAssistantWebSocketServer {
workspaceMultiplicity: true,
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: true,
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
@@ -391,9 +391,8 @@ export async function killTerminalsForWorkspace(
);
}
// Archiving the last workspace of a project leaves the project as a first-class
// empty project — it persists until explicitly removed, so we never archive the
// parent project here.
// Archiving the last workspace of a project leaves the project record active.
// The user removes the project explicitly, so we never archive the parent here.
export async function archivePersistedWorkspaceRecord(input: {
workspaceId: string;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
@@ -527,7 +527,7 @@ describe("WorkspaceDirectory empty projects", () => {
} satisfies PersistedProjectRecord;
}
test("surfaces a project with no active workspaces as an empty project", async () => {
test("surfaces a project with no active workspaces through the compatibility projection", async () => {
const directory = makeDirectory({
projects: [project({ projectId: "empty", customName: "Renamed" })],
workspaces: [],
@@ -475,9 +475,9 @@ export class WorkspaceDirectory {
return candidates.at(-1) ?? null;
}
// Project parents that have no active workspaces. These persist as first-class
// empty projects so the sidebar can render an empty project row with a
// "+ New workspace" affordance.
// Project parents that have no active workspaces. The wire field is the
// sidebar projection bucket for projects whose workspace list is currently
// empty; it is not a separate domain record.
async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> {
const [persistedWorkspaces, persistedProjects] = await Promise.all([
this.deps.workspaceRegistry.list(),
@@ -566,8 +566,8 @@ export class WorkspaceDirectory {
? this.pager.encode(pagedEntries[pagedEntries.length - 1], sort)
: null;
// Empty project parents ride only on the first page so the sidebar can render
// them without them being duplicated across pagination.
// Project parents with no active workspaces ride only on the first page so
// the sidebar can render them without duplicating them across pagination.
const projectIdFilter = filter?.projectId?.trim();
const emptyProjects = cursorToken
? []
@@ -160,8 +160,8 @@ export class WorkspaceReconciliationService {
await this.mergeDuplicateProjectsByRoot(activeProjects, workspacesByProject, changes);
// 3. Reconcile git metadata for active projects whose directories still exist.
// A project with zero active workspaces is a first-class empty project — it
// persists until explicitly removed and still reconciles its own metadata.
// Projects persist until explicitly removed, even when they currently have
// zero active workspaces, so they still reconcile their own metadata.
// Skip projects archived earlier in this pass (e.g. merged duplicates) so we
// don't resurrect them by upserting a stale, non-archived copy.
const archivedProjectIds = new Set(