fix(projects): preserve exact-folder runtime isolation

This commit is contained in:
Mohamed Boudra
2026-07-16 15:47:38 +00:00
parent 5beee13331
commit b4ac6a04d4
10 changed files with 375 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
@@ -151,6 +151,41 @@ test("repairs a legacy source workspace whose project record is missing", async
expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true);
});
test("uses an equivalent source workspace path when creating a worktree", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "app");
mkdirSync(sourceDir);
writeFileSync(path.join(repoDir, "app", ".gitkeep"), "");
const deps = createDeps();
const sourceProject = createPersistedProjectRecordForTest({
projectId: "prj_source-folder",
rootPath: sourceDir,
displayName: "app",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: "ws-source-folder",
projectId: sourceProject.projectId,
cwd: `${sourceDir}${path.sep}`,
kind: "local_checkout",
displayName: "app",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
worktreeSlug: "equivalent-source",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace.projectId).toBe(sourceProject.projectId);
});
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);

View File

@@ -1,5 +1,6 @@
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { resolve } from "node:path";
import { areEquivalentPaths } from "../utils/path.js";
import {
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
@@ -314,8 +315,12 @@ async function findWorkspaceForSource(options: {
}): Promise<PersistedWorkspaceRecord | null> {
const workspaces = await options.workspaceRegistry.list();
return (
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
workspaces.find(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, options.inputCwd),
) ??
workspaces.find(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, options.repoRoot),
) ??
null
);
}

View File

