Skip to content
Open
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 @@ -188,6 +188,7 @@ function makeHarness(options: HarnessOptions = {}) {
: Option.none(),
),
getByWorkspaceRoot: () => Effect.succeedSome(project),
whileWorkspaceUnowned: () => Effect.die("unused"),
snapshot: Effect.die("unused"),
getShell: () => Effect.die("unused"),
listShells: () => Effect.die("unused"),
Expand Down
128 changes: 97 additions & 31 deletions apps/server/src/project/ManagedProjectFolders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,38 +433,61 @@ it.effect("keeps a named project and reports why when Git cannot commit", () =>
),
);

it.effect("keeps a folder that another project owns when the create conflicts", () =>
withScratch(({ baseDir }) =>
withGitEnv(
TEST_IDENTITY,
Effect.gen(function* () {
const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
const projects = yield* ProjectService.ProjectService;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const taken = path.join(folders.namedProjectsRoot, "taken");
yield* projects.create({
commandId: CommandId.make("command:owner"),
projectId: ProjectId.make("project:owner"),
title: "Owner",
workspaceRoot: taken,
createWorkspaceRootIfMissing: true,
});
yield* fileSystem.remove(taken, { recursive: true });

const failure = yield* Effect.flip(folders.createNamedProject({ name: "Taken" }));

assert.equal(failure._tag, "NamedProjectCreateError");
assert.equal(failure.message, "Failed to create the project.");
assert.isTrue(yield* fileSystem.exists(path.join(taken, "README.md")));
const owner = yield* projects.getByWorkspaceRoot(taken);
assert.equal(Option.getOrThrow(owner).id, ProjectId.make("project:owner"));
assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), [
"taken",
]);
}),
it.effect.each([false, true])(
"keeps a conflicted folder even if its owner was deleted: %s",
(deleted) =>
withScratch(
({ baseDir }) =>
withGitEnv(
TEST_IDENTITY,
Effect.gen(function* () {
const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
const projects = yield* ProjectService.ProjectService;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const taken = path.join(folders.namedProjectsRoot, "taken");
yield* projects.create({
commandId: CommandId.make("command:owner"),
projectId: ProjectId.make("project:owner"),
title: "Owner",
workspaceRoot: taken,
createWorkspaceRootIfMissing: true,
});
yield* fileSystem.remove(taken, { recursive: true });

const failure = yield* Effect.flip(folders.createNamedProject({ name: "Taken" }));

assert.equal(failure._tag, "NamedProjectCreateError");
assert.equal(failure.message, "Failed to create the project.");
assert.isTrue(yield* fileSystem.exists(path.join(taken, "README.md")));
const owner = yield* projects.getByWorkspaceRoot(taken);
if (deleted) assert.isTrue(Option.isNone(owner));
else assert.equal(Option.getOrThrow(owner).id, ProjectId.make("project:owner"));
assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), [
"taken",
]);
}),
),
{
projects: (real) =>
ProjectService.ProjectService.of({
...real,
create: (input) =>
real.create(input).pipe(
Effect.tapError((error) =>
deleted && error._tag === "ProjectConflictError"
? real
.delete({
commandId: CommandId.make("delete-conflicting-owner"),
projectId: error.conflictingProjectId,
})
.pipe(Effect.asVoid)
: Effect.void,
),
),
}),
},
),
),
);

it.effect("removes the folder when the project create is rejected for another reason", () =>
Expand Down Expand Up @@ -533,3 +556,46 @@ it.effect("removes the folder when the create is cancelled before the project ex
);
}),
);

