Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/durable-project-root-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Resolve and retain durable project roots consistently for Studio and System Graph, including descendant sessions after a recent directory is evicted. Compare Windows drive and UNC roots without case sensitivity and migrate legacy case aliases while preserving durable project identities.
172 changes: 152 additions & 20 deletions packages/harness/src/core/studio-project-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,151 @@ describe("StudioProjectCatalog", () => {
{ workspaceKey: "parent", cwd: parent },
{ workspaceKey: "child", cwd: child },
]);
const parentProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === parent)?.projectId;
const childProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === child)?.projectId;
expect((await catalog.resolveIdentityForPath(path.join(child, "src")))?.projectId).toBe(
childProject,
const parentProject = reconciled.workspaceScopes.find(
({ cwd }) => cwd === parent,
)?.projectId;
const childProject = reconciled.workspaceScopes.find(
({ cwd }) => cwd === child,
)?.projectId;
expect(
(await catalog.resolveIdentityForPath(path.join(child, "src")))
?.projectId,
).toBe(childProject);
expect(
(await catalog.resolveIdentityForPath(path.join(parent, "other")))
?.projectId,
).toBe(parentProject);
expect(
JSON.stringify(await catalog.resolveIdentityForPath(child)),
).not.toContain(root);
});

it("resolves Windows case variants through the most-specific active binding", async () => {
const { catalogPath } = await fixture();
const catalog = new StudioProjectCatalog(catalogPath);
const project = await catalog.create("Windows project");
await catalog.addRootBinding(
project.projectId,
"C:\\Users\\Alice\\Project",
);
await catalog.addRootBinding(
project.projectId,
"C:\\Users\\Alice\\Project\\packages",
);
const caseEquivalent = await catalog.addRootBinding(
project.projectId,
"c:/users/alice/project/",
);
expect((await catalog.resolveIdentityForPath(path.join(parent, "other")))?.projectId).toBe(
parentProject,
const sibling = await catalog.create("Sibling");
await catalog.addRootBinding(
sibling.projectId,
"C:\\Users\\Alice\\Project-two",
);
expect(JSON.stringify(await catalog.resolveIdentityForPath(child))).not.toContain(root);

expect(
(
await catalog.resolveIdentityForPath(
"c:/users/alice/project/PACKAGES/app/src",
)
)?.projectId,
).toBe(project.projectId);
expect(
await catalog.resolveIdentityForPath(
"c:/users/alice/project-two-adjacent/src",
),
).toBeNull();
expect(
await catalog.resolveIdentityForPath("D:/users/alice/project/src"),
).toBeNull();
expect(caseEquivalent.bindings).toHaveLength(2);
});

it("reconciles reordered case-varied scopes into one multi-root Windows project", async () => {
const { catalogPath } = await fixture();
const catalog = new StudioProjectCatalog(catalogPath);
const project = await catalog.create("Multi-root Windows project");
await catalog.addRootBinding(project.projectId, "C:\\Projects\\Research");
await catalog.addRootBinding(project.projectId, "D:\\Projects\\Publisher");

const first = await catalog.reconcile([
{ workspaceKey: "research", cwd: "c:/projects/research/" },
{ workspaceKey: "publisher", cwd: "d:/PROJECTS/PUBLISHER" },
]);
const second = await new StudioProjectCatalog(catalogPath).reconcile([
{ workspaceKey: "publisher", cwd: "D:\\projects\\publisher" },
{ workspaceKey: "research", cwd: "C:\\PROJECTS\\RESEARCH" },
]);

expect(first.projects).toHaveLength(1);
expect(second.projects).toHaveLength(1);
expect(second.projects[0]).toMatchObject({
projectId: project.projectId,
bindings: [
expect.objectContaining({ status: "active" }),
expect.objectContaining({ status: "active" }),
],
});
expect(
new Set(second.workspaceScopes.map((scope) => scope.projectId)),
).toEqual(new Set([project.projectId]));
});

it("loads and persists legacy Windows case aliases without replacing project identity", async () => {
const { catalogPath } = await fixture();
const catalog = new StudioProjectCatalog(catalogPath);
const project = await catalog.create("Legacy Windows project");
await catalog.addRootBinding(project.projectId, "C:\\Work\\Project");
const raw = JSON.parse(await fs.readFile(catalogPath, "utf8"));
const originalBinding = raw.projects[0].rootBindings[0];
raw.projects[0].rootBindings.push({
...originalBinding,
id: "root_00000000-0000-4000-8000-000000000099",
localRootRef: "c:\\work\\project",
});
await fs.writeFile(catalogPath, JSON.stringify(raw));

const restarted = new StudioProjectCatalog(catalogPath);
expect(await restarted.list()).toEqual([
expect.objectContaining({
projectId: project.projectId,
bindings: [{ id: originalBinding.id, status: "active" }],
}),
]);
expect(await restarted.resolveIdentityForPath("c:\\WORK\\PROJECT\\src"))
.toMatchObject({ projectId: project.projectId });
await restarted.reconcile([{ workspaceKey: "legacy", cwd: "c:\\work\\project" }]);
const persisted = JSON.parse(await fs.readFile(catalogPath, "utf8"));
expect(persisted.projects[0].rootBindings).toEqual([originalBinding]);
expect((await new StudioProjectCatalog(catalogPath).list())[0]?.projectId)
.toBe(project.projectId);
});

it("preserves separate legacy project identities when Windows roots become ambiguous", async () => {
const { catalogPath } = await fixture();
const catalog = new StudioProjectCatalog(catalogPath);
const first = await catalog.create("First legacy project");
const second = await catalog.create("Second legacy project");
await catalog.addRootBinding(first.projectId, "C:\\Work\\Project");
await catalog.addRootBinding(second.projectId, "D:\\Work\\Project");
const raw = JSON.parse(await fs.readFile(catalogPath, "utf8"));
raw.projects.find((entry: { projectId: string }) => entry.projectId === second.projectId)
.rootBindings[0].localRootRef = "c:\\work\\project";
await fs.writeFile(catalogPath, JSON.stringify(raw));

const restarted = new StudioProjectCatalog(catalogPath);
expect((await restarted.list()).map(({ projectId }) => projectId).sort())
.toEqual([first.projectId, second.projectId].sort());
expect(await restarted.resolveIdentityForPath("C:\\Work\\Project\\src")).toBeNull();
const result = await restarted.reconcile([
{ workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" },
{ workspaceKey: "unrelated", cwd: "/unrelated-project" },
]);
expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "ambiguous"))
.toEqual({ workspaceKey: "ambiguous", cwd: "C:\\Work\\Project" });
expect(result.workspaceScopes.find(({ workspaceKey }) => workspaceKey === "unrelated")?.projectId)
.toMatch(/^project_/);
expect(await restarted.resolve(first.projectId)).not.toBeNull();
expect(await restarted.resolve(second.projectId)).not.toBeNull();
});

