From 4b295a9202f8328882a83587cf60797e86866ee0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 21:08:14 -0700 Subject: [PATCH 1/2] fix(server): project favicons stop being rescanned every minute PR sync reads enriched project shells about once a minute. Every enrichment entry expired after 1 minute, so each project with a linked PR re-ran its favicon scan (about 20 file checks plus t3.json) every minute. Favicons now keep for 15 minutes. Repository identity keeps its 1-minute TTL, because re-reading it is only a hit on RepositoryIdentityResolver's own cache, so remote changes still show within a minute. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../project/ProjectEnrichmentService.test.ts | 81 +++++++++++++++++++ .../src/project/ProjectEnrichmentService.ts | 9 ++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index c5f37e5fc616..d2d14b429381 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import { TestClock } from "effect/testing"; import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -341,3 +342,83 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r ); }), ); + +it.effect("rescans a favicon only after 15 minutes", () => + Effect.gen(function* () { + const faviconScans = yield* Ref.make(0); + const layerMetadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + resolvePath: (workspaceRoot) => + Ref.update(faviconScans, (count) => count + 1).pipe( + Effect.as(`${workspaceRoot}/favicon.svg`), + ), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + yield* service.getAvailable("/repo"); + yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null); + + // Callers such as the shell stream read projects far more often than this. + for (let minute = 1; minute < 15; minute += 1) { + yield* TestClock.adjust("1 minute"); + yield* service.getAvailable("/repo"); + } + assert.equal(yield* Ref.get(faviconScans), 1); + + yield* TestClock.adjust("1 minute"); + yield* service.getAvailable("/repo"); + yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null); + assert.equal(yield* Ref.get(faviconScans), 2); + }).pipe(Effect.provide(layer(layerMetadata))); + }), +); + +it.effect( + "follows repository identity changes within a minute without rescanning the favicon", + () => + Effect.gen(function* () { + // 0: no repository yet, then one version per remote. + const remoteVersion = yield* Ref.make(0); + const faviconScans = yield* Ref.make(0); + const layerMetadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (workspaceRoot) => + Ref.get(remoteVersion).pipe( + Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))), + ), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)), + }), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectEnrichment.ProjectEnrichmentService; + yield* service.getAvailable("/folder"); + yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved); + assert.equal((yield* service.peek("/folder")).repositoryIdentity, null); + + // The folder is published as a repository, then its remote moves to another host. + for (const version of [1, 2]) { + yield* Ref.set(remoteVersion, version); + yield* TestClock.adjust("1 minute"); + yield* service.getAvailable("/folder"); + const available = yield* waitForAvailable( + service, + "/folder", + (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`, + ); + assert.equal( + available.repositoryIdentity?.canonicalKey, + `example.test/v${version}/folder`, + ); + } + assert.equal(yield* Ref.get(faviconScans), 1); + }).pipe(Effect.provide(layer(layerMetadata))); + }), +); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 93f6c5c854a1..199c31b827d0 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -19,6 +19,11 @@ const DEFAULT_CACHE_CAPACITY = 512; const DEFAULT_MAX_PENDING = 512; const DEFAULT_CONCURRENCY = 4; const DEFAULT_SUCCESS_TTL = Duration.minutes(1); +// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every +// linked project about once a minute, and icons rarely change, so a resolved favicon is kept +// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache. +// Moving a project's workspace root, and deleting it, invalidate both at once. +const DEFAULT_FAVICON_TTL = Duration.minutes(15); const DEFAULT_FAILURE_TTL = Duration.seconds(5); export interface ProjectEnrichment { @@ -41,6 +46,7 @@ export interface ProjectEnrichmentServiceOptions { /** Worker concurrency for each enrichment field. */ readonly concurrency?: number; readonly successTtl?: Duration.Input; + readonly faviconTtl?: Duration.Input; readonly failureTtl?: Duration.Input; } @@ -97,6 +103,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); const successTtl = options.successTtl ?? DEFAULT_SUCCESS_TTL; const failureTtl = options.failureTtl ?? DEFAULT_FAILURE_TTL; + const faviconTtl = options.faviconTtl ?? DEFAULT_FAVICON_TTL; const repositoryIdentityCache = yield* Cache.makeWith( (workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)), @@ -114,7 +121,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( capacity: cacheCapacity, timeToLive: Exit.match({ onFailure: () => failureTtl, - onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl), + onSuccess: (result) => (Exit.isSuccess(result) ? faviconTtl : failureTtl), }), }, ); From 2393e7ef3d94da1e724ca1d177133b514e4bb8cf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 5 Oct 2026 21:15:37 -0700 Subject: [PATCH 2/2] test(server): favicon TTL test waits for the rescanned path Each mocked scan returns a new path, so the wait at 15 minutes cannot match the cached path from before expiry. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../project/ProjectEnrichmentService.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index d2d14b429381..71d4cbec2fc9 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -351,9 +351,10 @@ it.effect("rescans a favicon only after 15 minutes", () => resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + // Each scan returns a new path, so a wait can tell a rescan from the cached value. resolvePath: (workspaceRoot) => - Ref.update(faviconScans, (count) => count + 1).pipe( - Effect.as(`${workspaceRoot}/favicon.svg`), + Ref.updateAndGet(faviconScans, (count) => count + 1).pipe( + Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`), ), }), ); @@ -361,7 +362,11 @@ it.effect("rescans a favicon only after 15 minutes", () => yield* Effect.gen(function* () { const service = yield* ProjectEnrichment.ProjectEnrichmentService; yield* service.getAvailable("/repo"); - yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null); + yield* waitForAvailable( + service, + "/repo", + (value) => value.faviconPath === "/repo/favicon-1.svg", + ); // Callers such as the shell stream read projects far more often than this. for (let minute = 1; minute < 15; minute += 1) { @@ -372,7 +377,11 @@ it.effect("rescans a favicon only after 15 minutes", () => yield* TestClock.adjust("1 minute"); yield* service.getAvailable("/repo"); - yield* waitForAvailable(service, "/repo", (value) => value.faviconPath !== null); + yield* waitForAvailable( + service, + "/repo", + (value) => value.faviconPath === "/repo/favicon-2.svg", + ); assert.equal(yield* Ref.get(faviconScans), 2); }).pipe(Effect.provide(layer(layerMetadata))); }),