Skip to content

Commit 2a1f155

Browse files
authored
Merge branch 'main' into posthog-code/fix-sidebar-peek-projectswitcher
2 parents e3783de + 9beff6e commit 2a1f155

143 files changed

Lines changed: 8029 additions & 1049 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/src/main/di/bindings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
import type {
1212
CLOUD_TASK_AUTH,
1313
ICloudTaskAuth,
14+
MCP_RELAY_EXECUTOR,
15+
McpRelayExecutor,
1416
} from "@posthog/core/cloud-task/identifiers";
1517
import type {
1618
CONTEXT_MENU_EXTERNAL_APPS_SERVICE,
@@ -180,6 +182,10 @@ import type {
180182
} from "@posthog/workspace-server/services/local-logs/identifiers";
181183
import type { MCP_PROXY_AUTH } from "@posthog/workspace-server/services/mcp-proxy/identifiers";
182184
import type { McpProxyAuth } from "@posthog/workspace-server/services/mcp-proxy/ports";
185+
import type {
186+
MCP_RELAY_SERVICE,
187+
McpRelayService,
188+
} from "@posthog/workspace-server/services/mcp-relay/identifiers";
183189
import type { PosthogPluginService } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin";
184190
import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking";
185191
import type {
@@ -358,9 +364,11 @@ export interface MainBindings {
358364
[MAIN_AUTH_SERVICE]: AuthService;
359365
[AUTH_SERVICE]: AuthService;
360366

361-
// Auth proxy / mcp proxy
367+
// Auth proxy / mcp proxy / mcp relay
362368
[AUTH_PROXY_AUTH]: AuthProxyAuth;
363369
[MCP_PROXY_AUTH]: McpProxyAuth;
370+
[MCP_RELAY_SERVICE]: McpRelayService;
371+
[MCP_RELAY_EXECUTOR]: McpRelayExecutor;
364372

365373
// Archive / suspension host ports
366374
[ARCHIVE_SESSION_CANCELLER]: SessionCanceller;

apps/code/src/main/di/container.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module";
2323
import {
2424
CLOUD_TASK_AUTH,
2525
CLOUD_TASK_SERVICE,
26+
MCP_RELAY_EXECUTOR,
2627
} from "@posthog/core/cloud-task/identifiers";
2728
import { contextMenuCoreModule } from "@posthog/core/context-menu/context-menu.module";
2829
import {
@@ -177,9 +178,12 @@ import {
177178
import type { HandoffGitGateway } from "@posthog/workspace-server/services/handoff/ports";
178179
import { HandoffHostService } from "@posthog/workspace-server/services/handoff/service";
179180
import { LOGS_SERVICE } from "@posthog/workspace-server/services/local-logs/identifiers";
181+
import { localMcpModule } from "@posthog/workspace-server/services/local-mcp/local-mcp.module";
180182
import { mcpCallbackModule } from "@posthog/workspace-server/services/mcp-callback/mcp-callback.module";
181183
import { MCP_PROXY_AUTH } from "@posthog/workspace-server/services/mcp-proxy/identifiers";
182184
import { mcpProxyModule } from "@posthog/workspace-server/services/mcp-proxy/mcp-proxy.module";
185+
import { MCP_RELAY_SERVICE } from "@posthog/workspace-server/services/mcp-relay/identifiers";
186+
import { mcpRelayModule } from "@posthog/workspace-server/services/mcp-relay/mcp-relay.module";
183187
import { OAUTH_CALLBACK_SERVER } from "@posthog/workspace-server/services/oauth-callback/identifiers";
184188
import { oauthCallbackModule } from "@posthog/workspace-server/services/oauth-callback/oauth-callback.module";
185189
import { onboardingImportModule } from "@posthog/workspace-server/services/onboarding-import/onboarding-import.module";
@@ -620,6 +624,15 @@ container.load(skillsModule);
620624
container.load(skillsMarketplaceModule);
621625
container.load(githubReleasesModule);
622626
container.load(onboardingImportModule);
627+
container.load(localMcpModule);
628+
container.load(mcpRelayModule);
629+
// Core's cloud-task service executes MCP relay requests through this seam;
630+
// the workspace relay service satisfies the core executor interface
631+
// structurally (docs/cloud-mcp-relay.md).
632+
container
633+
.bind(MCP_RELAY_EXECUTOR)
634+
.toDynamicValue((ctx) => ctx.get(MCP_RELAY_SERVICE))
635+
.inSingletonScope();
623636
container.load(claudeCliSessionsModule);
624637
container.load(additionalDirectoriesModule);
625638
container.bind(MAIN_SLEEP_SERVICE).to(SleepService);

apps/code/src/main/trpc/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import { githubReleasesRouter } from "@posthog/host-router/routers/github-releas
2828
import { handoffRouter } from "@posthog/host-router/routers/handoff.router";
2929
import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router";
3030
import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router";
31+
import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router";
3132
import { logsRouter } from "@posthog/host-router/routers/logs.router";
3233
import { mcpAppsRouter } from "@posthog/host-router/routers/mcp-apps.router";
3334
import { mcpCallbackRouter } from "@posthog/host-router/routers/mcp-callback.router";
35+
import { mcpRelayRouter } from "@posthog/host-router/routers/mcp-relay.router";
3436
import { notificationRouter } from "@posthog/host-router/routers/notification.router";
3537
import { oauthRouter } from "@posthog/host-router/routers/oauth.router";
3638
import { onboardingImportRouter } from "@posthog/host-router/routers/onboarding-import.router";
@@ -86,8 +88,10 @@ export const trpcRouter = router({
8688
handoff: handoffRouter,
8789
linearIntegration: linearIntegrationRouter,
8890
llmGateway: llmGatewayRouter,
91+
localMcp: localMcpRouter,
8992
mcpApps: mcpAppsRouter,
9093
mcpCallback: mcpCallbackRouter,
94+
mcpRelay: mcpRelayRouter,
9195
notification: notificationRouter,
9296
oauth: oauthRouter,
9397
onboardingImport: onboardingImportRouter,

apps/code/src/renderer/desktop-contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.
33
import { billingCoreModule } from "@posthog/core/billing/billing.module";
44
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
55
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
6+
import { localMcpCoreModule } from "@posthog/core/local-mcp/local-mcp.module";
67
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
78
import { setupCoreModule } from "@posthog/core/setup/setup.module";
89
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
@@ -44,6 +45,7 @@ export function registerDesktopContributions(): void {
4445
focusUiModule,
4546
githubConnectModule,
4647
inboxCoreModule,
48+
localMcpCoreModule,
4749
notificationsUiModule,
4850
onboardingModule,
4951
provisioningUiModule,

apps/code/src/renderer/di/bindings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ import {
6161
import type { RepositoriesService } from "@posthog/core/integrations/repositoriesService";
6262
import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers";
6363
import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway";
64+
import {
65+
LOCAL_MCP_IMPORT_SERVICE,
66+
LOCAL_MCP_WORKSPACE_CLIENT,
67+
} from "@posthog/core/local-mcp/identifiers";
68+
import type {
69+
LocalMcpImportService,
70+
LocalMcpWorkspaceClient,
71+
} from "@posthog/core/local-mcp/localMcpImport";
6472
import {
6573
GITHUB_CONNECT_CLIENT,
6674
type GithubConnectClient,
@@ -305,6 +313,8 @@ export interface RendererBindings {
305313
[CODE_REVIEW_WORKSPACE_CLIENT]: CodeReviewWorkspaceClient;
306314
[REVERT_HUNK_SERVICE]: RevertHunkService;
307315
[SKILLS_WORKSPACE_CLIENT]: SkillsWorkspaceClient;
316+
[LOCAL_MCP_WORKSPACE_CLIENT]: LocalMcpWorkspaceClient;
317+
[LOCAL_MCP_IMPORT_SERVICE]: LocalMcpImportService;
308318
[CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL]: BundleLocalSkill;
309319
[CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES]: ResolveSkillBundleDependencies;
310320
[CLOUD_ARTIFACT_READ_FILE_AS_BASE64]: ReadFileAsBase64;

apps/code/src/renderer/di/container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers";
3131
import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway";
3232
import type { LlmMessage } from "@posthog/core/llm-gateway/schemas";
33+
import { LOCAL_MCP_WORKSPACE_CLIENT } from "@posthog/core/local-mcp/identifiers";
34+
import type { LocalMcpWorkspaceClient } from "@posthog/core/local-mcp/localMcpImport";
3335
import {
3436
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
3537
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
@@ -388,6 +390,12 @@ container.bind(CODE_REVIEW_WORKSPACE_CLIENT).toConstantValue({
388390
} satisfies CodeReviewWorkspaceClient);
389391
container.bind(REVERT_HUNK_SERVICE).to(RevertHunkService).inSingletonScope();
390392

393+
// local MCP servers (~/.claude.json), read for cloud-import classification
394+
container.bind(LOCAL_MCP_WORKSPACE_CLIENT).toConstantValue({
395+
listLocalMcpServers: (cwd?: string) =>
396+
trpcClient.localMcp.list.query({ cwd }),
397+
} satisfies LocalMcpWorkspaceClient);
398+
391399
// skills (team publish/install reach workspace-server through this slice)
392400
container.bind(SKILLS_WORKSPACE_CLIENT).toConstantValue({
393401
exportSkill: (skillPath: string) =>

apps/mobile/src/app/task/[id].tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
1515
import { FloatingBackButton } from "@/components/FloatingBackButton";
1616
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
1717
import { getTask, runTaskInCloud } from "@/features/tasks/api";
18+
import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge";
1819
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
1920
import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge";
2021
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
@@ -667,14 +668,17 @@ export default function TaskDetailScreen() {
667668
title={showLoading ? "Loading..." : task?.title || "Task"}
668669
subtitle={task?.repository ?? undefined}
669670
rightSlot={
670-
canStopRun ? (
671-
<StopRunButton onPress={handleStopRun} />
672-
) : prUrl ? (
673-
<>
674-
<PrDiffStatsBadge prUrl={prUrl} />
675-
<PrStatusBadge prUrl={prUrl} />
676-
</>
677-
) : null
671+
<>
672+
{task ? <CustomImageBadge task={task} /> : null}
673+
{canStopRun ? (
674+
<StopRunButton onPress={handleStopRun} />
675+
) : prUrl ? (
676+
<>
677+
<PrDiffStatsBadge prUrl={prUrl} />
678+
<PrStatusBadge prUrl={prUrl} />
679+
</>
680+
) : null}
681+
</>
678682
}
679683
/>
680684
<Animated.View className="flex-1" style={contentPosition}>

apps/mobile/src/features/tasks/api.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { Adapter } from "@posthog/shared";
2+
import type {
3+
SandboxCustomImage,
4+
SandboxEnvironment,
5+
} from "@posthog/shared/domain-types";
26
import { fetch } from "expo/fetch";
37
import {
48
authedFetch,
@@ -779,6 +783,50 @@ export async function streamCloudTask(
779783
});
780784
}
781785

786+
export async function getSandboxCustomImages(): Promise<SandboxCustomImage[]> {
787+
const baseUrl = getBaseUrl();
788+
const projectId = getProjectId();
789+
790+
const response = await authedFetch(
791+
`${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`,
792+
);
793+
794+
if (!response.ok) {
795+
throw new HttpError(
796+
response.status,
797+
response.statusText,
798+
"Failed to fetch sandbox custom images",
799+
);
800+
}
801+
802+
const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>(
803+
response,
804+
);
805+
return data.results ?? [];
806+
}
807+
808+
export async function getSandboxEnvironments(): Promise<SandboxEnvironment[]> {
809+
const baseUrl = getBaseUrl();
810+
const projectId = getProjectId();
811+
812+
const response = await authedFetch(
813+
`${baseUrl}/api/projects/${projectId}/sandbox_environments/`,
814+
);
815+
816+
if (!response.ok) {
817+
throw new HttpError(
818+
response.status,
819+
response.statusText,
820+
"Failed to fetch sandbox environments",
821+
);
822+
}
823+
824+
const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>(
825+
response,
826+
);
827+
return data.results ?? [];
828+
}
829+
782830
export async function getIntegrations(): Promise<Integration[]> {
783831
const baseUrl = getBaseUrl();
784832
const projectId = getProjectId();
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { createElement } from "react";
3+
import { act, create } from "react-test-renderer";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { Task, TaskRun } from "../types";
6+
7+
const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted(
8+
() => ({
9+
mockUseAuthStore: vi.fn(),
10+
mockGetImages: vi.fn(),
11+
mockGetEnvironments: vi.fn(),
12+
}),
13+
);
14+
15+
vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore }));
16+
17+
vi.mock("../api", () => ({
18+
getSandboxCustomImages: mockGetImages,
19+
getSandboxEnvironments: mockGetEnvironments,
20+
}));
21+
22+
vi.mock("phosphor-react-native", () => ({
23+
Cube: (props: Record<string, unknown>) => createElement("Cube", props),
24+
}));
25+
26+
vi.mock("@/lib/theme", () => ({
27+
toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
28+
}));
29+
30+
import { CustomImageBadge } from "./CustomImageBadge";
31+
32+
function makeTask(run: Partial<TaskRun> | undefined): Task {
33+
return {
34+
id: "task-1",
35+
task_number: 1,
36+
slug: "task-1",
37+
title: "Task",
38+
description: "",
39+
created_at: "2026-01-01T00:00:00Z",
40+
updated_at: "2026-01-01T00:00:00Z",
41+
origin_product: "user_created",
42+
latest_run: run
43+
? ({
44+
id: "run-1",
45+
task: "task-1",
46+
team: 1,
47+
branch: null,
48+
status: "completed",
49+
log_url: "",
50+
error_message: null,
51+
output: null,
52+
state: {},
53+
created_at: "2026-01-01T00:00:00Z",
54+
updated_at: "2026-01-01T00:00:00Z",
55+
completed_at: null,
56+
...run,
57+
} as TaskRun)
58+
: undefined,
59+
};
60+
}
61+
62+
async function render(task: Task) {
63+
const queryClient = new QueryClient({
64+
defaultOptions: { queries: { retry: false, gcTime: 0 } },
65+
});
66+
let renderer: ReturnType<typeof create> | null = null;
67+
await act(async () => {
68+
renderer = create(
69+
createElement(
70+
QueryClientProvider,
71+
{ client: queryClient },
72+
createElement(CustomImageBadge, { task }),
73+
),
74+
);
75+
});
76+
// Flush pending react-query resolutions so a resolved image name renders.
77+
await act(async () => {
78+
await Promise.resolve();
79+
await new Promise((resolve) => setTimeout(resolve, 0));
80+
});
81+
if (!renderer) throw new Error("Renderer not created");
82+
return renderer as ReturnType<typeof create>;
83+
}
84+
85+
function label(renderer: ReturnType<typeof create>): string | undefined {
86+
const node = renderer.root.findAll(
87+
(n) => typeof n.props?.accessibilityLabel === "string",
88+
)[0];
89+
return node?.props.accessibilityLabel as string | undefined;
90+
}
91+
92+
describe("CustomImageBadge", () => {
93+
beforeEach(() => {
94+
mockUseAuthStore.mockReturnValue({
95+
projectId: 1,
96+
oauthAccessToken: "token",
97+
});
98+
mockGetImages.mockReset();
99+
mockGetEnvironments.mockReset();
100+
mockGetImages.mockResolvedValue([]);
101+
mockGetEnvironments.mockResolvedValue([]);
102+
});
103+
104+
it("renders nothing for a local run", async () => {
105+
const r = await render(
106+
makeTask({ environment: "local", state: { custom_image_id: "img-1" } }),
107+
);
108+
expect(r.toJSON()).toBeNull();
109+
expect(mockGetImages).not.toHaveBeenCalled();
110+
});
111+
112+
it("renders nothing for a cloud run without a custom image id", async () => {
113+
const r = await render(makeTask({ environment: "cloud", state: {} }));
114+
expect(r.toJSON()).toBeNull();
115+
expect(mockGetImages).not.toHaveBeenCalled();
116+
});
117+
118+
it("resolves the image name from a direct custom_image_id", async () => {
119+
mockGetImages.mockResolvedValue([{ id: "img-1", name: "My Image" }]);
120+
const r = await render(
121+
makeTask({ environment: "cloud", state: { custom_image_id: "img-1" } }),
122+
);
123+
expect(label(r)).toBe('Runs on custom base image "My Image"');
124+
});
125+
126+
it("resolves the image name via sandbox_environment_id", async () => {
127+
mockGetEnvironments.mockResolvedValue([
128+
{ id: "env-1", custom_image_id: "img-2", custom_image_name: null },
129+
]);
130+
mockGetImages.mockResolvedValue([{ id: "img-2", name: "Env Image" }]);
131+
const r = await render(
132+
makeTask({
133+
environment: "cloud",
134+
state: { sandbox_environment_id: "env-1" },
135+
}),
136+
);
137+
expect(label(r)).toBe('Runs on custom base image "Env Image"');
138+
});
139+
140+
it("renders nothing when the image cannot be resolved", async () => {
141+
mockGetImages.mockResolvedValue([{ id: "other", name: "Other" }]);
142+
const r = await render(
143+
makeTask({ environment: "cloud", state: { custom_image_id: "missing" } }),
144+
);
145+
expect(r.toJSON()).toBeNull();
146+
});
147+
});

0 commit comments

Comments
 (0)