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
129 changes: 100 additions & 29 deletions apps/server/src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,19 @@ type ProjectMutationTarget = {
type ProjectCommandExecutionMode = "live" | "offline";
type ProjectCliDispatchCommand = Extract<
ClientOrchestrationCommand,
{ type: "project.create" | "project.meta.update" | "project.delete" }
{
type: "project.create" | "project.meta.update" | "project.delete" | "project.merge";
}
>;

export type ProjectMutationHandlerInput = {
readonly snapshot: OrchestrationReadModel;
readonly dispatch: (
command: ProjectCliDispatchCommand,
) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
readonly mode: ProjectCommandExecutionMode;
};

const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);

export class ProjectCommandIdGenerationError extends Schema.TaggedErrorClass<ProjectCommandIdGenerationError>()(
Expand Down Expand Up @@ -155,6 +165,18 @@ export class ProjectAlreadyExistsError extends Schema.TaggedErrorClass<ProjectAl
}
}

export class ProjectMergeSameProjectError extends Schema.TaggedErrorClass<ProjectMergeSameProjectError>()(
"ProjectMergeSameProjectError",
{
operation: Schema.Literal("mergeProject"),
projectId: ProjectId,
},
) {
override get message(): string {
return `Cannot merge project '${this.projectId}' into itself.`;
}
}

