443 lines · 17.0 KB · TypeScript
RawDownload
// @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));    }),  );});