Files
PaperClipAI/server/src/services/workspace-runtime-read-model.ts
T
DottaandPaperclip b51112798f feat(apps): improve gateway and workspace connection UX (#12340)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections must work in both the operator UI and agent tool
gateway.
> - The first stack layer adds secure remote connections.
> - Operators still need clear setup, test, and recovery states.
> - This pull request adds the gateway behavior and the workspace
connection experience.
> - The benefit is a connection flow that is easier to understand and
recover.

## Linked Issues or Issue Description

Refs #11965

This is stack 2 of 11. It depends on stack 1 and replaces another
reviewable part of #11965.

## What Changed

- Improve remote tool gateway connection behavior.
- Add clearer app setup, test, and recovery states.
- Add focused server and UI tests for the new paths.
- Keep the diff isolated from later identity and catalog work.
- Stabilize DNS-pinned remote HTTP protocol fixtures and the
managed-runtime public-origin fixture for this independently tested
layer.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts` (150 passed)
- `pnpm test:run`
- `pnpm check:token-gates`
- `pnpm build`

## Risks

- Gateway errors now surface through new user-facing states.
- A stale connection can require a new setup attempt.
- The change does not add a database migration.
- The injected HTTP transport and public URL are test-only fixtures;
production DNS pinning and runtime behavior are unchanged.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-29 12:08:32 -05:00

150 lines
4.7 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import { workspaceRuntimeServices } from "@paperclipai/db";
import {
listWorkspaceServiceCommandDefinitions,
matchWorkspaceRuntimeServiceToCommand,
} from "@paperclipai/shared";
import { and, desc, eq, inArray } from "drizzle-orm";
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
type RuntimeServiceReadDb = Pick<Db, "select">;
function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) {
if (row.reuseKey) return row.reuseKey;
return [
row.scopeType,
row.scopeId ?? "",
row.projectWorkspaceId ?? "",
row.executionWorkspaceId ?? "",
row.serviceName,
row.command ?? "",
row.cwd ?? "",
].join(":");
}
export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) {
const current = new Map<string, WorkspaceRuntimeServiceRow>();
for (const row of rows) {
const identity = runtimeServiceIdentityKey(row);
if (!current.has(identity)) current.set(identity, row);
}
return [...current.values()];
}
export function selectConfiguredRuntimeServiceRows(
rows: WorkspaceRuntimeServiceRow[],
workspaceRuntime: Record<string, unknown> | null | undefined,
options?: {
fallbackScopeTypes?: WorkspaceRuntimeServiceRow["scopeType"][];
},
) {
const availableRows = selectCurrentRuntimeServiceRows(rows).map((row) => ({
...row,
configIndex: null as number | null,
}));
const selectedRows: Array<WorkspaceRuntimeServiceRow & { configIndex: number | null }> = [];
for (const command of listWorkspaceServiceCommandDefinitions(workspaceRuntime)) {
const reuseScope = command.rawConfig.reuseScope;
const expectedScope =
reuseScope === "project_workspace" ||
reuseScope === "execution_workspace" ||
reuseScope === "agent" ||
reuseScope === "run"
? reuseScope
: command.lifecycle === "shared"
? "project_workspace"
: "run";
const candidateScopes = [
expectedScope,
...(options?.fallbackScopeTypes ?? []).filter((scopeType) => scopeType !== expectedScope),
];
const matchedRow = candidateScopes.reduce<(typeof availableRows)[number] | null>(
(match, scopeType) =>
match
?? matchWorkspaceRuntimeServiceToCommand(
command,
availableRows.filter((row) => row.scopeType === scopeType),
),
null,
);
if (!matchedRow) continue;
selectedRows.push({
...matchedRow,
configIndex: command.serviceIndex,
});
availableRows.splice(availableRows.indexOf(matchedRow), 1);
}
return selectedRows;
}
export async function listCurrentRuntimeServicesForProjectWorkspaces(
db: RuntimeServiceReadDb,
companyId: string,
projectWorkspaceIds: string[],
) {
if (projectWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, companyId),
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
for (const row of rows) {
if (!row.projectWorkspaceId) continue;
const existing = grouped.get(row.projectWorkspaceId) ?? [];
existing.push(row);
grouped.set(row.projectWorkspaceId, existing);
}
return new Map(
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
workspaceId,
selectCurrentRuntimeServiceRows(workspaceRows),
]),
);
}
export async function listCurrentRuntimeServicesForExecutionWorkspaces(
db: RuntimeServiceReadDb,
companyId: string,
executionWorkspaceIds: string[],
) {
if (executionWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, companyId),
inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
const existing = grouped.get(row.executionWorkspaceId) ?? [];
existing.push(row);
grouped.set(row.executionWorkspaceId, existing);
}
return new Map(
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
workspaceId,
selectCurrentRuntimeServiceRows(workspaceRows),
]),
);
}