| author | |
| committer | |
| log | b1e223e2b0d87124883b1410ab52dd6a1338e40d |
| tree | 6c715b608c2b2dc61deb3f3e91907c516fe8dd54 |
| parent | 57b23a09f9dff15d57e3fa288c111381259a9a12 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 114 insertions(+), 15 deletions(-)
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5| ... | ... | @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 88 | 88 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 89 | 89 | getEventReplayStats: () => Effect.die("unused"), |
| 90 | 90 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 91 | getProjectShells: () => Effect.die("unused"), | |
| 91 | 92 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 92 | 93 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 93 | 94 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 202 | 203 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 203 | 204 | getEventReplayStats: () => Effect.die("unused"), |
| 204 | 205 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 206 | getProjectShells: () => Effect.die("unused"), | |
| 205 | 207 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 206 | 208 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 207 | 209 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -291,6 +293,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 291 | 293 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 292 | 294 | getEventReplayStats: () => Effect.die("unused"), |
| 293 | 295 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 296 | getProjectShells: () => Effect.die("unused"), | |
| 294 | 297 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 295 | 298 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 296 | 299 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -365,6 +368,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 365 | 368 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 366 | 369 | getEventReplayStats: () => Effect.die("unused"), |
| 367 | 370 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 371 | getProjectShells: () => Effect.die("unused"), | |
| 368 | 372 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 369 | 373 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 370 | 374 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -424,6 +428,7 @@ describe("CheckpointDiffQuery.layer", () => { |
| 424 | 428 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 425 | 429 | getEventReplayStats: () => Effect.die("unused"), |
| 426 | 430 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 431 | getProjectShells: () => Effect.die("unused"), | |
| 427 | 432 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 428 | 433 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 429 | 434 | getImportedAgentSessionSources: () => Effect.die("unused"), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1| ... | ... | @@ -446,6 +446,7 @@ describe("OrchestrationEngine", () => { |
| 446 | 446 | getEventReplayStats: () => Effect.die("unused"), |
| 447 | 447 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 448 | 448 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 449 | getProjectShells: () => Effect.succeed([]), | |
| 449 | 450 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 450 | 451 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| 451 | 452 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+52| ... | ... | @@ -47,6 +47,58 @@ const encodeMessageContext = Schema.encodeEffect( |
| 47 | 47 | Schema.fromJsonString(OrchestrationMessageContext), |
| 48 | 48 | ); |
| 49 | 49 | |
| 50 | it.effect("reads project shells without loading threads or resolving excluded projects", () => { | |
| 51 | const resolved: string[] = []; | |
| 52 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( | |
| 53 | Layer.provide(ThreadBackgroundLiveness.layer), | |
| 54 | Layer.provide(ThreadPlanProgress.layer), | |
| 55 | Layer.provide( | |
| 56 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { | |
| 57 | resolve: (root) => | |
| 58 | Effect.sync(() => { | |
| 59 | resolved.push(root); | |
| 60 | return null; | |
| 61 | }), | |
| 62 | }), | |
| 63 | ), | |
| 64 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 65 | ); | |
| 66 | return Effect.gen(function* () { | |
| 67 | const sql = yield* SqlClient.SqlClient; | |
| 68 | const query = yield* ProjectionSnapshotQuery; | |
| 69 | yield* sql`INSERT INTO projection_projects | |
| 70 | (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) | |
| 71 | VALUES | |
| 72 | ('p1', 'First', '/first', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL), | |
| 73 | ('p2', 'Second', '/second', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL), | |
| 74 | ('p3', 'Deleted', '/deleted', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', '2026-09-04T00:00:00Z')`; | |
| 75 | const expected = (yield* query.getShellSnapshot()).projects; | |
| 76 | resolved.length = 0; | |
| 77 | yield* sql`INSERT INTO projection_threads | |
| 78 | (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at) | |
| 79 | VALUES ('t1', 'p1', 'Thread', 'invalid-json', 'full-access', 'default', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`; | |
| 80 | ||
| 81 | const counter = makeSqlStatementCounter(); | |
| 82 | const projects = yield* query.getProjectShells().pipe(Effect.withTracer(counter.tracer)); | |
| 83 | assert.deepStrictEqual(projects, expected); | |
| 84 | assert.strictEqual(counter.count(), 1); | |
| 85 | assert.deepStrictEqual(resolved.toSorted(), ["/first", "/second"]); | |
| 86 | resolved.length = 0; | |
| 87 | yield* sql`UPDATE projection_projects SET scripts_json = 'invalid-json' WHERE project_id IN ('p1', 'p3')`; | |
| 88 | assert.deepStrictEqual(yield* query.getProjectShells([asProjectId("p2")]), [expected[1]!]); | |
| 89 | assert.deepStrictEqual(resolved, ["/second"]); | |
| 90 | resolved.length = 0; | |
| 91 | const beforeEmpty = counter.count(); | |
| 92 | assert.deepStrictEqual( | |
| 93 | yield* query.getProjectShells([]).pipe(Effect.withTracer(counter.tracer)), | |
| 94 | [], | |
| 95 | ); | |
| 96 | assert.strictEqual(counter.count(), beforeEmpty); | |
| 97 | assert.deepStrictEqual(yield* query.getProjectShells([asProjectId("p3")]), []); | |
| 98 | assert.deepStrictEqual(resolved, []); | |
| 99 | }).pipe(Effect.provide(layer)); | |
| 100 | }); | |
| 101 | ||
| 50 | 102 | const projectionSnapshotLayer = it.layer( |
| 51 | 103 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| 52 | 104 | Layer.provide(ThreadBackgroundLiveness.layer), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+29-2| ... | ... | @@ -521,9 +521,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 521 | 521 | }); |
| 522 | 522 | |
| 523 | 523 | const listProjectRows = SqlSchema.findAll({ |
| 524 | Request: Schema.Void, | |
| 524 | Request: Schema.UndefinedOr( | |
| 525 | Schema.Struct({ | |
| 526 | activeOnly: Schema.Boolean, | |
| 527 | projectIds: Schema.optional(Schema.Array(ProjectId)), | |
| 528 | }), | |
| 529 | ), | |
| 525 | 530 | Result: ProjectionProjectDbRowSchema, |
| 526 | execute: () => | |
| 531 | execute: (filter) => | |
| 527 | 532 | sql` |
| 528 | 533 | SELECT |
| 529 | 534 | project_id AS "projectId", |
| ... | ... | @@ -539,6 +544,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 539 | 544 | updated_at AS "updatedAt", |
| 540 | 545 | deleted_at AS "deletedAt" |
| 541 | 546 | FROM projection_projects |
| 547 | WHERE ${filter?.activeOnly === true ? sql`deleted_at IS NULL` : sql`1 = 1`} | |
| 548 | AND ${filter?.projectIds === undefined ? sql`1 = 1` : sql.in("project_id", filter.projectIds)} | |
| 542 | 549 | ORDER BY created_at ASC, project_id ASC |
| 543 | 550 | `, |
| 544 | 551 | }); |
| ... | ... | @@ -2947,6 +2954,25 @@ pending_approval_requests AS ( |
| 2947 | 2954 | ), |
| 2948 | 2955 | ); |
| 2949 | 2956 | |
| 2957 | const getProjectShells: ProjectionSnapshotQueryShape["getProjectShells"] = (projectIds) => { | |
| 2958 | if (projectIds?.length === 0) return Effect.succeed([]); | |
| 2959 | return listProjectRows({ activeOnly: true, projectIds }).pipe( | |
| 2960 | Effect.mapError( | |
| 2961 | toPersistenceSqlOrDecodeError( | |
| 2962 | "ProjectionSnapshotQuery.getProjectShells:query", | |
| 2963 | "ProjectionSnapshotQuery.getProjectShells:decodeRows", | |
| 2964 | ), | |
| 2965 | ), | |
| 2966 | Effect.flatMap((projects) => | |
| 2967 | resolveRepositoryIdentitiesForProjects(projects).pipe( | |
| 2968 | Effect.map((identities) => | |
| 2969 | projects.map((row) => mapProjectShellRow(row, identities.get(row.projectId) ?? null)), | |
| 2970 | ), | |
| 2971 | ), | |
| 2972 | ), | |
| 2973 | ); | |
| 2974 | }; | |
| 2975 | ||
| 2950 | 2976 | const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => |
| 2951 | 2977 | getActiveProjectRowById({ projectId }).pipe( |
| 2952 | 2978 | Effect.mapError( |
| ... | ... | @@ -3659,6 +3685,7 @@ pending_approval_requests AS ( |
| 3659 | 3685 | getEventReplayStats, |
| 3660 | 3686 | getActiveProjectByWorkspaceRoot, |
| 3661 | 3687 | getProjectShellById, |
| 3688 | getProjectShells, | |
| 3662 | 3689 | getFirstActiveThreadIdByProjectId, |
| 3663 | 3690 | getImportedAgentSessionSources, |
| 3664 | 3691 | getThreadCheckpointContext, |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+4| ... | ... | @@ -166,6 +166,10 @@ export interface ProjectionSnapshotQueryShape { |
| 166 | 166 | projectId: ProjectId, |
| 167 | 167 | ) => Effect.Effect<Option.Option<OrchestrationProjectShell>, ProjectionRepositoryError>; |
| 168 | 168 | |
| 169 | readonly getProjectShells: ( | |
| 170 | projectIds?: ReadonlyArray<ProjectId>, | |
| 171 | ) => Effect.Effect<ReadonlyArray<OrchestrationProjectShell>, ProjectionRepositoryError>; | |
| 172 | ||
| 169 | 173 | /** |
| 170 | 174 | * Read the earliest active thread for a project. |
| 171 | 175 | */ |
apps/server/src/project/AgentSessionScanner.test.ts+1| ... | ... | @@ -51,6 +51,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< |
| 51 | 51 | getCounts: () => Effect.die("unused"), |
| 52 | 52 | getEventReplayStats: () => Effect.die("unused"), |
| 53 | 53 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), |
| 54 | getProjectShells: () => Effect.die("unused"), | |
| 54 | 55 | getProjectShellById: () => Effect.die("unused"), |
| 55 | 56 | getImportedAgentSessionSources: () => Effect.succeed([]), |
| 56 | 57 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1| ... | ... | @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => |
| 39 | 39 | Effect.succeed( |
| 40 | 40 | workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), |
| 41 | 41 | ), |
| 42 | getProjectShells: () => Effect.die("unused"), | |
| 42 | 43 | getProjectShellById: (projectId) => |
| 43 | 44 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), |
| 44 | 45 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
apps/server/src/provider/Layers/ProviderService.test.ts+1| ... | ... | @@ -4928,6 +4928,7 @@ describe("agent browser access", () => { |
| 4928 | 4928 | getCounts: () => Effect.die("unused"), |
| 4929 | 4929 | getEventReplayStats: () => Effect.die("unused"), |
| 4930 | 4930 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), |
| 4931 | getProjectShells: () => Effect.die("unused"), | |
| 4931 | 4932 | getProjectShellById: () => Effect.die("unused"), |
| 4932 | 4933 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 4933 | 4934 | getThreadCheckpointContext: () => Effect.die("unused"), |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -243,6 +243,7 @@ describe("ProviderSessionReaper", () => { |
| 243 | 243 | getCounts: () => Effect.die("unused"), |
| 244 | 244 | getEventReplayStats: () => Effect.die("unused"), |
| 245 | 245 | getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), |
| 246 | getProjectShells: () => Effect.die("unused"), | |
| 246 | 247 | getProjectShellById: () => Effect.die("unused"), |
| 247 | 248 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), |
| 248 | 249 | getImportedAgentSessionSources: () => Effect.die("unused"), |
apps/server/src/pullRequest/PullRequestService.test.ts+6-7| ... | ... | @@ -193,13 +193,12 @@ function makeService(input: { |
| 193 | 193 | input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), |
| 194 | 194 | }), |
| 195 | 195 | Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ |
| 196 | getShellSnapshot: () => | |
| 197 | Effect.succeed({ | |
| 198 | snapshotSequence: 1, | |
| 199 | projects: input.projects, | |
| 200 | threads: [], | |
| 201 | updatedAt: "2026-07-01T00:00:00Z", | |
| 202 | }), | |
| 196 | getProjectShells: (projectIds) => | |
| 197 | Effect.succeed( | |
| 198 | input.projects.filter((project) => projectIds?.includes(project.id) ?? true), | |
| 199 | ), | |
| 200 | getProjectShellById: (projectId) => | |
| 201 | Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), | |
| 203 | 202 | }), |
| 204 | 203 | SourceControlRateLimit.layer, |
| 205 | 204 | Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( |
apps/server/src/pullRequest/PullRequestService.ts+9-6| ... | ... | @@ -603,7 +603,10 @@ export const make = Effect.gen(function* () { |
| 603 | 603 | const listWorkspaceProjects = ( |
| 604 | 604 | filter: Pick<PullRequestListInput, "projectId" | "projectIds" | "host">, |
| 605 | 605 | ): Effect.Effect<WorkspaceProjects, PullRequestError> => |
| 606 | projections.getShellSnapshot().pipe( | |
| 606 | (filter.projectId === undefined | |
| 607 | ? projections.getProjectShells(filter.projectIds) | |
| 608 | : projections.getProjectShellById(filter.projectId).pipe(Effect.map(Option.toArray)) | |
| 609 | ).pipe( | |
| 607 | 610 | Effect.mapError( |
| 608 | 611 | (error) => |
| 609 | 612 | new PullRequestOperationError({ |
| ... | ... | @@ -612,12 +615,12 @@ export const make = Effect.gen(function* () { |
| 612 | 615 | cause: error, |
| 613 | 616 | }), |
| 614 | 617 | ), |
| 615 | Effect.flatMap((snapshot) => | |
| 616 | refineUnknownProjectKinds(snapshot.projects, filter).pipe( | |
| 617 | Effect.map((refinedKinds) => ({ refinedKinds, snapshot })), | |
| 618 | Effect.flatMap((projects) => | |
| 619 | refineUnknownProjectKinds(projects, filter).pipe( | |
| 620 | Effect.map((refinedKinds) => ({ refinedKinds, projects })), | |
| 618 | 621 | ), |
| 619 | 622 | ), |
| 620 | Effect.map(({ refinedKinds, snapshot }) => { | |
| 623 | Effect.map(({ refinedKinds, projects }) => { | |
| 621 | 624 | const supported: SupportedProject[] = []; |
| 622 | 625 | const unimplemented = new Map< |
| 623 | 626 | string, |
| ... | ... | @@ -625,7 +628,7 @@ export const make = Effect.gen(function* () { |
| 625 | 628 | >(); |
| 626 | 629 | const viewerRoots = new Map<string, string[]>(); |
| 627 | 630 | const seen = new Set<string>(); |
| 628 | for (const project of snapshot.projects) { | |
| 631 | for (const project of projects) { | |
| 629 | 632 | if (filter.projectId !== undefined && project.id !== filter.projectId) continue; |
| 630 | 633 | if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; |
| 631 | 634 | const identity = project.repositoryIdentity; |
apps/server/src/serverRuntimeStartup.test.ts+4| ... | ... | @@ -187,6 +187,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 187 | 187 | deletedAt: null, |
| 188 | 188 | }), |
| 189 | 189 | ), |
| 190 | getProjectShells: () => Effect.die("unused"), | |
| 190 | 191 | getProjectShellById: () => Effect.die("unused"), |
| 191 | 192 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), |
| 192 | 193 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -314,6 +315,7 @@ it.effect.each([ |
| 314 | 315 | }) |
| 315 | 316 | : Option.none(), |
| 316 | 317 | ), |
| 318 | getProjectShells: () => Effect.die("unused"), | |
| 317 | 319 | getProjectShellById: () => Effect.die("unused"), |
| 318 | 320 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 319 | 321 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -384,6 +386,7 @@ it.effect( |
| 384 | 386 | getCounts: () => Effect.die("unused"), |
| 385 | 387 | getEventReplayStats: () => Effect.die("unused"), |
| 386 | 388 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 389 | getProjectShells: () => Effect.die("unused"), | |
| 387 | 390 | getProjectShellById: () => Effect.die("unused"), |
| 388 | 391 | getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), |
| 389 | 392 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -446,6 +449,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 446 | 449 | getCounts: () => Effect.die("unused"), |
| 447 | 450 | getEventReplayStats: () => Effect.die("unused"), |
| 448 | 451 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 452 | getProjectShells: () => Effect.die("unused"), | |
| 449 | 453 | getProjectShellById: () => Effect.die("unused"), |
| 450 | 454 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 451 | 455 | getImportedAgentSessionSources: () => Effect.die("unused"), |