diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5c713ff2be7..64044b59c1e 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,10 +6,16 @@ import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentOrchestrationHttpApi, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; @@ -22,6 +28,7 @@ import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; import * as ServerConfig from "./config.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; @@ -460,6 +467,63 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("force removes projects that still contain threads", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-projects-force-remove-workspace-"), + ); + + yield* runCliWithRuntime(["project", "add", workspaceRoot, "--base-dir", baseDir]); + const afterAdd = yield* readPersistedSnapshot(baseDir); + const project = afterAdd.projects.find( + (candidate) => candidate.workspaceRoot === workspaceRoot && candidate.deletedAt === null, + ); + assert.isTrue(project !== undefined); + + const config = yield* makeCliTestServerConfig(baseDir); + yield* Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-cli-force-remove-thread"), + threadId: ThreadId.make("thread-cli-force-remove"), + projectId: project!.id, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); + + yield* runCliWithRuntime([ + "project", + "remove", + project!.id, + "--force", + "--base-dir", + baseDir, + ]); + const afterRemove = yield* readPersistedSnapshot(baseDir); + assert.isTrue( + (afterRemove.projects.find((candidate) => candidate.id === project!.id)?.deletedAt ?? + null) !== null, + ); + assert.isTrue( + (afterRemove.threads.find((thread) => thread.id === "thread-cli-force-remove")?.deletedAt ?? + null) !== null, + ); + }), + ); + it.effect("routes project commands through a running server when runtime state is present", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 710d39c4c29..25733a5e35b 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -493,6 +493,10 @@ const projectRemoveCommand = Command.make("remove", { project: Argument.string("project").pipe( Argument.withDescription("Project id or workspace root to remove."), ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Delete the project and all of its threads."), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Remove a project."), Command.withHandler((flags) => @@ -515,6 +519,7 @@ const projectRemoveCommand = Command.make("remove", { type: "project.delete", commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, + force: flags.force, }); return `Removed project ${project.id} (${project.title}).`; }),