@@ -897,6 +897,7 @@ export class Session {
scriptRuntimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
@@ -3764,7 +3765,7 @@ export class Session {
statusEnteredAt: null,
activityAt: null,
diffStat,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace.workspaceId, workspace.cwd),
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
@@ -3868,7 +3869,7 @@ export class Session {
projectRecord?: PersistedProjectRecord | null;
includeGitData: boolean;
}): Promise<WorkspaceDescriptorPayload> {
if (input.includeGitData && input.projectRecord?.kind === "git") {
if (input.includeGitData && input.workspace.kind !== "directory") {
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
}
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
@@ -5130,10 +5131,10 @@ export class Session {
// Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
private buildWorkspaceScriptPayloadSnapshot(
workspaceId: string,
workspaceDirectory: string,
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null,
): WorkspaceDescriptorPayload["scripts"] {
return this.workspaceScripts.buildSnapshot(workspaceId, workspaceDirectory);
return this.workspaceScripts.buildSnapshot(workspace, project);
}
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {

View File

@@ -5756,6 +5756,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
expect(descriptors).toEqual([baselineDescriptor]);
});
test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-explicit-directory",
rootPath: "/tmp/explicit-directory",
kind: "non_git",
displayName: "directory project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-explicit-checkout",
projectId: project.projectId,
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
const descriptors = Array.from(
(await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(),
) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>;
expect(descriptors[0]).toMatchObject({
gitRuntime: { currentBranch: "main" },
githubRuntime: expect.any(Object),
});
});
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
const setupSession = () => {
const session = createSessionForWorkspaceTests();

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pino } from "pino";
@@ -6,7 +6,12 @@ import { afterEach, describe, expect, test } from "vitest";
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "../../workspace-registry.js";
import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js";
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
import type {
@@ -14,6 +19,7 @@ import type {
WorktreeScriptResult,
} from "../../worktree-bootstrap.js";
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
@@ -32,7 +38,15 @@ function fakeWorkspaceRegistry(
};
}
function fakeGitService(projectSlug = "paseo") {
function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick<ProjectRegistry, "get"> {
return {
async get() {
return record;
},
};
}
function fakeGitService() {
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
snapshot.git = {
...snapshot.git,
@@ -47,9 +61,6 @@ function fakeGitService(projectSlug = "paseo") {
peekSnapshot() {
return snapshot;
},
async getProjectSlug() {
return projectSlug;
},
};
}
@@ -62,6 +73,7 @@ interface BuildOptions {
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
terminalManager?: TerminalManager | null;
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
spawnThrows?: string;
}
@@ -85,6 +97,7 @@ function buildService(options: BuildOptions = {}) {
terminalManager:
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
workspaceRegistry: fakeWorkspaceRegistry(workspace),
projectRegistry: fakeProjectRegistry(options.project ?? null),
workspaceGitService: fakeGitService(),
getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1",
@@ -128,28 +141,34 @@ afterEach(() => {
});
describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts when the runtime store is unavailable", () => {
test("returns no scripts when the runtime store is unavailable", async () => {
const { service } = buildService({ scriptRuntimeStore: null });
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts for a workspace without a paseo.json", () => {
test("returns no scripts for a workspace without a paseo.json", async () => {
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(dir);
const { service } = buildService();
expect(service.buildSnapshot("ws-1", dir)).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
).toEqual([]);
});
});
describe("emitStatusUpdate", () => {
test("emits one script_status_update carrying the snapshot", () => {
test("emits one script_status_update carrying the snapshot", async () => {
const { service, emitted } = buildService();
service.emitStatusUpdate("ws-1", "/tmp/repo");
await service.emitStatusUpdate("ws-1", "/tmp/repo");
expect(emitted).toEqual([
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
]);
@@ -223,6 +242,101 @@ describe("start", () => {
});
});
test("uses the exact project root for a service hostname", async () => {
const workspace = {
workspaceId: "ws-app",
projectId: "prj-app",
cwd: "/repo/apps/app",
} as PersistedWorkspaceRecord;
const project = {
projectId: "prj-app",
rootPath: "/repo/apps/app",
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const { service, spawnCalls } = buildService({ workspace, project });
await service.start({ ...request, workspaceId: workspace.workspaceId });
expect(spawnCalls[0]).toMatchObject({
projectSlug: deriveProjectServiceSlug(project),
});
});
test("keeps same-named service projects distinct", async () => {
const projectA = {
projectId: "prj-app-a",
rootPath: "/repo-a/app",
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" };
const workspaceA = {
workspaceId: "ws-app-a",
projectId: projectA.projectId,
cwd: projectA.rootPath,
} as PersistedWorkspaceRecord;
const workspaceB = {
workspaceId: "ws-app-b",
projectId: projectB.projectId,
cwd: projectB.rootPath,
} as PersistedWorkspaceRecord;
const first = buildService({ workspace: workspaceA, project: projectA });
const second = buildService({ workspace: workspaceB, project: projectB });
await first.service.start({ ...request, workspaceId: workspaceA.workspaceId });
await second.service.start({ ...request, workspaceId: workspaceB.workspaceId });
expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug);
});
test("predicts the same service hostname that start registers", async () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(directory);
writeFileSync(
join(directory, "paseo.json"),
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
);
const project = {
projectId: "prj_hostname",
rootPath: directory,
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const workspace = {
workspaceId: "ws-hostname",
projectId: project.projectId,
cwd: directory,
} as PersistedWorkspaceRecord;
const serviceProxy = createServiceProxySubsystem({ logger });
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
const snapshot = service.buildSnapshot(workspace, project);
await service.start({ ...request, workspaceId: workspace.workspaceId });
const started = spawnCalls[0]!;
expect(snapshot[0]?.hostname).toBe(
serviceProxy.projectWorkspaceService({
projectSlug: started.projectSlug,
branchName: started.branchName,
scriptName: started.scriptName,
daemonPort: started.daemonPort,
}).hostname,
);
});
test("reports the launcher error when spawning fails", async () => {
const { service, emitted } = buildService({ spawnThrows: "boom" });
await service.start(request);

View File

@@ -9,7 +9,12 @@ import type { ServiceProxySubsystem } from "../../service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
import type { ScriptHealthState } from "../../script-health-monitor.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "../../workspace-registry.js";
import type {
SpawnWorkspaceScriptOptions,
WorktreeScriptResult,
@@ -18,7 +23,7 @@ import {
buildWorkspaceScriptPayloads,
readPaseoConfigForProjection,
} from "../../script-status-projection.js";
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
@@ -32,18 +37,22 @@ type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
* that assembly and guard across the session.
*/
export interface WorkspaceScriptsService {
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
buildSnapshot(
workspace: PersistedWorkspaceRecord,
project?: PersistedProjectRecord | null,
): WorkspaceScriptsPayload;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
start(request: StartWorkspaceScriptRequest): Promise<void>;
}
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot" | "getProjectSlug">;
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
export function createWorkspaceScriptsService(deps: {
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
terminalManager: TerminalManager | null;
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
projectRegistry: Pick<ProjectRegistry, "get">;
workspaceGitService: WorkspaceScriptsGitSource;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
@@ -58,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
scriptRuntimeStore,
terminalManager,
workspaceRegistry,
projectRegistry,
workspaceGitService,
getDaemonTcpPort,
getDaemonTcpHost,
@@ -68,45 +78,54 @@ export function createWorkspaceScriptsService(deps: {
spawnWorkspaceScript,
} = deps;
function resolveGitMetadata(workspaceDirectory: string) {
function resolveGitMetadata(
workspaceDirectory: string,
project: { projectId: string; rootPath: string } | null,
) {
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
if (!snapshot) {
return undefined;
}
return {
projectSlug: deriveProjectSlug(
workspaceDirectory,
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
),
projectSlug: project
? deriveProjectServiceSlug(project)
: deriveProjectSlug(workspaceDirectory, snapshot.git.isGit ? snapshot.git.remoteUrl : null),
currentBranch: snapshot.git.currentBranch,
};
}
function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload {
function buildSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null = null,
): WorkspaceScriptsPayload {
if (!serviceProxy || !scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl,
gitMetadata: resolveGitMetadata(workspaceDirectory),
gitMetadata: resolveGitMetadata(workspace.cwd, project),
resolveHealth: resolveScriptHealth ?? undefined,
});
}
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
emit({
type: "script_status_update",
payload: {
workspaceId,
scripts: buildSnapshot(workspaceId, workspaceDirectory),
},
});
async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> {
try {
const workspace = await workspaceRegistry.get(workspaceId);
if (!workspace) return;
const project = await projectRegistry.get(workspace.projectId);
emit({
type: "script_status_update",
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
});
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
}
}
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
@@ -119,7 +138,13 @@ export function createWorkspaceScriptsService(deps: {
if (!workspace) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const projectSlug = await workspaceGitService.getProjectSlug(workspace.cwd);
const project = await projectRegistry.get(workspace.projectId);
const projectSlug = project
? deriveProjectServiceSlug(project)
: deriveProjectSlug(
workspace.cwd,
workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null,
);
const branchName = workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
const serviceResult = await spawnWorkspaceScript({
@@ -136,11 +161,11 @@ export function createWorkspaceScriptsService(deps: {
terminalManager,
logger,
onLifecycleChanged: () => {
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
},
});
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
emit({
type: "start_workspace_script_response",
payload: {

View File

@@ -233,6 +233,68 @@ describe("archiveByScope", () => {
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-sibling");
const sourceWorkspaceId = "ws-subdirectory-source";
const siblingWorkspaceId = "ws-subdirectory-sibling";
const siblingDirectory = path.join(worktree.worktreePath, "packages", "app");
mkdirSync(siblingDirectory, { recursive: true });
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: sourceWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: siblingWorkspaceId, cwd: siblingDirectory, kind: "local_checkout" },
],
}),
{
scope: { kind: "workspace", workspaceId: sourceWorkspaceId },
repoRoot: repoDir,
requestId: "req-subdirectory-sibling",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [sourceWorkspaceId],
removedDirectory: false,
});
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("archiving a subdirectory workspace keeps its active worktree root", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "subdirectory-target");
const rootWorkspaceId = "ws-subdirectory-root";
const subdirectoryWorkspaceId = "ws-subdirectory-target";
const subdirectory = path.join(worktree.worktreePath, "packages", "app");
mkdirSync(subdirectory, { recursive: true });
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{ workspaceId: rootWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" },
{ workspaceId: subdirectoryWorkspaceId, cwd: subdirectory, kind: "local_checkout" },
],
}),
{
scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId },
repoRoot: repoDir,
requestId: "req-subdirectory-target",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [subdirectoryWorkspaceId],
removedDirectory: false,
});
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("worktree scope archives every workspace on the directory and removes it", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");

View File

@@ -171,7 +171,14 @@ async function resolveArchiveTargets(
);
return { targetDir: null, targetWorkspaceIds: [] };
}
return { targetDir: resolve(record.cwd), targetWorkspaceIds: [workspaceId] };
const worktree = await resolvePaseoWorktreeRootForCwd(record.cwd, {
paseoHome: dependencies.paseoHome,
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
return {
targetDir: worktree?.worktreePath ?? resolve(record.cwd),
targetWorkspaceIds: [workspaceId],
};
}
let targetPath = scope.targetPath;
@@ -237,7 +244,15 @@ async function maybeRemoveDirectory(
}
const remainingActive = await dependencies.listActiveWorkspaces();
if (!isDirectoryUnreferenced(remainingActive, targetDir, new Set(archivedWorkspaceIds))) {
if (
!(await isDirectoryUnreferenced(
remainingActive,
targetDir,
new Set(archivedWorkspaceIds),
dependencies,
request,
))
) {
return false;
}
@@ -326,16 +341,23 @@ export async function archiveWorkspaceContents(
// EXACTLY one last-reference predicate in the module. True when, after archiving
// the in-scope records, no active workspace still points at targetDir. Derived
// from records each call — no stored counter.
function isDirectoryUnreferenced(
async function isDirectoryUnreferenced(
activeWorkspaces: ActiveWorkspaceRef[],
targetDir: string,
archivedWorkspaceIds: ReadonlySet<string>,
): boolean {
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
request: Pick<ArchiveByScopeRequest, "paseoWorktreesBaseRoot">,
): Promise<boolean> {
const target = resolve(targetDir);
return !activeWorkspaces.some(
(workspace) =>
!archivedWorkspaceIds.has(workspace.workspaceId) && resolve(workspace.cwd) === target,
);
for (const workspace of activeWorkspaces) {
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
const worktree = await resolvePaseoWorktreeRootForCwd(workspace.cwd, {
paseoHome: dependencies.paseoHome,
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
if (resolve(worktree?.worktreePath ?? workspace.cwd) === target) return false;
}
return true;
}
export async function killTerminalsForWorkspace(

View File

@@ -5,7 +5,11 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { deriveProjectSlug, parseGitHubRepoNameFromRemote } from "./workspace-git-metadata.js";
import {
deriveProjectServiceSlug,
deriveProjectSlug,
parseGitHubRepoNameFromRemote,
} from "./workspace-git-metadata.js";
function runGit(cwd: string, args: string[]): void {
execFileSync("git", args, {
@@ -166,3 +170,13 @@ describe("deriveProjectSlug", () => {
expect(deriveProjectSlug(cwd)).toBe("untitled");
});
});
describe("deriveProjectServiceSlug", () => {
test("keeps same-basename exact projects distinct and stable", () => {
const first = { projectId: "prj_aaaaaaaaaaaaaaaa", rootPath: "/repo-a/app" };
const second = { projectId: "prj_bbbbbbbbbbbbbbbb", rootPath: "/repo-b/app" };
expect(deriveProjectServiceSlug(first)).toBe(deriveProjectServiceSlug(first));
expect(deriveProjectServiceSlug(first)).not.toBe(deriveProjectServiceSlug(second));
});
});

View File

@@ -1,4 +1,5 @@
import { basename } from "path";
import { createHash } from "node:crypto";
import { parseGitHubRemoteUrl } from "../utils/github-remote.js";
import { slugify } from "../utils/worktree.js";
@@ -20,3 +21,8 @@ export function deriveProjectSlug(cwd: string, remoteUrl: string | null = null):
const sourceName = githubRepoName ?? basename(cwd);
return slugify(sourceName) || "untitled";
}
export function deriveProjectServiceSlug(project: { projectId: string; rootPath: string }): string {
const identity = createHash("sha256").update(project.projectId).digest("hex").slice(0, 8);
return `${deriveProjectSlug(project.rootPath)}-${identity}`;
}