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
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ test('new sessions resolve a merged project alias to the surviving project id',
const original = await catalog.register(originalPath);
await rm(originalPath, { recursive: true, force: true });
const duplicate = await catalog.register(cwd);
await catalog.relink(original.id, cwd, async () => {});
await catalog.relinkWithSessions(original.id, cwd);

const resolved = await resolveNewSessionProjectInput(
makeInput(cwd, { projectId: duplicate.id }),
Expand Down
159 changes: 2 additions & 157 deletions apps/desktop/src/main/__tests__/project-management-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createProjectCatalog, createSessionStore } from '@maka/storage';
import { createProjectCatalog } from '@maka/storage';
import { createProjectManagementService } from '../project-management-service.js';

test('project management service owns selection and reversible lifecycle actions', async () => {
Expand All @@ -20,12 +20,6 @@ test('project management service owns selection and reversible lifecycle actions
});
const service = createProjectManagementService({
catalog,
sessions: {
listHeaders: async () => [],
updateHeader: async () => {
throw new Error('No sessions expected');
},
},
chooseDirectory: async () => nextDirectory,
selection: {
currentSelection: async () => ({
Expand Down Expand Up @@ -80,12 +74,6 @@ test('project management service rejects malformed IPC identities before catalog
const base = await mkdtemp(join(tmpdir(), 'maka-project-service-input-'));
const service = createProjectManagementService({
catalog: createProjectCatalog(join(base, 'storage')),
sessions: {
listHeaders: async () => [],
updateHeader: async () => {
throw new Error('No sessions expected');
},
},
chooseDirectory: async () => undefined,
selection: {
currentSelection: async () => ({ projectId: undefined, path: base }),
Expand All @@ -112,12 +100,6 @@ test('project management service resolves a legacy path into one canonical selec
const savedSelections: Array<{ projectId: string | null; projectPath: string }> = [];
const service = createProjectManagementService({
catalog,
sessions: {
listHeaders: async () => [],
updateHeader: async () => {
throw new Error('No sessions expected');
},
},
chooseDirectory: async () => undefined,
selection: {
currentSelection: async () => ({
Expand Down Expand Up @@ -153,12 +135,6 @@ test('project management service persists an explicit no-project selection in ma
const savedSelections: Array<{ projectId: string | null; projectPath: string }> = [];
const service = createProjectManagementService({
catalog: createProjectCatalog(join(base, 'storage')),
sessions: {
listHeaders: async () => [],
updateHeader: async () => {
throw new Error('No sessions expected');
},
},
chooseDirectory: async () => undefined,
selection: {
currentSelection: async () => ({
Expand Down Expand Up @@ -208,12 +184,6 @@ test('archiving the current project resolves fallback or no-project inside main'
};
const service = createProjectManagementService({
catalog,
sessions: {
listHeaders: async () => [],
updateHeader: async () => {
throw new Error('No sessions expected');
},
},
chooseDirectory: async () => undefined,
selection: {
currentSelection: async () => selection,
Expand Down Expand Up @@ -251,128 +221,3 @@ test('archiving the current project resolves fallback or no-project inside main'
await rm(base, { recursive: true, force: true });
}
});

test('relinking merges a project that was accidentally added from its new path', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-project-service-merge-'));
const oldPath = join(base, 'old-location');
const newPath = join(base, 'new-location');
const secondPath = join(base, 'second-location');
const storage = join(base, 'storage');
await mkdir(oldPath);
let nextDirectory: string | undefined = oldPath;
let nextId = 0;
const catalog = createProjectCatalog(storage, {
now: () => 1_000,
createId: () => `project-${++nextId}`,
});
const sessions = createSessionStore(storage);
let failUpdateNumber: number | undefined;
let updateCount = 0;
const service = createProjectManagementService({
catalog,
sessions: {
listHeaders: () => sessions.listHeaders(),
updateHeader: async (sessionId, patch) => {
updateCount += 1;
if (updateCount === failUpdateNumber) {
throw new Error('injected session reassignment failure');
}
return sessions.updateHeader(sessionId, patch);
},
},
chooseDirectory: async () => nextDirectory,
selection: {
currentSelection: async () => ({
projectId: undefined,
path: nextDirectory ? await realpath(nextDirectory) : base,
}),
setSelection: () => {},
},
});

try {
const original = await service.add();
assert.equal(original.ok, true);
if (!original.ok) throw new Error('Expected original project');
await service.rename(original.project.id, 'Original name');
await rename(oldPath, newPath);

nextDirectory = newPath;
const duplicate = await service.add();
assert.equal(duplicate.ok, true);
if (!duplicate.ok) throw new Error('Expected duplicate project');
const oldSession = await sessions.create(
makeSessionInput(oldPath, original.project.id, 'Old history'),
);
const newSession = await sessions.create(
makeSessionInput(newPath, duplicate.project.id, 'New history'),
);

failUpdateNumber = 2;
await assert.rejects(
() => service.relink(original.project.id),
/injected session reassignment failure/,
);
assert.deepEqual(
(await catalog.list()).map((project) => project.id).sort(),
[original.project.id, duplicate.project.id].sort(),
);

failUpdateNumber = undefined;
updateCount = 0;
const merged = await service.relink(original.project.id);

assert.equal(merged.ok, true);
if (!merged.ok) throw new Error('Expected merged project');
assert.equal(merged.project.id, original.project.id);
assert.equal(merged.project.name, 'Original name');
assert.equal(merged.project.preferredPath, await realpath(newPath));
assert.deepEqual(
(await catalog.list()).map((project) => project.id),
[original.project.id],
);
assert.equal(
(await sessions.readHeaderSnapshot(oldSession.id)).projectId,
original.project.id,
);
assert.equal(
(await sessions.readHeaderSnapshot(oldSession.id)).cwd,
await realpath(newPath),
);
assert.equal(
(await sessions.readHeaderSnapshot(newSession.id)).projectId,
original.project.id,
);

const lateAliasSession = await sessions.create(
makeSessionInput(newPath, duplicate.project.id, 'Late alias history'),
);
await rename(newPath, secondPath);
nextDirectory = secondPath;
const relinkedAgain = await service.relink(original.project.id);

assert.equal(relinkedAgain.ok, true);
const lateAliasHeader = await sessions.readHeaderSnapshot(lateAliasSession.id);
assert.equal(lateAliasHeader.projectId, original.project.id);
assert.equal(
lateAliasHeader.cwd,
await realpath(secondPath),
);
} finally {
await sessions.close?.();
await rm(base, { recursive: true, force: true });
}
});

function makeSessionInput(cwd: string, projectId: string, name: string) {
return {
cwd,
projectId,
backend: 'fake' as const,
llmConnectionSlug: 'fake',
model: 'fake-model',
permissionMode: 'ask' as const,
name,
labels: [],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { RuntimeHostOperationError } from '@maka/runtime-host/client';
import { isProjectPathMismatchError } from '@maka/storage';
import { createRuntimeHostProjectCatalog } from '../runtime-host-project-catalog.js';

test('Host touch conflicts retain the Project path mismatch contract', async () => {
const catalog = createRuntimeHostProjectCatalog(() =>
({
touchProject: async () => {
throw new RuntimeHostOperationError(
'project.catalog.mutate',
'operation_conflict',
'Path does not belong to project project-1',
);
},
}) as never,
);

await assert.rejects(
() => catalog.touch('project-1', '/workspace/other'),
(error: unknown) => isProjectPathMismatchError(error),
);
});

This file was deleted.

64 changes: 21 additions & 43 deletions apps/desktop/src/main/project-management-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProjectCatalog, ProjectRecord } from '@maka/storage';
import type { ProjectRecord } from '@maka/core';
import type { CurrentProjectSelection } from './project-root-controller.js';

type DirectoryActionResult =
Expand Down Expand Up @@ -29,19 +29,18 @@ export interface ProjectManagementService {
restore(projectId: unknown): Promise<ProjectRecord>;
}

export interface ProjectSessionCatalog {
listHeaders(): Promise<
Array<{ readonly id: string; readonly cwd: string; readonly projectId?: string | null }>
>;
updateHeader(
sessionId: string,
patch: { readonly cwd?: string; readonly projectId?: string | null },
): Promise<unknown>;
export interface ProjectManagementCatalog {
list(): Promise<ProjectRecord[]>;
register(path: string): Promise<ProjectRecord>;
select(projectId: string): Promise<{ project: ProjectRecord; path: string }>;
relink(projectId: string, path: string): Promise<ProjectRecord>;
rename(projectId: string, name: string): Promise<ProjectRecord>;
archive(projectId: string): Promise<ProjectRecord>;
restore(projectId: string): Promise<ProjectRecord>;
}

export function createProjectManagementService(deps: {
catalog: ProjectCatalog;
sessions: ProjectSessionCatalog;
catalog: ProjectManagementCatalog;
chooseDirectory(): Promise<string | undefined>;
selection: {
currentSelection(): Promise<CurrentProjectSelection>;
Expand Down Expand Up @@ -112,38 +111,17 @@ export function createProjectManagementService(deps: {
const id = requireProjectId(projectId);
const path = await deps.chooseDirectory();
if (!path) return { ok: false, reason: 'cancelled' };
let selectedProjectWasRelinked = false;
const prepareSessions = async (context: {
projectId: string;
projectAliases: string[];
destinationPath: string;
previousLocations: Array<{ path: string }>;
conflictingProjectId?: string;
conflictingProjectAliases?: string[];
}) => {
const selectedPath = (await deps.selection.currentSelection()).path;
selectedProjectWasRelinked = context.previousLocations.some(
(location) => location.path === selectedPath,
);
const survivingIds = new Set([context.projectId, ...context.projectAliases]);
const conflictingIds = new Set([
...(context.conflictingProjectId ? [context.conflictingProjectId] : []),
...(context.conflictingProjectAliases ?? []),
]);
for (const header of await deps.sessions.listHeaders()) {
if (header.projectId && survivingIds.has(header.projectId)) {
await deps.sessions.updateHeader(header.id, {
cwd: context.destinationPath,
...(header.projectId !== context.projectId
? { projectId: context.projectId }
: {}),
});
} else if (header.projectId && conflictingIds.has(header.projectId)) {
await deps.sessions.updateHeader(header.id, { projectId: context.projectId });
}
}
};
const project = await deps.catalog.relink(id, path, prepareSessions);
const [selection, projects] = await Promise.all([
deps.selection.currentSelection(),
deps.catalog.list(),
]);
const previous = projects.find(
(project) => project.id === id || project.aliases?.includes(id),
);
const selectedProjectWasRelinked = previous?.locations.some(
(location) => location.path === selection.path,
);
const project = await deps.catalog.relink(id, path);
if (selectedProjectWasRelinked && project.preferredPath) {
deps.selection.setSelection(project.id, project.preferredPath);
}
Expand Down
Loading
Loading