// @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths.import * as NodeFS from "node:fs";import * as NodeOS from "node:os";import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices";import { assert, it } from "@effect/vitest";import { CommandId, EnvironmentInternalError, EventId, ProviderInstanceId, ThreadId, type OrchestrationV2AppThread, type ProjectId,} from "@t3tools/contracts";import * as NetService from "@t3tools/shared/Net";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as References from "effect/References";import * as Stream from "effect/Stream";import { Command } from "effect/unstable/cli"; import { cli } from "../bin.ts";import * as ServerConfig from "../config.ts";import { EventSinkV2 } from "../orchestration-v2/EventSink.ts";import * as EventStore from "../orchestration-v2/EventStore.ts";import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";import { OrchestrationV2EventSinkLayerLive, ProjectServiceLayerLive,} from "../orchestration-v2/runtimeLayer.ts";import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";import * as ProjectService from "../project/ProjectService.ts";import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";import { ProjectLiveServerDeclaredResponseError, ProjectLiveServerRequestError, projectCommandErrorFromLiveServerRequest,} from "./project.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(CliRuntimeLayer)); const makeConfig = (baseDir: string) => Effect.gen(function* () { const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); return { logLevel: "Info", traceMinLevel: "Info", traceTimingEnabled: true, traceBatchWindowMs: 200, traceMaxBytes: 10 * 1024 * 1024, traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", mode: "web", port: 0, host: "127.0.0.1", cwd: process.cwd(), baseDir, ...derivedPaths, staticDir: undefined, devUrl: undefined, devAllowedOrigins: [], noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, } satisfies ServerConfig.ServerConfig["Service"]; }); const readProjects = (baseDir: string) => Effect.gen(function* () { const config = yield* makeConfig(baseDir); const layer = ProjectServiceLayerLive.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ProjectFaviconResolver.layer), Layer.provideMerge(T3ProjectFileLoader.layer), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(SqlitePersistenceLayerLive), Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), ); return yield* ProjectService.ProjectService.pipe( Effect.flatMap((projects) => projects.snapshot), Effect.provide(layer), ); }); it("maps declared server failures into structural project command errors", () => { const cause = new EnvironmentInternalError({ code: "internal_error", reason: "access_token_issuance_failed", traceId: "trace-123", }); const error = projectCommandErrorFromLiveServerRequest(cause); assert.instanceOf(error, ProjectLiveServerDeclaredResponseError); assert.strictEqual(error.operation, "callLiveServer"); assert.strictEqual(error.code, "internal_error"); assert.strictEqual(error.traceId, "trace-123"); assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123)."); assert.strictEqual(error.cause, cause);}); it("preserves unexpected server failures without deriving the message from them", () => { const cause = new Error("credential abc123 was rejected"); const error = projectCommandErrorFromLiveServerRequest(cause); assert.instanceOf(error, ProjectLiveServerRequestError); assert.strictEqual(error.operation, "callLiveServer"); assert.strictEqual(error.message, "Failed to call the running server."); assert.strictEqual(error.cause, cause);}); it.effect("adds, renames, and removes projects through the V2 project CLI domain", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-project-cli-")); const workspaceRoot = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3-v2-project-workspace-"), ); yield* runCli(["project", "add", workspaceRoot, "--title", "Alpha", "--base-dir", baseDir]); const added = (yield* readProjects(baseDir)).projects[0]; assert.equal(added?.title, "Alpha"); assert.equal(added?.workspaceRoot, workspaceRoot); yield* runCli(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Beta"); yield* runCli(["project", "remove", added?.id ?? "", "--base-dir", baseDir]); assert.deepEqual((yield* readProjects(baseDir)).projects, []); }).pipe(Effect.provide(NodeServices.layer)),); const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" }); const baseDir = NodePath.join(root, "state"); const workspaceRoot = NodePath.join(root, "workspace"); yield* fs.makeDirectory(workspaceRoot); yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]); const project = (yield* readProjects(baseDir)).projects[0]; assert.isDefined(project); return { baseDir, workspaceRoot, project: project! };}); const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersistenceLayer")( function* (baseDir: string) { const config = yield* makeConfig(baseDir); return Layer.mergeAll( OrchestrationV2EventSinkLayerLive, ProjectionStore.layer, EventStore.layer, ).pipe( Layer.provideMerge(SqlitePersistenceLayerLive), Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), ); },); const seedNativeThreads = Effect.fn("ProjectCliTest.seedNativeThreads")(function* ( baseDir: string, threads: ReadonlyArray<{ readonly id: ThreadId; readonly projectId: ProjectId; readonly archived: boolean; }>,) { const layer = yield* makeThreadPersistenceLayer(baseDir); const createdAt = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z"); const providerInstanceId = ProviderInstanceId.make("codex"); yield* Effect.gen(function* () { const eventSink = yield* EventSinkV2; yield* eventSink.write({ commandId: CommandId.make("project-cli-seed-threads"), events: threads.map(({ id, projectId, archived }) => { const payload: OrchestrationV2AppThread = { createdBy: "user", creationSource: "web", id, projectId, title: id, providerInstanceId, modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, activeProviderThreadId: null, lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, forkedFrom: null, createdAt, updatedAt: createdAt, archivedAt: archived ? createdAt : null, settledOverride: null, settledAt: null, lastVisitedAt: null, deletedAt: null, }; return { id: EventId.make(`project-cli-create-${id}`), type: "thread.created" as const, threadId: id, providerInstanceId, occurredAt: createdAt, payload, }; }), }); }).pipe(Effect.provide(layer));}); const readNativeThreadState = Effect.fn("ProjectCliTest.readNativeThreadState")(function* ( baseDir: string, threadId: ThreadId,) { const layer = yield* makeThreadPersistenceLayer(baseDir); return yield* Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const events = yield* EventStore.EventStoreV2; return { thread: yield* projections.getThread(threadId), events: yield* events.read({ threadId }).pipe(Stream.runCollect), }; }).pipe(Effect.provide(layer));}); it.layer(NodeServices.layer)("project deletion with native V2 threads", (it) => { it.effect.each([ { label: "an active thread", archived: false, missing: false }, { label: "an archived thread", archived: true, missing: false }, { label: "an active thread after its workspace disappears", archived: false, missing: true, }, { label: "an archived thread after its workspace disappears", archived: true, missing: true, }, ])("rejects unforced removal of a project with $label", ({ archived, missing }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); const threadId = ThreadId.make("project-cli-preserved-thread"); yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived }]); const before = yield* readNativeThreadState(baseDir, threadId); if (missing) yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); const error = yield* runCli([ "project", "remove", missing ? workspaceRoot : project.id, "--base-dir", baseDir, ]).pipe( Effect.match({ onFailure: (error) => error, onSuccess: () => assert.fail("Removing a nonempty project must require --force."), }), ); assert.include(error.message, "not empty"); assert.deepEqual( (yield* readProjects(baseDir)).projects.map((entry) => entry.id), [project.id], ); assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before); assert.equal(yield* fs.exists(workspaceRoot), !missing); }), ); it.effect.each(["present", "missing"] as const)( "force-removes active and archived V2 threads with the workspace %s, preserving unrelated projects", (workspace) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); const otherWorkspace = `${workspaceRoot}-other`; yield* fs.makeDirectory(otherWorkspace); yield* runCli(["project", "add", otherWorkspace, "--base-dir", baseDir]); const otherProject = (yield* readProjects(baseDir)).projects.find( (entry) => entry.workspaceRoot === otherWorkspace, ); assert.isDefined(otherProject); const activeId = ThreadId.make("project-cli-deleted-active"); const archivedId = ThreadId.make("project-cli-deleted-archived"); const unrelatedId = ThreadId.make("project-cli-unrelated-thread"); yield* seedNativeThreads(baseDir, [ { id: activeId, projectId: project.id, archived: false }, { id: archivedId, projectId: project.id, archived: true }, { id: unrelatedId, projectId: otherProject!.id, archived: false }, ]); const unrelatedBefore = yield* readNativeThreadState(baseDir, unrelatedId); if (workspace === "missing") { yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); } yield* runCli([ "project", "remove", workspace === "missing" ? workspaceRoot : project.id, "--force", "--base-dir", baseDir, ]); assert.deepEqual( (yield* readProjects(baseDir)).projects.map((entry) => entry.id), [otherProject!.id], ); for (const threadId of [activeId, archivedId]) { const state = yield* readNativeThreadState(baseDir, threadId); assert.isNotNull(state.thread.deletedAt); assert.lengthOf( state.events.filter((record) => record.event.type === "thread.deleted"), 1, ); } assert.deepEqual(yield* readNativeThreadState(baseDir, unrelatedId), unrelatedBefore); assert.equal(yield* fs.exists(workspaceRoot), workspace === "present"); assert.isTrue(yield* fs.exists(otherWorkspace)); }), );}); it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => { it.effect.each(["id", "stored path"] as const)( "removes an empty project by %s after its directory is gone", (identifier) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); yield* runCli([ "project", "remove", identifier === "id" ? project.id : workspaceRoot, "--base-dir", baseDir, ]); assert.deepEqual((yield* readProjects(baseDir)).projects, []); assert.isFalse(yield* fs.exists(workspaceRoot)); }), ); it.effect("renames by ID and stored path after the directory is gone", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); for (const [identifier, title] of [ [project.id, "Renamed by ID"], [workspaceRoot, "Renamed by stored path"], ] as const) { yield* runCli(["project", "rename", identifier, title, "--base-dir", baseDir]); assert.equal((yield* readProjects(baseDir)).projects[0]?.title, title); } assert.isFalse(yield* fs.exists(workspaceRoot)); }), ); it.effect("does not resolve another environment's project ID in an empty database", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); const replacementDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-empty-" }); const error = yield* runCli([ "project", "remove", project.id, "--force", "--base-dir", replacementDir, ]).pipe(Effect.flip); assert.include(error.message, "No active project found"); assert.deepEqual( (yield* readProjects(baseDir)).projects.map((entry) => entry.id), [project.id], ); assert.deepEqual((yield* readProjects(replacementDir)).projects, []); }), ); it.effect("normalizes existing paths without conflating separately registered symlinks", () => Effect.gen(function* () { const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); yield* runCli([ "project", "rename", `${workspaceRoot}${NodePath.sep}.`, "Normalized", "--base-dir", baseDir, ]); assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Normalized"); const aliasPath = `${workspaceRoot}-alias`; NodeFS.symlinkSync(workspaceRoot, aliasPath, "junction"); const unknownAlias = yield* runCli([ "project", "remove", aliasPath, "--base-dir", baseDir, ]).pipe(Effect.flip); assert.include(unknownAlias.message, "No active project found"); yield* runCli(["project", "add", aliasPath, "--base-dir", baseDir]); const added = (yield* readProjects(baseDir)).projects; assert.equal(added.length, 2); const aliasProject = added.find((entry) => entry.workspaceRoot === aliasPath); assert.isDefined(aliasProject); assert.notEqual(aliasProject?.id, project.id); yield* runCli(["project", "remove", `${aliasPath}${NodePath.sep}.`, "--base-dir", baseDir]); assert.deepEqual( (yield* readProjects(baseDir)).projects.map((entry) => entry.id), [project.id], ); assert.isTrue(NodeFS.existsSync(workspaceRoot)); }), );});
// @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import {
CommandId,
EnvironmentInternalError,
EventId,
ProviderInstanceId,
ThreadId,
type OrchestrationV2AppThread,
type ProjectId,
} from "@t3tools/contracts";
import * as NetService from "@t3tools/shared/Net";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as References from "effect/References";
import * as Stream from "effect/Stream";
import { Command } from "effect/unstable/cli";
import { cli } from "../bin.ts";
import * as ServerConfig from "../config.ts";
import { EventSinkV2 } from "../orchestration-v2/EventSink.ts";
import * as EventStore from "../orchestration-v2/EventStore.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import {
OrchestrationV2EventSinkLayerLive,
ProjectServiceLayerLive,
} from "../orchestration-v2/runtimeLayer.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import {
ProjectLiveServerDeclaredResponseError,
ProjectLiveServerRequestError,
projectCommandErrorFromLiveServerRequest,
} from "./project.ts";
const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
const runCli = (args: ReadonlyArray<string>) =>
Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(CliRuntimeLayer));
const makeConfig = (baseDir: string) =>
Effect.gen(function* () {
const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
return {
logLevel: "Info",
traceMinLevel: "Info",
traceTimingEnabled: true,
traceBatchWindowMs: 200,
traceMaxBytes: 10 * 1024 * 1024,
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
mode: "web",
port: 0,
host: "127.0.0.1",
cwd: process.cwd(),
baseDir,
...derivedPaths,
staticDir: undefined,
devUrl: undefined,
devAllowedOrigins: [],
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
tailscaleServeEnabled: false,
tailscaleServePort: 443,
} satisfies ServerConfig.ServerConfig["Service"];
});
const readProjects = (baseDir: string) =>
Effect.gen(function* () {
const config = yield* makeConfig(baseDir);
const layer = ProjectServiceLayerLive.pipe(
Layer.provideMerge(ProjectEnrichmentService.layer),
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(ProjectFaviconResolver.layer),
Layer.provideMerge(T3ProjectFileLoader.layer),
Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
Layer.provideMerge(NodeServices.layer),
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)),
);
return yield* ProjectService.ProjectService.pipe(
Effect.flatMap((projects) => projects.snapshot),
Effect.provide(layer),
);
});
it("maps declared server failures into structural project command errors", () => {
const cause = new EnvironmentInternalError({
code: "internal_error",
reason: "access_token_issuance_failed",
traceId: "trace-123",
});
const error = projectCommandErrorFromLiveServerRequest(cause);
assert.instanceOf(error, ProjectLiveServerDeclaredResponseError);
assert.strictEqual(error.operation, "callLiveServer");
assert.strictEqual(error.code, "internal_error");
assert.strictEqual(error.traceId, "trace-123");
assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123).");
assert.strictEqual(error.cause, cause);
});
it("preserves unexpected server failures without deriving the message from them", () => {
const cause = new Error("credential abc123 was rejected");
const error = projectCommandErrorFromLiveServerRequest(cause);
assert.instanceOf(error, ProjectLiveServerRequestError);
assert.strictEqual(error.operation, "callLiveServer");
assert.strictEqual(error.message, "Failed to call the running server.");
assert.strictEqual(error.cause, cause);
});
it.effect("adds, renames, and removes projects through the V2 project CLI domain", () =>
Effect.gen(function* () {
const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-project-cli-"));
const workspaceRoot = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3-v2-project-workspace-"),
);
yield* runCli(["project", "add", workspaceRoot, "--title", "Alpha", "--base-dir", baseDir]);
const added = (yield* readProjects(baseDir)).projects[0];
assert.equal(added?.title, "Alpha");
assert.equal(added?.workspaceRoot, workspaceRoot);
yield* runCli(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Beta");
yield* runCli(["project", "remove", added?.id ?? "", "--base-dir", baseDir]);
assert.deepEqual((yield* readProjects(baseDir)).projects, []);
}).pipe(Effect.provide(NodeServices.layer)),
);
const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" });
const baseDir = NodePath.join(root, "state");
const workspaceRoot = NodePath.join(root, "workspace");
yield* fs.makeDirectory(workspaceRoot);
yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]);
const project = (yield* readProjects(baseDir)).projects[0];
assert.isDefined(project);
return { baseDir, workspaceRoot, project: project! };
});
const makeThreadPersistenceLayer = Effect.fn("ProjectCliTest.makeThreadPersistenceLayer")(
function* (baseDir: string) {
const config = yield* makeConfig(baseDir);
return Layer.mergeAll(
OrchestrationV2EventSinkLayerLive,
ProjectionStore.layer,
EventStore.layer,
).pipe(
Layer.provideMerge(SqlitePersistenceLayerLive),
Layer.provideMerge(NodeServices.layer),
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)),
);
},
);
const seedNativeThreads = Effect.fn("ProjectCliTest.seedNativeThreads")(function* (
baseDir: string,
threads: ReadonlyArray<{
readonly id: ThreadId;
readonly projectId: ProjectId;
readonly archived: boolean;
}>,
) {
const layer = yield* makeThreadPersistenceLayer(baseDir);
const createdAt = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z");
const providerInstanceId = ProviderInstanceId.make("codex");
yield* Effect.gen(function* () {
const eventSink = yield* EventSinkV2;
yield* eventSink.write({
commandId: CommandId.make("project-cli-seed-threads"),
events: threads.map(({ id, projectId, archived }) => {
const payload: OrchestrationV2AppThread = {
createdBy: "user",
creationSource: "web",
id,
projectId,
title: id,
providerInstanceId,
modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
activeProviderThreadId: null,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
forkedFrom: null,
createdAt,
updatedAt: createdAt,
archivedAt: archived ? createdAt : null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
};
return {
id: EventId.make(`project-cli-create-${id}`),
type: "thread.created" as const,
threadId: id,
providerInstanceId,
occurredAt: createdAt,
payload,
};
}),
});
}).pipe(Effect.provide(layer));
});
const readNativeThreadState = Effect.fn("ProjectCliTest.readNativeThreadState")(function* (
baseDir: string,
threadId: ThreadId,
) {
const layer = yield* makeThreadPersistenceLayer(baseDir);
return yield* Effect.gen(function* () {
const projections = yield* ProjectionStore.ProjectionStoreV2;
const events = yield* EventStore.EventStoreV2;
return {
thread: yield* projections.getThread(threadId),
events: yield* events.read({ threadId }).pipe(Stream.runCollect),
};
}).pipe(Effect.provide(layer));
});
it.layer(NodeServices.layer)("project deletion with native V2 threads", (it) => {
it.effect.each([
{ label: "an active thread", archived: false, missing: false },
{ label: "an archived thread", archived: true, missing: false },
{
label: "an active thread after its workspace disappears",
archived: false,
missing: true,
},
{
label: "an archived thread after its workspace disappears",
archived: true,
missing: true,
},
])("rejects unforced removal of a project with $label", ({ archived, missing }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
const threadId = ThreadId.make("project-cli-preserved-thread");
yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived }]);
const before = yield* readNativeThreadState(baseDir, threadId);
if (missing) yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
const error = yield* runCli([
"project",
"remove",
missing ? workspaceRoot : project.id,
"--base-dir",
baseDir,
]).pipe(
Effect.match({
onFailure: (error) => error,
onSuccess: () => assert.fail("Removing a nonempty project must require --force."),
}),
);
assert.include(error.message, "not empty");
assert.deepEqual(
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
[project.id],
);
assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before);
assert.equal(yield* fs.exists(workspaceRoot), !missing);
}),
);
it.effect.each(["present", "missing"] as const)(
"force-removes active and archived V2 threads with the workspace %s, preserving unrelated projects",
(workspace) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
const otherWorkspace = `${workspaceRoot}-other`;
yield* fs.makeDirectory(otherWorkspace);
yield* runCli(["project", "add", otherWorkspace, "--base-dir", baseDir]);
const otherProject = (yield* readProjects(baseDir)).projects.find(
(entry) => entry.workspaceRoot === otherWorkspace,
);
assert.isDefined(otherProject);
const activeId = ThreadId.make("project-cli-deleted-active");
const archivedId = ThreadId.make("project-cli-deleted-archived");
const unrelatedId = ThreadId.make("project-cli-unrelated-thread");
yield* seedNativeThreads(baseDir, [
{ id: activeId, projectId: project.id, archived: false },
{ id: archivedId, projectId: project.id, archived: true },
{ id: unrelatedId, projectId: otherProject!.id, archived: false },
]);
const unrelatedBefore = yield* readNativeThreadState(baseDir, unrelatedId);
if (workspace === "missing") {
yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
}
yield* runCli([
"project",
"remove",
workspace === "missing" ? workspaceRoot : project.id,
"--force",
"--base-dir",
baseDir,
]);
assert.deepEqual(
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
[otherProject!.id],
);
for (const threadId of [activeId, archivedId]) {
const state = yield* readNativeThreadState(baseDir, threadId);
assert.isNotNull(state.thread.deletedAt);
assert.lengthOf(
state.events.filter((record) => record.event.type === "thread.deleted"),
1,
);
}
assert.deepEqual(yield* readNativeThreadState(baseDir, unrelatedId), unrelatedBefore);
assert.equal(yield* fs.exists(workspaceRoot), workspace === "present");
assert.isTrue(yield* fs.exists(otherWorkspace));
}),
);
});
it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) => {
it.effect.each(["id", "stored path"] as const)(
"removes an empty project by %s after its directory is gone",
(identifier) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
yield* runCli([
"project",
"remove",
identifier === "id" ? project.id : workspaceRoot,
"--base-dir",
baseDir,
]);
assert.deepEqual((yield* readProjects(baseDir)).projects, []);
assert.isFalse(yield* fs.exists(workspaceRoot));
}),
);
it.effect("renames by ID and stored path after the directory is gone", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
for (const [identifier, title] of [
[project.id, "Renamed by ID"],
[workspaceRoot, "Renamed by stored path"],
] as const) {
yield* runCli(["project", "rename", identifier, title, "--base-dir", baseDir]);
assert.equal((yield* readProjects(baseDir)).projects[0]?.title, title);
}
assert.isFalse(yield* fs.exists(workspaceRoot));
}),
);
it.effect("does not resolve another environment's project ID in an empty database", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`);
const replacementDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-empty-" });
const error = yield* runCli([
"project",
"remove",
project.id,
"--force",
"--base-dir",
replacementDir,
]).pipe(Effect.flip);
assert.include(error.message, "No active project found");
assert.deepEqual(
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
[project.id],
);
assert.deepEqual((yield* readProjects(replacementDir)).projects, []);
}),
);
it.effect("normalizes existing paths without conflating separately registered symlinks", () =>
Effect.gen(function* () {
const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture();
yield* runCli([
"project",
"rename",
`${workspaceRoot}${NodePath.sep}.`,
"Normalized",
"--base-dir",
baseDir,
]);
assert.equal((yield* readProjects(baseDir)).projects[0]?.title, "Normalized");
const aliasPath = `${workspaceRoot}-alias`;
NodeFS.symlinkSync(workspaceRoot, aliasPath, "junction");
const unknownAlias = yield* runCli([
"project",
"remove",
aliasPath,
"--base-dir",
baseDir,
]).pipe(Effect.flip);
assert.include(unknownAlias.message, "No active project found");
yield* runCli(["project", "add", aliasPath, "--base-dir", baseDir]);
const added = (yield* readProjects(baseDir)).projects;
assert.equal(added.length, 2);
const aliasProject = added.find((entry) => entry.workspaceRoot === aliasPath);
assert.isDefined(aliasProject);
assert.notEqual(aliasProject?.id, project.id);
yield* runCli(["project", "remove", `${aliasPath}${NodePath.sep}.`, "--base-dir", baseDir]);
assert.deepEqual(
(yield* readProjects(baseDir)).projects.map((entry) => entry.id),
[project.id],
);
assert.isTrue(NodeFS.existsSync(workspaceRoot));
}),
);
});