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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user