Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 29 additions & 2 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
`,
});
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -3659,6 +3685,7 @@ pending_approval_requests AS (
getEventReplayStats,
getActiveProjectByWorkspaceRoot,
getProjectShellById,
getProjectShells,
getFirstActiveThreadIdByProjectId,
getImportedAgentSessionSources,
getThreadCheckpointContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export interface ProjectionSnapshotQueryShape {
projectId: ProjectId,
) => Effect.Effect<Option.Option<OrchestrationProjectShell>, ProjectionRepositoryError>;

readonly getProjectShells: (
projectIds?: ReadonlyArray<ProjectId>,
) => Effect.Effect<ReadonlyArray<OrchestrationProjectShell>, ProjectionRepositoryError>;

/**
* Read the earliest active thread for a project.
*/
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
13 changes: 6 additions & 7 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,10 @@ export const make = Effect.gen(function* () {
const listWorkspaceProjects = (
filter: Pick<PullRequestListInput, "projectId" | "projectIds" | "host">,
): Effect.Effect<WorkspaceProjects, PullRequestError> =>
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({
Expand All @@ -612,20 +615,20 @@ 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,
{ kind: SourceControlProviderKind; projectCount: number }
>();
const viewerRoots = new Map<string, string[]>();
const seen = new Set<string>();
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;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
Loading