diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c9f514037804..45bc2778f0e0 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -291,6 +293,7 @@ describe("CheckpointDiffQuery.layer", () => { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -365,6 +368,7 @@ describe("CheckpointDiffQuery.layer", () => { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -424,6 +428,7 @@ describe("CheckpointDiffQuery.layer", () => { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6d062ebcab4b..7ea1d588acfb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -446,6 +446,7 @@ describe("OrchestrationEngine", () => { getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.succeed([]), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 36b9cec55601..7ffd333b2893 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -47,6 +47,58 @@ const encodeMessageContext = Schema.encodeEffect( Schema.fromJsonString(OrchestrationMessageContext), ); +it.effect("reads project shells without loading threads or resolving excluded projects", () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) + VALUES + ('p1', 'First', '/first', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL), + ('p2', 'Second', '/second', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL), + ('p3', 'Deleted', '/deleted', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', '2026-09-04T00:00:00Z')`; + const expected = (yield* query.getShellSnapshot()).projects; + resolved.length = 0; + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at) + VALUES ('t1', 'p1', 'Thread', 'invalid-json', 'full-access', 'default', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`; + + const counter = makeSqlStatementCounter(); + const projects = yield* query.getProjectShells().pipe(Effect.withTracer(counter.tracer)); + assert.deepStrictEqual(projects, expected); + assert.strictEqual(counter.count(), 1); + assert.deepStrictEqual(resolved.toSorted(), ["/first", "/second"]); + resolved.length = 0; + yield* sql`UPDATE projection_projects SET scripts_json = 'invalid-json' WHERE project_id IN ('p1', 'p3')`; + assert.deepStrictEqual(yield* query.getProjectShells([asProjectId("p2")]), [expected[1]!]); + assert.deepStrictEqual(resolved, ["/second"]); + resolved.length = 0; + const beforeEmpty = counter.count(); + assert.deepStrictEqual( + yield* query.getProjectShells([]).pipe(Effect.withTracer(counter.tracer)), + [], + ); + assert.strictEqual(counter.count(), beforeEmpty); + assert.deepStrictEqual(yield* query.getProjectShells([asProjectId("p3")]), []); + assert.deepStrictEqual(resolved, []); + }).pipe(Effect.provide(layer)); +}); + const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2d2727b90a95..eaad35d2e067 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -521,9 +521,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listProjectRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: Schema.UndefinedOr( + Schema.Struct({ + activeOnly: Schema.Boolean, + projectIds: Schema.optional(Schema.Array(ProjectId)), + }), + ), Result: ProjectionProjectDbRowSchema, - execute: () => + execute: (filter) => sql` SELECT project_id AS "projectId", @@ -539,6 +544,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt", deleted_at AS "deletedAt" FROM projection_projects + WHERE ${filter?.activeOnly === true ? sql`deleted_at IS NULL` : sql`1 = 1`} + AND ${filter?.projectIds === undefined ? sql`1 = 1` : sql.in("project_id", filter.projectIds)} ORDER BY created_at ASC, project_id ASC `, }); @@ -2947,6 +2954,25 @@ pending_approval_requests AS ( ), ); + const getProjectShells: ProjectionSnapshotQueryShape["getProjectShells"] = (projectIds) => { + if (projectIds?.length === 0) return Effect.succeed([]); + return listProjectRows({ activeOnly: true, projectIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectShells:query", + "ProjectionSnapshotQuery.getProjectShells:decodeRows", + ), + ), + Effect.flatMap((projects) => + resolveRepositoryIdentitiesForProjects(projects).pipe( + Effect.map((identities) => + projects.map((row) => mapProjectShellRow(row, identities.get(row.projectId) ?? null)), + ), + ), + ), + ); + }; + const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => getActiveProjectRowById({ projectId }).pipe( Effect.mapError( @@ -3659,6 +3685,7 @@ pending_approval_requests AS ( getEventReplayStats, getActiveProjectByWorkspaceRoot, getProjectShellById, + getProjectShells, getFirstActiveThreadIdByProjectId, getImportedAgentSessionSources, getThreadCheckpointContext, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index fda0ac04556e..03d1e5f83c33 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -166,6 +166,10 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + readonly getProjectShells: ( + projectIds?: ReadonlyArray, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the earliest active thread for a project. */ diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index c797eab6f550..41c6c8a961c0 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -51,6 +51,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.succeed([]), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3a8c3ad71e69..fc582ef35516 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Effect.succeed( workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), ), + getProjectShells: () => Effect.die("unused"), getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1d86e6d943a8..b68f8f219954 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4928,6 +4928,7 @@ describe("agent browser access", () => { getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 2538e05e37dc..a8036c426a94 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -243,6 +243,7 @@ describe("ProviderSessionReaper", () => { getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.die("unused"), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 3117c0072a78..66257a6c3af9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -193,13 +193,12 @@ function makeService(input: { input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), }), Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Effect.succeed({ - snapshotSequence: 1, - projects: input.projects, - threads: [], - updatedAt: "2026-07-01T00:00:00Z", - }), + getProjectShells: (projectIds) => + Effect.succeed( + input.projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), + getProjectShellById: (projectId) => + Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), }), SourceControlRateLimit.layer, Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 37716da44005..241a9e8b1836 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -603,7 +603,10 @@ export const make = Effect.gen(function* () { const listWorkspaceProjects = ( filter: Pick, ): Effect.Effect => - projections.getShellSnapshot().pipe( + (filter.projectId === undefined + ? projections.getProjectShells(filter.projectIds) + : projections.getProjectShellById(filter.projectId).pipe(Effect.map(Option.toArray)) + ).pipe( Effect.mapError( (error) => new PullRequestOperationError({ @@ -612,12 +615,12 @@ export const make = Effect.gen(function* () { cause: error, }), ), - Effect.flatMap((snapshot) => - refineUnknownProjectKinds(snapshot.projects, filter).pipe( - Effect.map((refinedKinds) => ({ refinedKinds, snapshot })), + Effect.flatMap((projects) => + refineUnknownProjectKinds(projects, filter).pipe( + Effect.map((refinedKinds) => ({ refinedKinds, projects })), ), ), - Effect.map(({ refinedKinds, snapshot }) => { + Effect.map(({ refinedKinds, projects }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -625,7 +628,7 @@ export const make = Effect.gen(function* () { >(); const viewerRoots = new Map(); const seen = new Set(); - for (const project of snapshot.projects) { + for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 60c93c2426cb..158606e95b19 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -187,6 +187,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa deletedAt: null, }), ), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -314,6 +315,7 @@ it.effect.each([ }) : Option.none(), ), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -384,6 +386,7 @@ it.effect( getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -446,6 +449,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"),