it("keeps project identity across a root move and an additional repository binding", async () => {
Expand Down Expand Up @@ -209,17 +345,13 @@ describe("StudioProjectCatalog", () => {
() => new Date(),
delayedHooks,
);
const winner = new StudioProjectCatalog(
catalogPath,
() => new Date(),
{
isPidAlive: (pid) => pid === process.pid,
afterLockAcquired: async () => {
winnerAcquired.resolve();
await releaseWinner.promise;
},
const winner = new StudioProjectCatalog(catalogPath, () => new Date(), {
isPidAlive: (pid) => pid === process.pid,
afterLockAcquired: async () => {
winnerAcquired.resolve();
await releaseWinner.promise;
},
);
});

const writeB = delayedB.create("Writer B");
const writeC = delayedC.create("Writer C");
Expand Down Expand Up @@ -276,9 +408,9 @@ describe("StudioProjectCatalog", () => {

await Promise.all([liveWrite, waiterWrite]);
expect(
(await new StudioProjectCatalog(catalogPath).list()).map(
(project) => project.displayName,
).sort(),
(await new StudioProjectCatalog(catalogPath).list())
.map((project) => project.displayName)
.sort(),
).toEqual(["Patient writer", "Slow live writer"]);
expect(
(await fs.readdir(path.dirname(catalogPath))).filter((entry) =>
Expand Down
Loading
Loading