| author | |
| committer | |
| log | 6ea01f8d24419019588f2be589681929d8e26b0c |
| tree | 5dbf96b592533f8483a036b35176f3446bc621b6 |
| parent | a97a4a9d189f145afe79c3a6f8533bbf9a6b40b1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>9 files changed, 230 insertions(+), 13 deletions(-)
apps/mobile/src/state/assets.ts+2| ... | ... | @@ -18,6 +18,7 @@ import { environmentCatalog } from "../connection/catalog"; |
| 18 | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 20 | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | import { environmentProjectCloneListAtom } from "./projectClones"; | |
| 21 | 22 | import { environmentSession, usePreparedConnection } from "./session"; |
| 22 | 23 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 23 | 24 | |
| ... | ... | @@ -29,6 +30,7 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 29 | 30 | imageCache: projectFaviconDatabaseCache, |
| 30 | 31 | createUrl: assetEnvironment.createUrl, |
| 31 | 32 | preparedConnection: environmentSession.preparedConnectionValueAtom, |
| 33 | projectClones: environmentProjectCloneListAtom, | |
| 32 | 34 | }); |
| 33 | 35 | |
| 34 | 36 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( |
apps/mobile/src/state/projectClones.ts+8| ... | ... | @@ -36,6 +36,14 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) |
| 36 | 36 | }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), |
| 37 | 37 | ); |
| 38 | 38 | |
| 39 | /** The environment's tracked clones, empty until the stream's first list. */ | |
| 40 | export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) => | |
| 41 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { | |
| 42 | const clones = get(environmentProjectClonesAtom(environmentId)); | |
| 43 | return clones === "pending" ? EMPTY_CLONES : clones; | |
| 44 | }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)), | |
| 45 | ); | |
| 46 | ||
| 39 | 47 | const projectCloneAtom = Atom.family((key: string) => { |
| 40 | 48 | const ref = parseScopedProjectKey(key); |
| 41 | 49 | return Atom.make((get): ProjectCloneState => { |
apps/server/src/assets/AssetAccess.ts+16-11| ... | ... | @@ -419,6 +419,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 419 | 419 | readonly resource: AssetResource; |
| 420 | 420 | readonly workspaceRoot?: string; |
| 421 | 421 | readonly projectFaviconPath?: string; |
| 422 | /** The project's clone has not landed, so its icon is reported missing without a lookup. */ | |
| 423 | readonly projectCheckoutPending?: boolean; | |
| 422 | 424 | }) { |
| 423 | 425 | const fileSystem = yield* FileSystem.FileSystem; |
| 424 | 426 | const path = yield* Path.Path; |
| ... | ... | @@ -573,17 +575,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 573 | 575 | ), |
| 574 | 576 | ); |
| 575 | 577 | const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
| 576 | const faviconPath = yield* faviconResolver | |
| 577 | .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) | |
| 578 | .pipe( | |
| 579 | Effect.mapError( | |
| 580 | (cause) => | |
| 581 | new AssetProjectFaviconResolutionError({ | |
| 582 | resource: input.resource, | |
| 583 | cause, | |
| 584 | }), | |
| 585 | ), | |
| 586 | ); | |
| 578 | // A lookup in a half-cloned checkout would cache a miss that outlives the clone. | |
| 579 | const faviconPath = input.projectCheckoutPending | |
| 580 | ? null | |
| 581 | : yield* faviconResolver | |
| 582 | .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) | |
| 583 | .pipe( | |
| 584 | Effect.mapError( | |
| 585 | (cause) => | |
| 586 | new AssetProjectFaviconResolutionError({ | |
| 587 | resource: input.resource, | |
| 588 | cause, | |
| 589 | }), | |
| 590 | ), | |
| 591 | ); | |
| 587 | 592 | const isExternalOverride = |
| 588 | 593 | faviconPath !== null && |
| 589 | 594 | input.projectFaviconPath !== undefined && |
apps/server/src/server.test.ts+81| ... | ... | @@ -8153,6 +8153,87 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8153 | 8153 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 8154 | 8154 | ); |
| 8155 | 8155 | |
| 8156 | it.effect("finds a cloned project's icon once the clone lands", () => | |
| 8157 | Effect.gen(function* () { | |
| 8158 | const fs = yield* FileSystem.FileSystem; | |
| 8159 | const path = yield* Path.Path; | |
| 8160 | const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-clone-favicon-" }); | |
| 8161 | const destinationPath = path.join(parentDir, "app"); | |
| 8162 | const projectId = ProjectId.make("project-clone-favicon"); | |
| 8163 | const cloneGate = yield* Deferred.make<void>(); | |
| 8164 | const metaUpdateDispatched = yield* Deferred.make<void>(); | |
| 8165 | ||
| 8166 | yield* buildAppUnderTest({ | |
| 8167 | layers: { | |
| 8168 | orchestrationEngine: { | |
| 8169 | dispatch: (command) => | |
| 8170 | (command.type === "project.meta.update" | |
| 8171 | ? Deferred.succeed(metaUpdateDispatched, undefined) | |
| 8172 | : Effect.void | |
| 8173 | ).pipe(Effect.as({ sequence: 1 })), | |
| 8174 | }, | |
| 8175 | projectionSnapshotQuery: { | |
| 8176 | getActiveProjectByWorkspaceRoot: (workspaceRoot) => | |
| 8177 | Effect.succeed( | |
| 8178 | workspaceRoot === destinationPath | |
| 8179 | ? Option.some({ | |
| 8180 | ...makeDefaultOrchestrationReadModel().projects[0]!, | |
| 8181 | id: projectId, | |
| 8182 | workspaceRoot, | |
| 8183 | }) | |
| 8184 | : Option.none(), | |
| 8185 | ), | |
| 8186 | }, | |
| 8187 | sourceControlRepositoryService: { | |
| 8188 | prepareClone: (input) => | |
| 8189 | Effect.succeed({ | |
| 8190 | destinationPath: input.destinationPath, | |
| 8191 | remoteUrl: input.remoteUrl ?? "", | |
| 8192 | cloneUrl: input.remoteUrl ?? "", | |
| 8193 | repository: null, | |
| 8194 | }), | |
| 8195 | cloneRepository: (input) => | |
| 8196 | Deferred.await(cloneGate).pipe( | |
| 8197 | Effect.andThen( | |
| 8198 | fs.writeFileString(path.join(input.destinationPath, "favicon.svg"), "<svg/>"), | |
| 8199 | ), | |
| 8200 | Effect.orDie, | |
| 8201 | Effect.as({ | |
| 8202 | cwd: input.destinationPath, | |
| 8203 | remoteUrl: input.remoteUrl ?? "", | |
| 8204 | repository: null, | |
| 8205 | }), | |
| 8206 | ), | |
| 8207 | }, | |
| 8208 | }, | |
| 8209 | }); | |
| 8210 | ||
| 8211 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 8212 | yield* Effect.scoped( | |
| 8213 | withWsRpcClient(wsUrl, (client) => | |
| 8214 | Effect.gen(function* () { | |
| 8215 | yield* client[WS_METHODS.projectCloneStart]({ | |
| 8216 | projectId, | |
| 8217 | title: "app", | |
| 8218 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 8219 | remoteUrl: "git@github.com:octocat/app.git", | |
| 8220 | destinationPath, | |
| 8221 | }); | |
| 8222 | const resource = { _tag: "project-favicon" as const, cwd: destinationPath }; | |
| 8223 | const duringClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); | |
| 8224 | assert.isTrue(duringClone.relativeUrl.endsWith("/project-favicon-missing")); | |
| 8225 | ||
| 8226 | yield* Deferred.succeed(cloneGate, undefined); | |
| 8227 | yield* Deferred.await(metaUpdateDispatched); | |
| 8228 | // The lookup during the clone must not leave a cached miss behind. | |
| 8229 | const afterClone = yield* client[WS_METHODS.assetsCreateUrl]({ resource }); | |
| 8230 | assert.equal(afterClone.sourcePath, "favicon.svg"); | |
| 8231 | }), | |
| 8232 | ), | |
| 8233 | ); | |
| 8234 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 8235 | ); | |
| 8236 | ||
| 8156 | 8237 | it.effect("records thread analytics only after a client command succeeds", () => |
| 8157 | 8238 | Effect.gen(function* () { |
| 8158 | 8239 | const effects: string[] = []; |
apps/server/src/ws.ts+7| ... | ... | @@ -3508,11 +3508,18 @@ const makeWsRpcLayer = ( |
| 3508 | 3508 | resource: input.resource, |
| 3509 | 3509 | }); |
| 3510 | 3510 | } |
| 3511 | // A cloned project exists before its files do. Clients ask again | |
| 3512 | // when the clone lands (see createProjectFaviconUrlAtomFamily). | |
| 3513 | const clone = yield* projectCloneTracker.get(project.value.id); | |
| 3511 | 3514 | return yield* issueAssetUrl({ |
| 3512 | 3515 | resource: input.resource, |
| 3513 | 3516 | ...(project.value.faviconPath |
| 3514 | 3517 | ? { projectFaviconPath: project.value.faviconPath } |
| 3515 | 3518 | : {}), |
| 3519 | projectCheckoutPending: | |
| 3520 | clone !== null && | |
| 3521 | clone.phase !== "done" && | |
| 3522 | clone.destinationPath === project.value.workspaceRoot, | |
| 3516 | 3523 | }); |
| 3517 | 3524 | } |
| 3518 | 3525 | const thread = yield* projectionSnapshotQuery |
apps/web/src/state/assets.ts+2| ... | ... | @@ -8,6 +8,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | 8 | import { projectFaviconCache } from "../assets/projectFaviconCache"; |
| 9 | 9 | import { isElectron } from "../env"; |
| 10 | 10 | import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; |
| 11 | import { environmentProjectClonesAtom } from "./projectClones"; | |
| 11 | 12 | import { environmentSession } from "./session"; |
| 12 | 13 | |
| 13 | 14 | const localMediaEnvironment = Atom.make((get) => { |
| ... | ... | @@ -31,4 +32,5 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 31 | 32 | imageCache: projectFaviconCache, |
| 32 | 33 | createUrl: assetEnvironment.createUrl, |
| 33 | 34 | preparedConnection: environmentSession.preparedConnectionValueAtom, |
| 35 | projectClones: environmentProjectClonesAtom, | |
| 34 | 36 | }); |
apps/web/src/state/projectClones.ts+1-1| ... | ... | @@ -16,7 +16,7 @@ const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe( |
| 16 | 16 | * Latest clone list an environment has streamed; empty until the subscription |
| 17 | 17 | * delivers, and never subscribed on servers that predate clone tracking. |
| 18 | 18 | */ |
| 19 | const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => | |
| 19 | export const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => | |
| 20 | 20 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { |
| 21 | 21 | const supported = |
| 22 | 22 | get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities |
packages/client-runtime/src/state/assets.test.ts+92| ... | ... | @@ -6,6 +6,8 @@ import { |
| 6 | 6 | AssetWorkspaceContextNotFoundError, |
| 7 | 7 | EnvironmentAuthorizationError, |
| 8 | 8 | EnvironmentId, |
| 9 | type ProjectCloneSnapshot, | |
| 10 | ProjectId, | |
| 9 | 11 | ThreadId, |
| 10 | 12 | WS_METHODS, |
| 11 | 13 | } from "@t3tools/contracts"; |
| ... | ... | @@ -409,4 +411,94 @@ describe("project favicon URL cache", () => { |
| 409 | 411 | registry.dispose(); |
| 410 | 412 | } |
| 411 | 413 | }); |
| 414 | ||
| 415 | const cloning: ProjectCloneSnapshot = { | |
| 416 | projectId: ProjectId.make("project-cloning"), | |
| 417 | remoteUrl: "git@github.com:octocat/app.git", | |
| 418 | destinationPath: "/workspace", | |
| 419 | repository: null, | |
| 420 | phase: "running", | |
| 421 | stage: "receiving", | |
| 422 | percent: 10, | |
| 423 | detail: null, | |
| 424 | error: null, | |
| 425 | startedAt: "2026-01-01T00:00:00.000Z", | |
| 426 | endedAt: null, | |
| 427 | sequence: 1, | |
| 428 | }; | |
| 429 | ||
| 430 | function mountClonedProjectFavicon(initialClones: ReadonlyArray<ProjectCloneSnapshot>) { | |
| 431 | const registry = AtomRegistry.make(); | |
| 432 | // Stands in for the server, which reports the icon missing until the clone lands. | |
| 433 | const server = { lookups: 0, landed: false }; | |
| 434 | const result = Atom.make(() => { | |
| 435 | server.lookups += 1; | |
| 436 | return AsyncResult.success({ | |
| 437 | expiresAt: 4_000_000_000_000, | |
| 438 | relativeUrl: server.landed | |
| 439 | ? "/api/assets/token-b/v1-icon.svg" | |
| 440 | : "/api/assets/token-a/project-favicon-missing", | |
| 441 | }); | |
| 442 | }); | |
| 443 | const clones = Atom.make(initialClones); | |
| 444 | const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); | |
| 445 | const favicon = createProjectFaviconUrlAtomFamily({ | |
| 446 | createUrl: () => result, | |
| 447 | preparedConnection: () => connection, | |
| 448 | projectClones: () => clones, | |
| 449 | })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); | |
| 450 | const unmount = registry.mount(favicon); | |
| 451 | return { | |
| 452 | registry, | |
| 453 | server, | |
| 454 | clones, | |
| 455 | favicon, | |
| 456 | dispose: () => { | |
| 457 | unmount(); | |
| 458 | registry.dispose(); | |
| 459 | }, | |
| 460 | }; | |
| 461 | } | |
| 462 | ||
| 463 | it("asks for a cloned project's icon again once its clone lands", () => { | |
| 464 | const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([cloning]); | |
| 465 | try { | |
| 466 | expect(registry.get(favicon)).toBe( | |
| 467 | "https://remote.test/api/assets/token-a/project-favicon-missing", | |
| 468 | ); | |
| 469 | // Progress, and another folder's clone landing, do not ask again. | |
| 470 | registry.set(clones, [ | |
| 471 | { ...cloning, percent: 80, sequence: 2 }, | |
| 472 | { | |
| 473 | ...cloning, | |
| 474 | projectId: ProjectId.make("project-other"), | |
| 475 | destinationPath: "/other", | |
| 476 | phase: "done", | |
| 477 | sequence: 3, | |
| 478 | }, | |
| 479 | ]); | |
| 480 | expect(server.lookups).toBe(1); | |
| 481 | ||
| 482 | server.landed = true; | |
| 483 | registry.set(clones, [{ ...cloning, phase: "done", sequence: 4 }]); | |
| 484 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); | |
| 485 | expect(server.lookups).toBe(2); | |
| 486 | } finally { | |
| 487 | dispose(); | |
| 488 | } | |
| 489 | }); | |
| 490 | ||
| 491 | it("asks again when the first clone list it sees already says done", () => { | |
| 492 | const { registry, server, clones, favicon, dispose } = mountClonedProjectFavicon([]); | |
| 493 | try { | |
| 494 | expect(registry.get(favicon)).toBe( | |
| 495 | "https://remote.test/api/assets/token-a/project-favicon-missing", | |
| 496 | ); | |
| 497 | server.landed = true; | |
| 498 | registry.set(clones, [{ ...cloning, phase: "done", sequence: 2 }]); | |
| 499 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-b/v1-icon.svg"); | |
| 500 | } finally { | |
| 501 | dispose(); | |
| 502 | } | |
| 503 | }); | |
| 412 | 504 | }); |
packages/client-runtime/src/state/assets.ts+21-1| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | type AssetImageDimensions, |
| 5 | 5 | AssetResource, |
| 6 | 6 | EnvironmentId, |
| 7 | type ProjectCloneSnapshot, | |
| 7 | 8 | WS_METHODS, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| ... | ... | @@ -181,14 +182,33 @@ export function createProjectFaviconUrlAtomFamily(input: { |
| 181 | 182 | readonly preparedConnection: ( |
| 182 | 183 | environmentId: EnvironmentId, |
| 183 | 184 | ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>; |
| 185 | /** The environment's tracked clones, empty when it reports none. */ | |
| 186 | readonly projectClones?: ( | |
| 187 | environmentId: EnvironmentId, | |
| 188 | ) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>; | |
| 184 | 189 | }) { |
| 185 | 190 | const decodeKey = Schema.decodeUnknownSync( |
| 186 | 191 | Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), |
| 187 | 192 | ); |
| 193 | const projectClones = input.projectClones; | |
| 188 | 194 | const family = Atom.family((key: string) => { |
| 189 | 195 | const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); |
| 190 | 196 | const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; |
| 191 | const request = input.createUrl({ environmentId, input: { resource } }); | |
| 197 | const query = input.createUrl({ environmentId, input: { resource } }); | |
| 198 | // A cloned project exists before its files do, and the server reports its | |
| 199 | // icon missing until the clone lands. Ask again whenever the clone's phase | |
| 200 | // changes: the first list a client sees may already say done. | |
| 201 | const request = projectClones | |
| 202 | ? query.pipe( | |
| 203 | Atom.makeRefreshOnSignal( | |
| 204 | Atom.make( | |
| 205 | (get) => | |
| 206 | get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd) | |
| 207 | ?.phase ?? null, | |
| 208 | ), | |
| 209 | ), | |
| 210 | ) | |
| 211 | : query; | |
| 192 | 212 | const resolvedUrl = Atom.make((get): string | null => { |
| 193 | 213 | const result = get(request); |
| 194 | 214 | const connection = get(input.preparedConnection(environmentId)); |