Skip to content
Closed
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
9 changes: 9 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ describe("RPC authorization scopes", () => {
});

it("requires write access to import agent session history", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsList)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsPreview)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsAttach)).toBe(
AuthOrchestrationOperateScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe(
AuthOrchestrationReadScope,
);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
[WS_METHODS.agentSessionsList]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsPreview]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsAttach]: AuthOrchestrationOperateScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
Expand Down
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 @@ -91,6 +91,7 @@ describe("CheckpointDiffQuery.layer", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () =>
Effect.sync(() => {
getThreadCheckpointContextCalls += 1;
Expand Down Expand Up @@ -205,6 +206,7 @@ describe("CheckpointDiffQuery.layer", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
Expand Down Expand Up @@ -294,6 +296,7 @@ describe("CheckpointDiffQuery.layer", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
Expand Down Expand Up @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadRuntimeContext: () => Effect.die("unused"),
Expand Down Expand Up @@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ describe("OrchestrationEngine", () => {
getProjectShellById: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
getImportedAgentSessionSources: () => Effect.die("unused"),
getThreadProjectIds: () => Effect.die("unused"),
getThreadCheckpointContext: () => Effect.succeed(Option.none()),
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadRuntimeContext: () => Effect.die("unused"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -909,11 +909,24 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
"None",
);
}
const candidateIds = [
...Array.from({ length: 501 }, (_, index) => ThreadId.make(`missing-${index}`)),
ThreadId.make("thread-active"),
ThreadId.make("thread-archived"),
];
assert.deepEqual(yield* snapshotQuery.getThreadProjectIds(candidateIds), [
{
threadId: ThreadId.make("thread-active"),
projectId: asProjectId("project-archive-test"),
},
]);
assert.deepEqual(yield* snapshotQuery.getThreadProjectIds([]), []);
yield* sql`UPDATE projection_threads SET deleted_at = '2026-04-06T00:00:08.000Z' WHERE thread_id = 'thread-active'`;
assert.equal(
(yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-active")))._tag,
"None",
);
assert.deepEqual(yield* snapshotQuery.getThreadProjectIds(candidateIds), []);
}),
);

Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

const getThreadProjectIdRows = SqlSchema.findAll({
Request: Schema.Array(ThreadId),
Result: Schema.Struct({ threadId: ThreadId, projectId: ProjectId }),
execute: (threadIds) => sql`
SELECT thread_id AS "threadId", project_id AS "projectId"
FROM projection_threads
WHERE ${sql.in("thread_id", threadIds)}
AND deleted_at IS NULL
AND archived_at IS NULL
`,
});

const getThreadRuntimeContextRow = SqlSchema.findOneOption({
Request: ThreadIdLookupInput,
Result: ProjectionThreadRuntimeContextDbRowSchema,
Expand Down Expand Up @@ -2930,6 +2942,24 @@ pending_approval_requests AS (
} satisfies OrchestrationThreadShell);
});

const getThreadProjectIds: ProjectionSnapshotQueryShape["getThreadProjectIds"] = (threadIds) =>
Effect.gen(function* () {
const rows: Array<{ threadId: ThreadId; projectId: ProjectId }> = [];
for (const batch of Arr.chunksOf(threadIds, 500)) {
rows.push(
...(yield* getThreadProjectIdRows(batch).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.getThreadProjectIds:query",
"ProjectionSnapshotQuery.getThreadProjectIds:decodeRows",
),
),
)),
);
}
return rows;
});

const getThreadRuntimeContext: ProjectionSnapshotQueryShape["getThreadRuntimeContext"] =
Effect.fn("ProjectionSnapshotQuery.getThreadRuntimeContext")(function* (threadId) {
const context = yield* getThreadRuntimeContextRow({ threadId }).pipe(
Expand Down Expand Up @@ -3413,6 +3443,7 @@ pending_approval_requests AS (
getThreadCheckpointContext,
getFullThreadDiffContext,
getThreadShellById,
getThreadProjectIds,
getThreadRuntimeContext,
getTurnStartMessage,
getThreadDetailById,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ export interface ProjectionSnapshotQueryShape {
threadId: ThreadId,
) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;

/** Read project ownership for active candidate threads without hydrating their shells. */
readonly getThreadProjectIds: (
threadIds: ReadonlyArray<ThreadId>,
) => Effect.Effect<
ReadonlyArray<{ readonly threadId: ThreadId; readonly projectId: ProjectId }>,
ProjectionRepositoryError
>;

/** Read the active thread and session facts used to ingest provider events. */
readonly getThreadRuntimeContext: (
threadId: ThreadId,
Expand Down
Loading
Loading