it.effect("keeps a folder another project claims while scaffolding is cancelled", () =>
Effect.gen(function* () {
const scaffolding = yield* Deferred.make<void>();
yield* withScratch(
() =>
Effect.gen(function* () {
const folders = yield* ManagedProjectFolders.ManagedProjectFolders;
const projects = yield* ProjectService.ProjectService;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = path.join(folders.namedProjectsRoot, "claimed");
const fiber = yield* folders
.createNamedProject({ name: "Claimed" })
.pipe(Effect.forkChild);
yield* Deferred.await(scaffolding);
const owner = yield* projects.create({
commandId: CommandId.make("command:claimed-owner"),
projectId: ProjectId.make("project:claimed-owner"),
title: "Claimed owner",
workspaceRoot,
});
const ownerFile = path.join(workspaceRoot, "owner.txt");
yield* fileSystem.writeFileString(ownerFile, "keep\n");

yield* Fiber.interrupt(fiber);

assert.isTrue(yield* fileSystem.exists(ownerFile));
assert.equal(
Option.getOrThrow(yield* projects.getByWorkspaceRoot(workspaceRoot)).id,
owner.id,
);
}),
{
git: Layer.mock(GitVcsDriver.GitVcsDriver)({
readConfigValue: () => Effect.succeed(null),
execute: () =>
Deferred.succeed(scaffolding, undefined).pipe(Effect.andThen(Effect.never)),
}),
},
);
}),
);
15 changes: 7 additions & 8 deletions apps/server/src/project/ManagedProjectFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,14 +423,19 @@ const make = Effect.gen(function* () {
)(function* (input) {
const workspaceRoot = yield* claimNamedFolder(input.name);
const removeFolder = fileSystem.remove(workspaceRoot, { recursive: true }).pipe(Effect.ignore);
// Another create can register the claimed path while scaffolding runs.
// A failed ownership lookup keeps data rather than risking its deletion.
const removeFolderIfUnowned = projects
.whileWorkspaceUnowned(workspaceRoot, removeFolder)
.pipe(Effect.ignore);
const prepared = yield* Effect.all([
scaffoldRepository(workspaceRoot, input.name).pipe(
Effect.mapError((cause) => new NamedProjectFolderError({ folder: workspaceRoot, cause })),
),
crypto
.randomUUIDv4()
.pipe(Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause }))),
]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolder)));
]).pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : removeFolderIfUnowned)));
const [commitError, id] = prepared;
const project = yield* projects
.create({
Expand All @@ -440,14 +445,8 @@ const make = Effect.gen(function* () {
workspaceRoot,
})
.pipe(
// Re-check ownership before deleting rejected creations; interruption after commit or conflicts can leave an owner.
Effect.tapError((error) =>
error._tag === "ProjectConflictError"
? Effect.void
: projects.getByWorkspaceRoot(workspaceRoot).pipe(
Effect.flatMap((owner) => (Option.isNone(owner) ? removeFolder : Effect.void)),
Effect.ignore,
),
error._tag === "ProjectConflictError" ? Effect.void : removeFolderIfUnowned,
),
Effect.mapError((cause) => new NamedProjectCreateError({ workspaceRoot, cause })),
);
Expand Down
94 changes: 94 additions & 0 deletions apps/server/src/project/ProjectService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,3 +738,97 @@ it.effect("rejects an update that waited on the lock while its project was delet
);
}).pipe(Effect.provide(layerProjectServiceDependencies)),
);

it.effect("serializes unowned-folder cleanup with a project registration already committing", () =>
Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
const firstReachedCommit = yield* Deferred.make<void>();
const releaseFirst = yield* Deferred.make<void>();
const gatedSink = EventSink.EventSinkV2.of({
...eventSink,
commitProjectCommand: (input) =>
input.projectId === "project:race:first"
? Deferred.succeed(firstReachedCommit, undefined).pipe(
Effect.andThen(Deferred.await(releaseFirst)),
Effect.andThen(eventSink.commitProjectCommand(input)),
)
: eventSink.commitProjectCommand(input),
});
const service = yield* ProjectService.make.pipe(
Effect.provideService(EventSink.EventSinkV2, gatedSink),
);
const claim = (name: string) =>
service.create({
commandId: CommandId.make(`command:race:${name}`),
projectId: ProjectId.make(`project:race:${name}`),
title: name,
workspaceRoot: "/work/race",
});
const first = yield* claim("first").pipe(Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(firstReachedCommit);
let removed = false;
const second = yield* service
.whileWorkspaceUnowned(
"/work/race/",
Effect.sync(() => {
removed = true;
}),
)
.pipe(Effect.forkChild({ startImmediately: true }));
assert.isUndefined(second.pollUnsafe());
yield* Deferred.succeed(releaseFirst, undefined);
assert.equal((yield* Fiber.join(first)).id, "project:race:first");
yield* Fiber.join(second);
assert.isFalse(removed);
}).pipe(Effect.provide(layerProjectServiceDependencies)),
);

