import type { RepositoryIdentity } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const DEFAULT_CACHE_CAPACITY = 512; const DEFAULT_MAX_PENDING = 512; const DEFAULT_CONCURRENCY = 4; const DEFAULT_SUCCESS_TTL = Duration.minutes(1); const DEFAULT_FAILURE_TTL = Duration.seconds(5); export interface ProjectEnrichment { readonly repositoryIdentity: RepositoryIdentity | null; readonly faviconPath: string | null; /** True when identity resolution completed successfully, including cached null. */ readonly repositoryIdentityResolved: boolean; } export interface ProjectEnrichmentChange { readonly workspaceRoot: string; readonly enrichment: ProjectEnrichment; readonly repositoryIdentityResolved: boolean; } export interface ProjectEnrichmentServiceOptions { readonly cacheCapacity?: number; /** Maximum active and queued roots for each enrichment field. */ readonly maxPending?: number; /** Worker concurrency for each enrichment field. */ readonly concurrency?: number; readonly successTtl?: Duration.Input; readonly failureTtl?: Duration.Input; } export class ProjectEnrichmentService extends Context.Service< ProjectEnrichmentService, { /** Read resolved metadata without starting or awaiting filesystem work. */ readonly peek: (workspaceRoot: string) => Effect.Effect; /** Schedule missing metadata for bounded background resolution. */ readonly request: (workspaceRoot: string) => Effect.Effect; /** Read immediately available metadata and schedule anything missing. */ readonly getAvailable: (workspaceRoot: string) => Effect.Effect; /** Invalidate workspace-derived metadata. */ readonly invalidate: (workspaceRoots: Iterable) => Effect.Effect; /** Subscribe to ephemeral completion notifications. */ readonly subscribeChanges: Effect.Effect< PubSub.Subscription, never, Scope.Scope >; } >()("t3/project/ProjectEnrichmentService") {} function availableValue(cached: Option.Option>): A | null { return Option.match(cached, { onNone: () => null, onSome: (exit) => Exit.match(exit, { onFailure: () => null, onSuccess: (value) => value, }), }); } function isSuccessfullyResolved(cached: Option.Option>): boolean { return Option.match(cached, { onNone: () => false, onSome: (exit) => Exit.isSuccess(exit), }); } interface EnrichmentWorkLane { readonly pendingRoots: Ref.Ref>; readonly queue: Queue.Queue; } export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( options: ProjectEnrichmentServiceOptions = {}, ) { const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const cacheCapacity = Math.max(1, options.cacheCapacity ?? DEFAULT_CACHE_CAPACITY); const maxPending = Math.max(1, options.maxPending ?? DEFAULT_MAX_PENDING); const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); const successTtl = options.successTtl ?? DEFAULT_SUCCESS_TTL; const failureTtl = options.failureTtl ?? DEFAULT_FAILURE_TTL; const repositoryIdentityCache = yield* Cache.makeWith( (workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)), { capacity: cacheCapacity, timeToLive: Exit.match({ onFailure: () => failureTtl, onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl), }), }, ); const faviconCache = yield* Cache.makeWith( (workspaceRoot: string) => Effect.exit(faviconResolver.resolvePath(workspaceRoot)), { capacity: cacheCapacity, timeToLive: Exit.match({ onFailure: () => failureTtl, onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl), }), }, ); const makeWorkLane = Effect.gen(function* () { const pendingRoots = yield* Ref.make>(new Set()); const queue = yield* Effect.acquireRelease(Queue.dropping(maxPending), (queue) => Queue.shutdown(queue), ); return { pendingRoots, queue } satisfies EnrichmentWorkLane; }); const repositoryIdentityLane = yield* makeWorkLane; const faviconLane = yield* makeWorkLane; const changes = yield* Effect.acquireRelease( PubSub.sliding(256), (pubsub) => PubSub.shutdown(pubsub), ); const removePending = (lane: EnrichmentWorkLane, workspaceRoot: string) => Ref.update(lane.pendingRoots, (current) => { if (!current.has(workspaceRoot)) return current; const next = new Set(current); next.delete(workspaceRoot); return next; }); const reservePending = (lane: EnrichmentWorkLane, workspaceRoot: string) => Ref.modify(lane.pendingRoots, (current) => { if (current.has(workspaceRoot) || current.size >= maxPending) { return [false, current] as const; } const next = new Set(current); next.add(workspaceRoot); return [true, next] as const; }); const logFailure = ( workspaceRoot: string, field: "repositoryIdentity" | "faviconPath", result: Exit.Exit, ) => Exit.isFailure(result) ? Effect.logWarning("Failed to enrich optional project metadata", { workspaceRoot, field, cause: Cause.pretty(result.cause), }) : Effect.void; const resolveRepositoryIdentity = Effect.fn("ProjectEnrichmentService.resolveRepositoryIdentity")( function* (workspaceRoot: string) { const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot); yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity); const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot); const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity); yield* PubSub.publish(changes, { workspaceRoot, repositoryIdentityResolved, enrichment: { repositoryIdentity: availableValue(Option.some(repositoryIdentity)), faviconPath: availableValue(faviconPath), repositoryIdentityResolved, }, }); }, ); const resolveFavicon = Effect.fn("ProjectEnrichmentService.resolveFavicon")(function* ( workspaceRoot: string, ) { const faviconPath = yield* Cache.get(faviconCache, workspaceRoot); yield* logFailure(workspaceRoot, "faviconPath", faviconPath); }); const startWorkers = ( lane: EnrichmentWorkLane, resolve: (workspaceRoot: string) => Effect.Effect, ) => { const worker = Queue.take(lane.queue).pipe( Effect.flatMap((workspaceRoot) => resolve(workspaceRoot).pipe(Effect.ensuring(removePending(lane, workspaceRoot))), ), Effect.forever, ); return Effect.forEach(Array.from({ length: concurrency }), () => Effect.forkScoped(worker), { discard: true, }); }; yield* Effect.all( [ startWorkers(repositoryIdentityLane, resolveRepositoryIdentity), startWorkers(faviconLane, resolveFavicon), ], { concurrency: "unbounded", discard: true }, ); const requestLane = Effect.fn("ProjectEnrichmentService.requestLane")(function* ( lane: EnrichmentWorkLane, workspaceRoot: string, field: "repositoryIdentity" | "faviconPath", ) { if (!(yield* reservePending(lane, workspaceRoot))) return; if (!(yield* Queue.offer(lane.queue, workspaceRoot))) { yield* removePending(lane, workspaceRoot); yield* Effect.logWarning("Project metadata enrichment queue is full", { workspaceRoot, field, }); } }); const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fn( "ProjectEnrichmentService.peek", )(function* (workspaceRoot) { const [repositoryIdentity, faviconPath] = yield* Effect.all( [ Cache.getSuccess(repositoryIdentityCache, workspaceRoot), Cache.getSuccess(faviconCache, workspaceRoot), ] as const, { concurrency: "unbounded" }, ); return { repositoryIdentity: availableValue(repositoryIdentity), faviconPath: availableValue(faviconPath), repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), }; }); const request: ProjectEnrichmentService["Service"]["request"] = Effect.fn( "ProjectEnrichmentService.request", )(function* (workspaceRoot) { const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all( [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)], { concurrency: "unbounded" }, ); yield* Effect.all( [ hasRepositoryIdentity ? Effect.void : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"), hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"), ], { concurrency: "unbounded", discard: true }, ); }); const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fn( "ProjectEnrichmentService.getAvailable", )(function* (workspaceRoot) { const available = yield* peek(workspaceRoot); yield* request(workspaceRoot); return available; }); const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn( "ProjectEnrichmentService.invalidate", )(function* (workspaceRoots) { yield* Effect.forEach( new Set(workspaceRoots), (workspaceRoot) => Effect.all( [ Cache.invalidate(repositoryIdentityCache, workspaceRoot), Cache.invalidate(faviconCache, workspaceRoot), ], { concurrency: "unbounded", discard: true }, ), { discard: true }, ); }); return ProjectEnrichmentService.of({ peek, request, getAvailable, invalidate, subscribeChanges: PubSub.subscribe(changes), }); }); export const layer = Layer.effect(ProjectEnrichmentService, make());