import { CommandId, ModelSelection, ProjectId, type Project, type ProjectIconOverride, type ProjectScript, type ProjectSnapshot, type ThreadEnvMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { EventSinkV2 } from "../orchestration-v2/EventSink.ts"; import { IdAllocatorV2 } from "../orchestration-v2/IdAllocator.ts"; import { LegacyV1ThreadImporter } from "../orchestration-v2/LegacyV1ThreadImporter.ts"; import { ProjectionStoreV2 } from "../orchestration-v2/ProjectionStore.ts"; import { ThreadCommandExecutor, layer as threadCommandExecutorLayer, } from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ProjectionProjects from "../persistence/Services/ProjectionProjects.ts"; import { ProjectEnrichmentService, type ProjectEnrichment } from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export interface ProjectCreateInput { readonly commandId: CommandId; readonly projectId: ProjectId; readonly title: string; readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; readonly scripts?: ReadonlyArray; } export interface ProjectUpdateInput { readonly commandId: CommandId; readonly projectId: ProjectId; readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; readonly autoPull?: boolean; readonly projectIcon?: ProjectIconOverride | null; readonly faviconPath?: string | null; readonly defaultThreadEnvMode?: ThreadEnvMode | null; readonly scripts?: ReadonlyArray; } export interface ProjectBootstrapInput extends ProjectCreateInput {} export interface ProjectDeleteInput { readonly commandId: CommandId; readonly projectId: ProjectId; readonly force?: boolean; } export class ProjectNotFoundError extends Schema.TaggedErrorClass()( "ProjectNotFoundError", { projectId: ProjectId }, ) { override get message(): string { return `Project ${this.projectId} was not found.`; } } export class ProjectConflictError extends Schema.TaggedErrorClass()( "ProjectConflictError", { projectId: ProjectId, workspaceRoot: Schema.String, conflictingProjectId: ProjectId, }, ) { override get message(): string { return `Workspace ${this.workspaceRoot} already belongs to project ${this.conflictingProjectId}.`; } } export class ProjectNotEmptyError extends Schema.TaggedErrorClass()( "ProjectNotEmptyError", { projectId: ProjectId }, ) { override get message(): string { return `Project ${this.projectId} is not empty.`; } } export class ProjectOperationError extends Schema.TaggedErrorClass()( "ProjectOperationError", { operation: Schema.Literals([ "normalize-workspace", "read-project", "list-projects", "list-threads", "delete-thread", "dispatch-project-command", ]), projectId: Schema.optional(ProjectId), workspaceRoot: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { return `Project operation '${this.operation}' failed${this.projectId === undefined ? "" : ` for ${this.projectId}`}.`; } } export type ProjectServiceError = | ProjectNotFoundError | ProjectConflictError | ProjectNotEmptyError | ProjectOperationError; export class ProjectService extends Context.Service< ProjectService, { readonly create: (input: ProjectCreateInput) => Effect.Effect; readonly bootstrap: ( input: ProjectBootstrapInput, ) => Effect.Effect< { readonly project: Project; readonly created: boolean }, ProjectServiceError >; readonly update: (input: ProjectUpdateInput) => Effect.Effect; readonly delete: (input: ProjectDeleteInput) => Effect.Effect; readonly getById: ( projectId: ProjectId, options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; readonly getByWorkspaceRoot: ( workspaceRoot: string, options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; readonly snapshot: Effect.Effect; } >()("t3/project/ProjectService") {} export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const projects = yield* ProjectionProjects.ProjectionProjectRepository; const projectEnrichment = yield* ProjectEnrichmentService; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const threadProjections = yield* ProjectionStoreV2; const threadEvents = yield* EventSinkV2; const idAllocator = yield* IdAllocatorV2; const legacyImporter = yield* LegacyV1ThreadImporter; const threadCommands = yield* ThreadCommandExecutor; const toProject = ( row: ProjectionProjects.ProjectionProject, enrichment: ProjectEnrichment | null, ): Project => ({ id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, repositoryIdentity: enrichment?.repositoryIdentity ?? null, faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull, projectIcon: row.projectIcon, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, }); const hydrateAvailable = Effect.fn("ProjectService.hydrateAvailable")(function* ( row: ProjectionProjects.ProjectionProject, ) { const enrichment = row.deletedAt === null ? yield* projectEnrichment.getAvailable(row.workspaceRoot) : yield* projectEnrichment.peek(row.workspaceRoot); return toProject(row, enrichment); }); const readRows = Effect.fn("ProjectService.readRows")(function* () { return yield* projects .listAll() .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-projects", cause }), ), ); }); const getById: ProjectService["Service"]["getById"] = Effect.fn("ProjectService.getById")( function* (projectId, options) { const row = yield* projects .getById({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), ), ); if (Option.isNone(row) || (row.value.deletedAt !== null && !options?.includeDeleted)) { return Option.none(); } return Option.some(yield* hydrateAvailable(row.value)); }, ); const getByWorkspaceRoot: ProjectService["Service"]["getByWorkspaceRoot"] = Effect.fn( "ProjectService.getByWorkspaceRoot", )(function* (workspaceRoot, options) { const normalized = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "normalize-workspace", workspaceRoot, cause, }), ), ); const row = (yield* readRows()).find( (candidate) => candidate.workspaceRoot === normalized && (options?.includeDeleted === true || candidate.deletedAt === null), ); return row === undefined ? Option.none() : Option.some(yield* hydrateAvailable(row)); }); const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) { const row = yield* projects .getById({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), ), ); if (Option.isNone(row)) { return yield* new ProjectOperationError({ operation: "read-project", projectId, cause: "The accepted project command did not produce a project projection.", }); } return yield* hydrateAvailable(row.value); }); const invalidateEnrichment = (...workspaceRoots: ReadonlyArray) => projectEnrichment.invalidate(workspaceRoots); const dispatch = ( projectId: ProjectId, command: Parameters[0], onCommitted: Effect.Effect, ) => engine.dispatch(command).pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "dispatch-project-command", projectId, cause, }), ), Effect.andThen(onCommitted), ); const assertWorkspaceAvailable = Effect.fn("ProjectService.assertWorkspaceAvailable")(function* ( projectId: ProjectId, workspaceRoot: string, ) { const conflicting = (yield* readRows()).find( (candidate) => candidate.deletedAt === null && candidate.projectId !== projectId && candidate.workspaceRoot === workspaceRoot, ); if (conflicting !== undefined) { return yield* new ProjectConflictError({ projectId, workspaceRoot, conflictingProjectId: conflicting.projectId, }); } }); const create: ProjectService["Service"]["create"] = Effect.fn("ProjectService.create")( function* (input) { const workspaceRoot = yield* workspacePaths .normalizeWorkspaceRoot(input.workspaceRoot, { createIfMissing: input.createWorkspaceRootIfMissing ?? false, }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "normalize-workspace", projectId: input.projectId, workspaceRoot: input.workspaceRoot, cause, }), ), ); yield* assertWorkspaceAvailable(input.projectId, workspaceRoot); const now = DateTime.formatIso(yield* DateTime.now); return yield* dispatch( input.projectId, { type: "project.create", commandId: input.commandId, projectId: input.projectId, title: input.title, workspaceRoot, defaultModelSelection: input.defaultModelSelection ?? null, scripts: [...(input.scripts ?? [])], createdAt: now, }, invalidateEnrichment(workspaceRoot).pipe(Effect.andThen(readCommitted(input.projectId))), ); }, ); const update: ProjectService["Service"]["update"] = Effect.fn("ProjectService.update")( function* (input) { const existing = yield* projects.getById({ projectId: input.projectId }).pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "read-project", projectId: input.projectId, cause, }), ), ); if (Option.isNone(existing) || existing.value.deletedAt !== null) { return yield* new ProjectNotFoundError({ projectId: input.projectId }); } const workspaceRoot = input.workspaceRoot === undefined ? existing.value.workspaceRoot : yield* workspacePaths.normalizeWorkspaceRoot(input.workspaceRoot).pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "normalize-workspace", projectId: input.projectId, workspaceRoot: input.workspaceRoot, cause, }), ), ); yield* assertWorkspaceAvailable(input.projectId, workspaceRoot); return yield* dispatch( input.projectId, { type: "project.meta.update", commandId: input.commandId, projectId: input.projectId, ...(input.title === undefined ? {} : { title: input.title }), ...(workspaceRoot === existing.value.workspaceRoot ? {} : { workspaceRoot }), ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), ...(input.autoPull === undefined ? {} : { autoPull: input.autoPull }), ...(input.projectIcon === undefined ? {} : { projectIcon: input.projectIcon }), ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), }, (workspaceRoot === existing.value.workspaceRoot ? Effect.void : invalidateEnrichment(existing.value.workspaceRoot, workspaceRoot) ).pipe(Effect.andThen(readCommitted(input.projectId))), ); }, ); const bootstrap: ProjectService["Service"]["bootstrap"] = Effect.fn("ProjectService.bootstrap")( function* (input) { const existing = yield* getByWorkspaceRoot(input.workspaceRoot); if (Option.isSome(existing)) return { project: existing.value, created: false }; return { project: yield* create(input), created: true }; }, ); const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( function* (input) { const { projectId } = input; const existing = yield* projects .getById({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), ), ); if (Option.isNone(existing) || existing.value.deletedAt !== null) { return yield* new ProjectNotFoundError({ projectId }); } const snapshot = yield* threadProjections .getShellSnapshot() .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), ), ); const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( (thread) => thread.projectId === projectId, ); if (projectThreads.length > 0 && input.force !== true) { return yield* new ProjectNotEmptyError({ projectId }); } // Delete children durably before the project. Stable command IDs let a retry // finish a partially completed cascade without repeating cleanup effects. yield* Effect.forEach( projectThreads, (thread) => threadCommands .withLock( thread.id, Effect.gen(function* () { yield* legacyImporter.ensureTranscript(thread.id); const projection = yield* threadProjections.getThreadProjection(thread.id); if ( projection.thread.deletedAt !== null || projection.thread.projectId !== projectId ) { return; } const command = { type: "thread.delete" as const, commandId: CommandId.make(`${input.commandId}:delete-thread:${thread.id}`), threadId: thread.id, }; const now = yield* DateTime.now; const plan = yield* planThreadDeletion({ command, projection, now, idAllocator }); const committed = yield* threadEvents.commitCommand({ commandId: command.commandId, commandType: command.type, threadId: command.threadId, acceptedAt: now, events: plan.events, effects: plan.effects, }); if ( committed.receipt.threadId !== command.threadId || committed.receipt.commandType !== command.type ) { return yield* Effect.fail( "The thread deletion command ID belongs to a different command.", ); } if (committed.receipt.status === "rejected") { return yield* Effect.fail( committed.receipt.error ?? "Thread deletion was previously rejected.", ); } }), ) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "delete-thread", projectId, cause }), ), ), { concurrency: 1, discard: true }, ); return yield* dispatch( projectId, { type: "project.delete", commandId: input.commandId, projectId, ...(input.force === undefined ? {} : { force: input.force }), }, invalidateEnrichment(existing.value.workspaceRoot).pipe( Effect.andThen(readCommitted(projectId)), ), ); }, ); const snapshot = Effect.gen(function* () { const rows = (yield* readRows()).filter((row) => row.deletedAt === null); const hydrated = yield* Effect.forEach(rows, hydrateAvailable, { concurrency: 8 }); return { projects: hydrated, updatedAt: DateTime.formatIso(yield* DateTime.now), } satisfies ProjectSnapshot; }); return ProjectService.of({ create, bootstrap, update, delete: deleteProject, getById, getByWorkspaceRoot, snapshot, }); }); export const layer = Layer.effect(ProjectService, make).pipe( Layer.provide(threadCommandExecutorLayer), );