import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ServerConfig } from "../config.ts"; import { ProjectServiceLayerLive } from "../orchestration-v2/runtimeLayer.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; import * as ProjectService from "./ProjectService.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const workspacePathsLayer = Layer.succeed(WorkspacePaths.WorkspacePaths, { normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot.replace(/\/$/, "")), resolveRelativePathWithinRoot: ({ workspaceRoot, relativePath }) => Effect.succeed({ absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }), }); const metadataLayer = Layer.merge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (workspaceRoot) => Effect.succeed({ canonicalKey: `github.com/t3tools/${workspaceRoot.split("/").at(-1)}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: `git@github.com:t3tools/${workspaceRoot.split("/").at(-1)}.git`, }, rootPath: workspaceRoot, }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); const makeTestLayer = ( projectMetadataLayer: Layer.Layer< | ProjectFaviconResolver.ProjectFaviconResolver | RepositoryIdentityResolver.RepositoryIdentityResolver >, ) => ProjectServiceLayerLive.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(workspacePathsLayer), Layer.provideMerge(projectMetadataLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), Layer.provide(NodeServices.layer), ); const TestLayer = makeTestLayer(metadataLayer); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( service: ProjectService.ProjectService["Service"], projectId: ProjectId, predicate: (project: Project) => boolean, ) { for (let attempt = 0; attempt < 100; attempt += 1) { const project = Option.getOrThrow(yield* service.getById(projectId)); if (predicate(project)) return project; yield* Effect.yieldNow; } return yield* Effect.die(`Project ${projectId} was not enriched in time.`); }); it.layer(TestLayer)("ProjectService", (it) => { it.effect("creates, updates, resolves, snapshots, and soft-deletes projects", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; const projectId = ProjectId.make("project:service-test"); const modelSelection = { instanceId: ProviderInstanceId.make("codex_custom"), model: "gpt-5.1-codex", } as const; yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z")); const created = yield* service.create({ commandId: CommandId.make("command:project:create"), projectId, title: "Project", workspaceRoot: "/work/project/", defaultModelSelection: modelSelection, scripts: [ { id: "setup", name: "Setup", command: "vp install", icon: "configure", runOnWorktreeCreate: true, }, ], }); assert.equal(created.workspaceRoot, "/work/project"); assert.isNull(created.repositoryIdentity); assert.isNull(created.faviconPath); const hydratedCreated = yield* waitForProject( service, projectId, (project) => project.repositoryIdentity !== null && project.faviconPath !== null, ); assert.equal(hydratedCreated?.repositoryIdentity?.canonicalKey, "github.com/t3tools/project"); assert.equal(hydratedCreated?.faviconPath, "/work/project/favicon.svg"); const updated = yield* service.update({ commandId: CommandId.make("command:project:update"), projectId, title: "Renamed", autoPull: true, projectIcon: { kind: "emoji", emoji: "🦊" }, faviconPath: "/work/project/custom.svg", defaultThreadEnvMode: "worktree", }); assert.equal(updated.title, "Renamed"); assert.equal(updated.createdAt, created.createdAt); assert.isTrue(updated.autoPull); assert.deepEqual(updated.projectIcon, { kind: "emoji", emoji: "🦊" }); assert.equal(updated.faviconPath, "/work/project/custom.svg"); assert.equal(updated.defaultThreadEnvMode, "worktree"); const byId = yield* service.getById(projectId); const byWorkspace = yield* service.getByWorkspaceRoot("/work/project/"); assert.isTrue(Option.isSome(byId)); assert.isTrue(Option.isSome(byWorkspace)); assert.equal(Option.getOrThrow(byWorkspace).id, projectId); assert.isTrue(Option.getOrThrow(byId).autoPull); assert.deepEqual(Option.getOrThrow(byId).projectIcon, updated.projectIcon); assert.equal(Option.getOrThrow(byId).faviconPath, updated.faviconPath); assert.equal(Option.getOrThrow(byId).defaultThreadEnvMode, "worktree"); const reset = yield* service.update({ commandId: CommandId.make("command:project:reset-appearance"), projectId, autoPull: false, projectIcon: null, faviconPath: null, defaultThreadEnvMode: null, }); assert.isFalse(reset.autoPull); assert.isNull(reset.projectIcon); assert.equal(reset.faviconPath, hydratedCreated.faviconPath); assert.isNull(reset.defaultThreadEnvMode); assert.deepEqual( (yield* service.snapshot).projects.map((project) => project.id), [projectId], ); const deleted = yield* service.delete({ commandId: CommandId.make("command:project:delete"), projectId, }); assert.isNotNull(deleted.deletedAt); assert.isTrue(Option.isNone(yield* service.getById(projectId))); assert.isTrue(Option.isSome(yield* service.getById(projectId, { includeDeleted: true }))); assert.deepEqual((yield* service.snapshot).projects, []); const sql = yield* SqlClient.SqlClient; const changes = yield* sql<{ readonly event_type: string }>` SELECT event_type FROM orchestration_events WHERE aggregate_kind = 'project' AND stream_id = ${projectId} ORDER BY sequence ASC `; assert.deepEqual( changes.map((change) => change.event_type), ["project.created", "project.meta-updated", "project.meta-updated", "project.deleted"], ); }), ); it.effect("rejects active workspace collisions", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z")); yield* service.create({ commandId: CommandId.make("command:collision:first"), projectId: ProjectId.make("project:collision:first"), title: "First", workspaceRoot: "/work/shared", }); const error = yield* service .create({ commandId: CommandId.make("command:collision:second"), projectId: ProjectId.make("project:collision:second"), title: "Second", workspaceRoot: "/work/shared", }) .pipe(Effect.flip); assert.equal(error._tag, "ProjectConflictError"); }), ); it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z")); const input = { commandId: CommandId.make("command:bootstrap:first"), projectId: ProjectId.make("project:bootstrap"), title: "Bootstrap", workspaceRoot: "/work/bootstrap/", }; const first = yield* service.bootstrap(input); const second = yield* service.bootstrap({ ...input, commandId: CommandId.make("command:bootstrap:second"), projectId: ProjectId.make("project:bootstrap:unused"), }); assert.isTrue(first.created); assert.isFalse(second.created); assert.equal(second.project.id, first.project.id); }), ); }); it.effect( "returns project mutations before slow enrichment and shares the eventual result across reads", () => Effect.gen(function* () { const repositoryStarted = yield* Deferred.make(); const releaseRepository = yield* Deferred.make(); const repositoryCalls = yield* Ref.make(0); const faviconCalls = yield* Ref.make(0); const slowMetadataLayer = Layer.merge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (workspaceRoot) => Effect.gen(function* () { yield* Ref.update(repositoryCalls, (count) => count + 1); yield* Deferred.succeed(repositoryStarted, undefined); yield* Deferred.await(releaseRepository); return { canonicalKey: "github.com/t3tools/slow-project", locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "git@github.com:t3tools/slow-project.git", }, rootPath: workspaceRoot, }; }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { resolvePath: (workspaceRoot) => Ref.updateAndGet(faviconCalls, (count) => count + 1).pipe( Effect.as(`${workspaceRoot}/favicon.svg`), ), }), ); yield* Effect.gen(function* () { const service = yield* ProjectService.ProjectService; const projectId = ProjectId.make("project:slow-enrichment"); const createFiber = yield* service .create({ commandId: CommandId.make("command:slow-enrichment:create"), projectId, title: "Slow enrichment", workspaceRoot: "/work/slow-enrichment", }) .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(repositoryStarted); yield* Effect.yieldNow; assert.isDefined(createFiber.pollUnsafe()); const created = yield* Fiber.join(createFiber); assert.isNull(created.repositoryIdentity); assert.isNull(created.faviconPath); const updated = yield* service.update({ commandId: CommandId.make("command:slow-enrichment:update"), projectId, title: "Updated before enrichment", }); assert.equal(updated.title, "Updated before enrichment"); assert.isNull(updated.repositoryIdentity); assert.equal(updated.faviconPath, "/work/slow-enrichment/favicon.svg"); const immediateReadFiber = yield* service .getById(projectId) .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; assert.isDefined(immediateReadFiber.pollUnsafe()); const immediateRead = Option.getOrThrow(yield* Fiber.join(immediateReadFiber)); assert.isNull(immediateRead.repositoryIdentity); assert.equal(immediateRead.faviconPath, "/work/slow-enrichment/favicon.svg"); const snapshotFiber = yield* service.snapshot.pipe( Effect.forkChild({ startImmediately: true }), ); yield* Effect.yieldNow; assert.isDefined(snapshotFiber.pollUnsafe()); const immediateSnapshot = yield* Fiber.join(snapshotFiber); assert.isNull(immediateSnapshot.projects[0]?.repositoryIdentity ?? null); yield* Deferred.succeed(releaseRepository, undefined); const firstProject = yield* waitForProject( service, projectId, (project) => project.repositoryIdentity !== null && project.faviconPath !== null, ); assert.isDefined(firstProject); assert.equal( firstProject.repositoryIdentity?.canonicalKey, "github.com/t3tools/slow-project", ); assert.equal(firstProject.faviconPath, "/work/slow-enrichment/favicon.svg"); const byId = Option.getOrThrow(yield* service.getById(projectId)); const secondSnapshot = yield* service.snapshot; assert.equal(byId.repositoryIdentity?.canonicalKey, "github.com/t3tools/slow-project"); assert.equal(secondSnapshot.projects[0]?.faviconPath, "/work/slow-enrichment/favicon.svg"); assert.equal(yield* Ref.get(repositoryCalls), 1); assert.equal(yield* Ref.get(faviconCalls), 1); }).pipe(Effect.provide(makeTestLayer(slowMetadataLayer))); }), ); it.effect("keeps project snapshots available when optional metadata enrichment fails", () => Effect.gen(function* () { const failingMetadataLayer = Layer.merge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { resolvePath: (workspaceRoot) => Effect.fail( new ProjectFaviconResolver.ProjectFaviconResolutionError({ operation: "stat-candidate", workspaceRoot, cause: "permission denied", }), ), }), ); yield* Effect.gen(function* () { const service = yield* ProjectService.ProjectService; const projectId = ProjectId.make("project:failed-enrichment"); yield* service.create({ commandId: CommandId.make("command:failed-enrichment:create"), projectId, title: "Still visible", workspaceRoot: "/work/failed-enrichment", }); const snapshot = yield* service.snapshot; assert.equal(snapshot.projects.length, 1); assert.equal(snapshot.projects[0]?.id, projectId); assert.equal(snapshot.projects[0]?.title, "Still visible"); assert.isNull(snapshot.projects[0]?.repositoryIdentity ?? null); assert.isNull(snapshot.projects[0]?.faviconPath ?? null); }).pipe(Effect.provide(makeTestLayer(failingMetadataLayer))); }), ); it.effect("invalidates workspace-derived metadata when a project moves", () => Effect.gen(function* () { const metadataVersion = yield* Ref.make(1); const versionedMetadataLayer = Layer.merge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (workspaceRoot) => Ref.get(metadataVersion).pipe( Effect.map((version) => ({ canonicalKey: `example.test/v${version}${workspaceRoot}`, locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: `https://example.test/v${version}${workspaceRoot}.git`, }, rootPath: workspaceRoot, })), ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { resolvePath: (workspaceRoot) => Ref.get(metadataVersion).pipe( Effect.map((version) => `${workspaceRoot}/favicon-v${version}.svg`), ), }), ); yield* Effect.gen(function* () { const service = yield* ProjectService.ProjectService; const projectId = ProjectId.make("project:moved-enrichment"); yield* service.create({ commandId: CommandId.make("command:moved-enrichment:create"), projectId, title: "Moved", workspaceRoot: "/work/original", }); assert.equal( (yield* waitForProject( service, projectId, (project) => project.faviconPath === "/work/original/favicon-v1.svg", )).faviconPath, "/work/original/favicon-v1.svg", ); yield* Ref.set(metadataVersion, 2); yield* service.update({ commandId: CommandId.make("command:moved-enrichment:away"), projectId, workspaceRoot: "/work/temporary", }); yield* service.snapshot; yield* Ref.set(metadataVersion, 3); yield* service.update({ commandId: CommandId.make("command:moved-enrichment:return"), projectId, workspaceRoot: "/work/original", }); assert.equal( (yield* waitForProject( service, projectId, (project) => project.faviconPath === "/work/original/favicon-v3.svg", )).faviconPath, "/work/original/favicon-v3.svg", ); }).pipe(Effect.provide(makeTestLayer(versionedMetadataLayer))); }), );