mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): preserve exact-folder runtime isolation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user