1import type { RepositoryIdentity } from "@t3tools/contracts";
2import * as Cache from "effect/Cache";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PubSub from "effect/PubSub";
11import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";
13import type * as Scope from "effect/Scope";
14
15import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
16import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
17
18const DEFAULT_CACHE_CAPACITY = 512;
19const DEFAULT_MAX_PENDING = 512;
20const DEFAULT_CONCURRENCY = 4;
21const DEFAULT_SUCCESS_TTL = Duration.minutes(1);
22// Frequent shell reads scan ~20 files per project; icons change rarely, so cache longer than identities.
23const DEFAULT_FAVICON_TTL = Duration.minutes(15);
24const DEFAULT_FAILURE_TTL = Duration.seconds(5);
25
26export interface ProjectEnrichment {
27 readonly repositoryIdentity: RepositoryIdentity | null;
28 readonly faviconPath: string | null;
29 readonly repositoryIdentityResolved: boolean;
30}
31
32export interface ProjectEnrichmentChange {
33 readonly workspaceRoot: string;
34 readonly enrichment: ProjectEnrichment;
35 readonly repositoryIdentityResolved: boolean;
36}
37
38export interface ProjectEnrichmentServiceOptions {
39 readonly cacheCapacity?: number;
40 readonly maxPending?: number;
41 readonly concurrency?: number;
42}
43
44export class ProjectEnrichmentService extends Context.Service<
45 ProjectEnrichmentService,
46 {
47 readonly peek: (workspaceRoot: string) => Effect.Effect<ProjectEnrichment>;
48 readonly request: (workspaceRoot: string) => Effect.Effect<void>;
49 readonly getAvailable: (workspaceRoot: string) => Effect.Effect<ProjectEnrichment>;
50 readonly readRepositoryIdentity: (
51 workspaceRoot: string,
52 ) => Effect.Effect<RepositoryIdentity | null>;
53 readonly invalidate: (workspaceRoots: Iterable<string>) => Effect.Effect<void>;
54 /** Re-resolves past both identity caches and publishes a found identity, leaving the favicon alone. */
55 readonly refreshRepositoryIdentity: (workspaceRoot: string) => Effect.Effect<void>;
56 readonly subscribeChanges: Effect.Effect<
57 PubSub.Subscription<ProjectEnrichmentChange>,
58 never,
59 Scope.Scope
60 >;
61 }
62>()("t3/project/ProjectEnrichmentService") {}
63
64function availableValue<A, E>(cached: Option.Option<Exit.Exit<A, E>>): A | null {
65 return Option.match(cached, {
66 onNone: () => null,
67 onSome: (exit) =>
68 Exit.match(exit, {
69 onFailure: () => null,
70 onSuccess: (value) => value,
71 }),
72 });
73}
74
75function isSuccessfullyResolved<A, E>(cached: Option.Option<Exit.Exit<A, E>>): boolean {
76 return Option.match(cached, {
77 onNone: () => false,
78 onSome: (exit) => Exit.isSuccess(exit),
79 });
80}
81
82interface EnrichmentWorkLane {
83 readonly pendingRoots: Ref.Ref<ReadonlySet<string>>;
84 readonly queue: Queue.Queue<string>;
85}
86
87export const make = Effect.fn("ProjectEnrichmentService.make")(function* (
88 options: ProjectEnrichmentServiceOptions = {},
89) {
90 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
91 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
92 const cacheCapacity = Math.max(1, options.cacheCapacity ?? DEFAULT_CACHE_CAPACITY);
93 const maxPending = Math.max(1, options.maxPending ?? DEFAULT_MAX_PENDING);
94 const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
95
96 // Outlives the cache entry so an expired or refreshing identity still reads as its last value.
97 const lastRepositoryIdentities = new Map<string, RepositoryIdentity>();
98 // `invalidate` drops a root's in-flight lookup so its late result cannot overwrite a newer one.
99 const lookupsInFlight = new Map<string, object>();
100 const rememberRepositoryIdentity = (
101 workspaceRoot: string,
102 repositoryIdentity: RepositoryIdentity | null,
103 ) => {
104 lastRepositoryIdentities.delete(workspaceRoot);
105 if (repositoryIdentity === null) return;
106 if (lastRepositoryIdentities.size >= cacheCapacity) {
107 const oldestRoot = lastRepositoryIdentities.keys().next().value;
108 if (oldestRoot !== undefined) lastRepositoryIdentities.delete(oldestRoot);
109 }
110 lastRepositoryIdentities.set(workspaceRoot, repositoryIdentity);
111 };
112
113 const repositoryIdentityCache = yield* Cache.makeWith(
114 (workspaceRoot: string) =>
115 Effect.suspend(() => {
116 const lookup = {};
117 lookupsInFlight.set(workspaceRoot, lookup);
118 const isCurrent = () => lookupsInFlight.get(workspaceRoot) === lookup;
119 return Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)).pipe(
120 Effect.tap((result) =>
121 Effect.sync(() => {
122 if (Exit.isSuccess(result) && isCurrent()) {
123 rememberRepositoryIdentity(workspaceRoot, result.value);
124 }
125 }),
126 ),
127 Effect.ensuring(
128 Effect.sync(() => {
129 if (isCurrent()) lookupsInFlight.delete(workspaceRoot);
130 }),
131 ),
132 );
133 }),
134 {
135 capacity: cacheCapacity,
136 timeToLive: Exit.match({
137 onFailure: () => DEFAULT_FAILURE_TTL,
138 onSuccess: (result) => (Exit.isSuccess(result) ? DEFAULT_SUCCESS_TTL : DEFAULT_FAILURE_TTL),
139 }),
140 },
141 );
142 const faviconCache = yield* Cache.makeWith(
143 (workspaceRoot: string) => Effect.exit(faviconResolver.resolvePath(workspaceRoot)),
144 {
145 capacity: cacheCapacity,
146 timeToLive: Exit.match({
147 onFailure: () => DEFAULT_FAILURE_TTL,
148 onSuccess: (result) => (Exit.isSuccess(result) ? DEFAULT_FAVICON_TTL : DEFAULT_FAILURE_TTL),
149 }),
150 },
151 );
152 const makeWorkLane = Effect.gen(function* () {
153 const pendingRoots = yield* Ref.make<ReadonlySet<string>>(new Set());
154 const queue = yield* Effect.acquireRelease(Queue.dropping<string>(maxPending), (queue) =>
155 Queue.shutdown(queue),
156 );
157 return { pendingRoots, queue } satisfies EnrichmentWorkLane;
158 });
159 const repositoryIdentityLane = yield* makeWorkLane;
160 const faviconLane = yield* makeWorkLane;
161 const changes = yield* Effect.acquireRelease(
162 PubSub.sliding<ProjectEnrichmentChange>(256),
163 (pubsub) => PubSub.shutdown(pubsub),
164 );
165
166 const removePending = (lane: EnrichmentWorkLane, workspaceRoot: string) =>
167 Ref.update(lane.pendingRoots, (current) => {
168 if (!current.has(workspaceRoot)) return current;
169 const next = new Set(current);
170 next.delete(workspaceRoot);
171 return next;
172 });
173
174 const reservePending = (lane: EnrichmentWorkLane, workspaceRoot: string) =>
175 Ref.modify(lane.pendingRoots, (current) => {
176 if (current.has(workspaceRoot) || current.size >= maxPending) {
177 return [false, current] as const;
178 }
179 const next = new Set(current);
180 next.add(workspaceRoot);
181 return [true, next] as const;
182 });
183
184 const logFailure = <A, E>(
185 workspaceRoot: string,
186 field: "repositoryIdentity" | "faviconPath",
187 result: Exit.Exit<A, E>,
188 ) =>
189 Exit.isFailure(result)
190 ? Effect.logWarning("Failed to enrich optional project metadata", {
191 workspaceRoot,
192 field,
193 cause: Cause.pretty(result.cause),
194 })
195 : Effect.void;
196
197 const resolveRepositoryIdentity = Effect.fn("ProjectEnrichmentService.resolveRepositoryIdentity")(
198 function* (workspaceRoot: string) {
199 const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot);
200 yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity);
201 const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot);
202 const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity);
203 yield* PubSub.publish(changes, {
204 workspaceRoot,
205 repositoryIdentityResolved,
206 enrichment: {
207 repositoryIdentity: availableValue(Option.some(repositoryIdentity)),
208 faviconPath: availableValue(faviconPath),
209 repositoryIdentityResolved,
210 },
211 });
212 },
213 );
214
215 const resolveFavicon = Effect.fn("ProjectEnrichmentService.resolveFavicon")(function* (
216 workspaceRoot: string,
217 ) {
218 const faviconPath = yield* Cache.get(faviconCache, workspaceRoot);
219 const isMissingWorkspaceRoot = Option.exists(
220 Exit.findErrorOption(faviconPath),
221 ProjectFaviconResolver.isMissingWorkspaceRoot,
222 );
223 if (isMissingWorkspaceRoot) {
224 return;
225 }
226 yield* logFailure(workspaceRoot, "faviconPath", faviconPath);
227 });
228
229 const startWorkers = (
230 lane: EnrichmentWorkLane,
231 resolve: (workspaceRoot: string) => Effect.Effect<void>,
232 ) => {
233 const worker = Queue.take(lane.queue).pipe(
234 Effect.flatMap((workspaceRoot) =>
235 resolve(workspaceRoot).pipe(Effect.ensuring(removePending(lane, workspaceRoot))),
236 ),
237 Effect.forever,
238 );
239 return Effect.forEach(Array.from({ length: concurrency }), () => Effect.forkScoped(worker), {
240 discard: true,
241 });
242 };
243 yield* Effect.all(
244 [
245 startWorkers(repositoryIdentityLane, resolveRepositoryIdentity),
246 startWorkers(faviconLane, resolveFavicon),
247 ],
248 { concurrency: "unbounded", discard: true },
249 );
250
251 // Every project read lands here; only an actual enqueue gets a span, the resolve workers trace the work.
252 const requestLane = Effect.fnUntraced(function* (
253 lane: EnrichmentWorkLane,
254 workspaceRoot: string,
255 field: "repositoryIdentity" | "faviconPath",
256 ) {
257 if (!(yield* reservePending(lane, workspaceRoot))) return;
258 yield* Effect.gen(function* () {
259 if (!(yield* Queue.offer(lane.queue, workspaceRoot))) {
260 yield* removePending(lane, workspaceRoot);
261 yield* Effect.logWarning("Project metadata enrichment queue is full", {
262 workspaceRoot,
263 field,
264 });
265 }
266 }).pipe(Effect.withSpan("ProjectEnrichmentService.requestLane"));
267 });
268
269 // peek/request/getAvailable run per project on every shell read, inside the caller's span; own spans were ~0ms noise.
270 const peek: ProjectEnrichmentService["Service"]["peek"] = Effect.fnUntraced(
271 function* (workspaceRoot) {
272 const [repositoryIdentity, faviconPath] = yield* Effect.all(
273 [
274 Cache.getSuccess(repositoryIdentityCache, workspaceRoot),
275 Cache.getSuccess(faviconCache, workspaceRoot),
276 ] as const,
277 { concurrency: "unbounded" },
278 );
279 const repositoryIdentityResolved = isSuccessfullyResolved(repositoryIdentity);
280 return {
281 repositoryIdentity: repositoryIdentityResolved
282 ? availableValue(repositoryIdentity)
283 : (lastRepositoryIdentities.get(workspaceRoot) ?? null),
284 faviconPath: availableValue(faviconPath),
285 repositoryIdentityResolved,
286 };
287 },
288 );
289
290 const request: ProjectEnrichmentService["Service"]["request"] = Effect.fnUntraced(
291 function* (workspaceRoot) {
292 const [hasRepositoryIdentity, hasFaviconPath] = yield* Effect.all(
293 [Cache.has(repositoryIdentityCache, workspaceRoot), Cache.has(faviconCache, workspaceRoot)],
294 { concurrency: "unbounded" },
295 );
296 yield* Effect.all(
297 [
298 hasRepositoryIdentity
299 ? Effect.void
300 : requestLane(repositoryIdentityLane, workspaceRoot, "repositoryIdentity"),
301 hasFaviconPath ? Effect.void : requestLane(faviconLane, workspaceRoot, "faviconPath"),
302 ],
303 { concurrency: "unbounded", discard: true },
304 );
305 },
306 );
307
308 const getAvailable: ProjectEnrichmentService["Service"]["getAvailable"] = Effect.fnUntraced(
309 function* (workspaceRoot) {
310 const available = yield* peek(workspaceRoot);
311 yield* request(workspaceRoot);
312 return available;
313 },
314 );
315
316 const readRepositoryIdentity: ProjectEnrichmentService["Service"]["readRepositoryIdentity"] =
317 Effect.fn("ProjectEnrichmentService.readRepositoryIdentity")(function* (workspaceRoot) {
318 const resolved = yield* Cache.get(repositoryIdentityCache, workspaceRoot);
319 return Exit.isSuccess(resolved) ? resolved.value : null;
320 });
321
322 const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn(
323 "ProjectEnrichmentService.invalidate",
324 )(function* (workspaceRoots) {
325 yield* Effect.forEach(
326 new Set(workspaceRoots),
327 (workspaceRoot) =>
328 Effect.all(
329 [
330 Cache.invalidate(repositoryIdentityCache, workspaceRoot),
331 Effect.sync(() => {
332 lookupsInFlight.delete(workspaceRoot);
333 lastRepositoryIdentities.delete(workspaceRoot);
334 }),
335 Cache.invalidate(faviconCache, workspaceRoot),
336 // The resolver keeps its own misses, which would outlive a clone filling the folder.
337 faviconResolver.invalidate(workspaceRoot),
338 ],
339 { concurrency: "unbounded", discard: true },
340 ),
341 { discard: true },
342 );
343 });
344
345 const refreshRepositoryIdentity: ProjectEnrichmentService["Service"]["refreshRepositoryIdentity"] =
346 Effect.fn("ProjectEnrichmentService.refreshRepositoryIdentity")(function* (workspaceRoot) {
347 // Remote-less folders hit this after every turn; publishing their unchanged null would
348 // push a shell frame to every subscriber.
349 if ((yield* repositoryIdentityResolver.resolve(workspaceRoot, { refresh: true })) === null) {
350 return;
351 }
352 yield* Cache.invalidate(repositoryIdentityCache, workspaceRoot);
353 yield* resolveRepositoryIdentity(workspaceRoot);
354 });
355
356 return ProjectEnrichmentService.of({
357 peek,
358 request,
359 getAvailable,
360 readRepositoryIdentity,
361 invalidate,
362 refreshRepositoryIdentity,
363 subscribeChanges: PubSub.subscribe(changes),
364 });
365});
366
367export const layer = Layer.effect(ProjectEnrichmentService, make());