export const ProjectCommandError = Schema.Union([
ProjectCommandIdGenerationError,
ProjectLiveServerDeclaredResponseError,
Expand All @@ -164,6 +186,7 @@ export const ProjectCommandError = Schema.Union([
ProjectIdentifierEmptyError,
ProjectNotFoundError,
ProjectAlreadyExistsError,
ProjectMergeSameProjectError,
]);
export type ProjectCommandError = typeof ProjectCommandError.Type;

Expand Down Expand Up @@ -337,8 +360,8 @@ const dispatchLiveOrchestrationCommand = (

const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
// Project commands only read the project list, so use the lightweight
// command read model instead of hydrating every thread body in the database.
// The command read model includes lightweight thread rows without hydrating
// message, activity, or checkpoint bodies, which is sufficient for merge.
return yield* projectionSnapshotQuery.getCommandReadModel();
});

Expand Down Expand Up @@ -376,13 +399,9 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu

const runProjectMutation = Effect.fn("runProjectMutation")(function* (
flags: CliAuthLocationFlags,
run: (input: {
readonly snapshot: OrchestrationReadModel;
readonly dispatch: (
command: ProjectCliDispatchCommand,
) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
readonly mode: ProjectCommandExecutionMode;
}) => Effect.Effect<
run: (
input: ProjectMutationHandlerInput,
) => Effect.Effect<
string,
Error,
| Crypto.Crypto
Expand Down Expand Up @@ -455,12 +474,7 @@ const projectAddCommand = Command.make("add", {
Effect.fn("projectAddMutation")(function* ({
snapshot,
dispatch,
}: {
readonly snapshot: OrchestrationReadModel;
readonly dispatch: (
command: ProjectCliDispatchCommand,
) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
}) {
}: ProjectMutationHandlerInput) {
const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
const existingProject = snapshot.projects.find(
(project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
Expand Down Expand Up @@ -507,12 +521,7 @@ const projectRemoveCommand = Command.make("remove", {
Effect.fn("projectRemoveMutation")(function* ({
snapshot,
dispatch,
}: {
readonly snapshot: OrchestrationReadModel;
readonly dispatch: (
command: ProjectCliDispatchCommand,
) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
}) {
}: ProjectMutationHandlerInput) {
const project = yield* findActiveProjectTarget({
snapshot,
identifier: flags.project,
Expand Down Expand Up @@ -543,12 +552,7 @@ const projectRenameCommand = Command.make("rename", {
Effect.fn("projectRenameMutation")(function* ({
snapshot,
dispatch,
}: {
readonly snapshot: OrchestrationReadModel;
readonly dispatch: (
command: ProjectCliDispatchCommand,
) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
}) {
}: ProjectMutationHandlerInput) {
const project = yield* findActiveProjectTarget({
snapshot,
identifier: flags.project,
Expand All @@ -570,7 +574,74 @@ const projectRenameCommand = Command.make("rename", {
),
);

const projectMergeCommand = Command.make("merge", {
...projectLocationFlags,
source: Argument.string("source").pipe(
Argument.withDescription("Source project id or workspace root."),
),
target: Argument.string("target").pipe(
Argument.withDescription("Target project id or workspace root."),
),
allowUnrelatedRoots: Flag.boolean("allow-unrelated-roots").pipe(
Flag.withDescription("Allow moving threads between unrelated workspace roots."),
Flag.withDefault(false),
),
}).pipe(
Command.withDescription("Move a project's threads into another project and delete the source."),
Command.withHandler((flags) =>
runProjectMutation(
flags,
Effect.fn("projectMergeMutation")(function* ({
snapshot,
dispatch,
}: ProjectMutationHandlerInput) {
const source = yield* findActiveProjectTarget({
snapshot,
identifier: flags.source,
});
const target = yield* findActiveProjectTarget({
snapshot,
identifier: flags.target,
});
if (source.id === target.id) {
return yield* new ProjectMergeSameProjectError({
operation: "mergeProject",
projectId: source.id,
});
}

const activeSourceThreads = snapshot.threads.filter(
(thread) => thread.projectId === source.id && thread.deletedAt === null,
);
yield* Console.log(`Threads to move (advisory snapshot: ${activeSourceThreads.length}):`);
if (activeSourceThreads.length === 0) {
yield* Console.log("- (none)");
} else {
yield* Effect.forEach(activeSourceThreads, (thread) => Console.log(`- ${thread.title}`), {
discard: true,
});
}
yield* dispatch({
type: "project.merge",
commandId: CommandId.make(yield* projectCommandUuid),
sourceProjectId: source.id,
targetProjectId: target.id,
allowUnrelatedRoots: flags.allowUnrelatedRoots,
createdAt: DateTime.formatIso(yield* DateTime.now),
});

return `Merged project ${source.id} (${source.title}) into ${target.id} (${target.title}): moved ${activeSourceThreads.length} threads.`;
}),
),
),
);

export const projectCommand = Command.make("project").pipe(
Command.withDescription("Manage projects."),
Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
Command.withSubcommands([
projectAddCommand,
projectRemoveCommand,
projectRenameCommand,
projectMergeCommand,
]),
);
58 changes: 58 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,64 @@ describe("OrchestrationEngine", () => {
await system.dispose();
});

it("atomically merges projects and persists the moved thread project", async () => {
const createdAt = now();
const system = await createOrchestrationSystem();
const { engine } = system;

for (const [projectId, title, workspaceRoot] of [
[asProjectId("project-move-source"), "Source", "/tmp/project-move/apps/server"],
[asProjectId("project-move-target"), "Target", "/tmp/project-move"],
] as const) {
await system.run(
engine.dispatch({
type: "project.create",
commandId: CommandId.make(`cmd-${projectId}-create`),
projectId,
title,
workspaceRoot,
createdAt,
}),
);
}
await system.run(
engine.dispatch({
type: "thread.create",
commandId: CommandId.make("cmd-thread-project-move-create"),
threadId: ThreadId.make("thread-project-move"),
projectId: asProjectId("project-move-source"),
title: "Move me",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
branch: null,
worktreePath: null,
createdAt,
}),
);

const moved = await system.run(
engine.dispatch({
type: "project.merge",
commandId: CommandId.make("cmd-project-merge"),
sourceProjectId: asProjectId("project-move-source"),
targetProjectId: asProjectId("project-move-target"),
createdAt,
}),
);
expect(moved.sequence).toBe(5);
const readModel = await system.readModel();
expect(readModel.threads[0]?.projectId).toBe("project-move-target");
expect(
readModel.projects.find((project) => project.id === "project-move-source")?.deletedAt,
).not.toBeNull();

await system.dispose();
});

it("archives and unarchives threads through orchestration commands", async () => {
const system = await createOrchestrationSystem();
const { engine } = system;
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ function commandToAggregateRef(command: OrchestrationCommand): {
aggregateKind: "project",
aggregateId: command.projectId,
};
case "project.merge":
return {
aggregateKind: "project",
aggregateId: command.sourceProjectId,
};
default:
return {
aggregateKind: "thread",
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
return;
}

case "thread.project-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
projectId: event.payload.projectId,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.interaction-mode-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/orchestration/Normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,30 @@ describe("canonicalizeClientCommandTimestamps", () => {
expect(result.createdAt).toBe(serverReceivedAt);
expect(result.bootstrap?.createThread?.createdAt).toBe(serverReceivedAt);
});

it("canonicalizes project merge and thread project-set timestamps", () => {
const commands: ReadonlyArray<ClientOrchestrationCommand> = [
{
type: "project.merge",
commandId: CommandId.make("command-project-merge"),
sourceProjectId: ProjectId.make("project-source"),
targetProjectId: ProjectId.make("project-target"),
createdAt: clientCreatedAt,
},
{
type: "thread.project.set",
commandId: CommandId.make("command-thread-project-set"),
threadId: ThreadId.make("thread-1"),
projectId: ProjectId.make("project-target"),
createdAt: clientCreatedAt,
},
];

for (const command of commands) {
expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual({
...command,
createdAt: serverReceivedAt,
});
}
});
});
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ThreadSettledPayload as ContractsThreadSettledPayloadSchema,
ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
ThreadProjectSetPayload as ContractsThreadProjectSetPayloadSchema,
ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
Expand Down Expand Up @@ -39,6 +40,7 @@ export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema;
export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema;
export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
export const ThreadProjectSetPayload = ContractsThreadProjectSetPayloadSchema;
export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/orchestration/commandInvariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ export function requireProject(input: {
);
}

export function requireProjectNotDeleted(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly projectId: ProjectId;
}): Effect.Effect<OrchestrationProject, OrchestrationCommandInvariantError> {
return requireProject(input).pipe(
Effect.flatMap((project) =>
project.deletedAt === null
? Effect.succeed(project)
: Effect.fail(
invariantError(
input.command.type,
`Project '${input.projectId}' is deleted and cannot handle command '${input.command.type}'.`,
),
),
),
);
}

export function requireProjectAbsent(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
Expand Down Expand Up @@ -113,6 +132,25 @@ export function requireThread(input: {
);
}

export function requireThreadNotDeleted(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
readonly threadId: ThreadId;
}): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
return requireThread(input).pipe(
Effect.flatMap((thread) =>
thread.deletedAt === null
? Effect.succeed(thread)
: Effect.fail(
invariantError(
input.command.type,
`Thread '${input.threadId}' is deleted and cannot handle command '${input.command.type}'.`,
),
),
),
);
}

export function requireThreadArchived(input: {
readonly readModel: OrchestrationReadModel;
readonly command: OrchestrationCommand;
Expand Down
Loading
Loading