it.effect("rejects registration when cleanup removes the folder while it waits for the lock", () =>
Effect.gen(function* () {
const cleanupStarted = yield* Deferred.make<void>();
const releaseCleanup = yield* Deferred.make<void>();
let exists = true;
const service = yield* ProjectService.make.pipe(
Effect.provideService(WorkspacePaths.WorkspacePaths, {
normalizeWorkspaceRoot: (workspaceRoot) =>
exists
? Effect.succeed(workspaceRoot)
: Effect.fail(
new WorkspacePaths.WorkspaceRootNotExistsError({
workspaceRoot,
normalizedWorkspaceRoot: workspaceRoot,
}),
),
resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) =>
Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }),
}),
);
const cleanup = yield* service
.whileWorkspaceUnowned(
"/work/removed",
Deferred.succeed(cleanupStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseCleanup)),
Effect.andThen(
Effect.sync(() => {
exists = false;
}),
),
),
)
.pipe(Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(cleanupStarted);
const registration = yield* service
.create({
commandId: CommandId.make("command:cleanup-race:create"),
projectId: ProjectId.make("project:cleanup-race"),
title: "Removed",
workspaceRoot: "/work/removed",
})
.pipe(Effect.flip, Effect.forkChild({ startImmediately: true }));
assert.isUndefined(registration.pollUnsafe());
yield* Deferred.succeed(releaseCleanup, undefined);
yield* Fiber.join(cleanup);
assert.instanceOf(yield* Fiber.join(registration), ProjectService.ProjectOperationError);
assert.isTrue(Option.isNone(yield* service.getById(ProjectId.make("project:cleanup-race"))));
}).pipe(Effect.provide(layerProjectServiceDependencies)),
);
29 changes: 29 additions & 0 deletions apps/server/src/project/ProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export class ProjectService extends Context.Service<
workspaceRoot: string,
options?: { readonly includeDeleted?: boolean },
) => Effect.Effect<Option.Option<Project>, ProjectOperationError>;
readonly whileWorkspaceUnowned: <E, R>(
workspaceRoot: string,
effect: Effect.Effect<void, E, R>,
) => Effect.Effect<void, E | ProjectOperationError, R>;
readonly snapshot: Effect.Effect<ProjectSnapshot, ProjectOperationError>;
readonly getShell: (
projectId: ProjectId,
Expand Down Expand Up @@ -217,6 +221,8 @@ export const make = Effect.gen(function* () {
new ProjectOperationError({ operation: "dispatch-project-command", projectId, cause });
const workspaceRoot = command.type === "project.delete" ? undefined : command.workspaceRoot;
const planAndCommit = Effect.gen(function* () {
// Cleanup can remove an unowned folder while registration waits for the workspace lock.
if (workspaceRoot !== undefined) yield* normalizeWorkspaceRoot({ workspaceRoot });
const project = Option.getOrUndefined(yield* readRow(projectId, { includeDeleted: true }));
const workspaceOwner =
workspaceRoot === undefined
Expand Down Expand Up @@ -325,6 +331,28 @@ export const make = Effect.gen(function* () {
return Option.isNone(row) ? Option.none() : Option.some(yield* hydrate(row.value));
});

const whileWorkspaceUnowned: ProjectService["Service"]["whileWorkspaceUnowned"] = Effect.fn(
"ProjectService.whileWorkspaceUnowned",
)(function* <E, R>(workspaceRoot: string, effect: Effect.Effect<void, E, R>) {
const normalized = yield* normalizeWorkspaceRoot({ workspaceRoot });
yield* workspaceLocks.withLock(
normalized,
Effect.gen(function* () {
const owner = yield* projects.findActiveByWorkspaceRoot(normalized).pipe(
Effect.mapError(
(cause) =>
new ProjectOperationError({
operation: "read-project",
workspaceRoot: normalized,
cause,
}),
),
);
if (Option.isNone(owner)) yield* effect;
}),
);
});

const create: ProjectService["Service"]["create"] = Effect.fn("ProjectService.create")(
function* (input) {
const workspaceRoot = yield* normalizeWorkspaceRoot({
Expand Down Expand Up @@ -564,6 +592,7 @@ export const make = Effect.gen(function* () {
delete: deleteProject,
getById,
getByWorkspaceRoot,
whileWorkspaceUnowned,
snapshot,
getShell,
listShells,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
update: unused,
delete: unused,
getByWorkspaceRoot: unused,
whileWorkspaceUnowned: unused,
snapshot: Effect.succeed({ projects: [] } as never),
getShell: unused,
listShells: unused,
Expand Down
Loading