Skip to content

Commit e338415

Browse files
authored
feat(platform): host capabilities seam + boot-time binding guard (#3446)
1 parent 6dd0274 commit e338415

23 files changed

Lines changed: 552 additions & 155 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ import {
5050
} from "@posthog/core/speech/identifiers";
5151
import { resolveService } from "@posthog/di/container";
5252
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
53+
import {
54+
HOST_CAPABILITIES,
55+
type HostCapabilities,
56+
} from "@posthog/platform/host-capabilities";
5357
import {
5458
type INotifications,
5559
NOTIFICATIONS_SERVICE,
@@ -441,3 +445,7 @@ container
441445
.inSingletonScope();
442446

443447
container.bind(SETUP_STORE).toConstantValue(setupStore);
448+
449+
container
450+
.bind(HOST_CAPABILITIES)
451+
.toConstantValue({ localWorkspaces: true } satisfies HostCapabilities);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ import {
139139
HOST_TRPC_CLIENT,
140140
type HostTrpcClient,
141141
} from "@posthog/host-router/client";
142+
import {
143+
HOST_CAPABILITIES,
144+
type HostCapabilities,
145+
} from "@posthog/platform/host-capabilities";
142146
import {
143147
type INotifications,
144148
NOTIFICATIONS_SERVICE,
@@ -338,6 +342,7 @@ export interface RendererBindings {
338342
[FEATURE_FLAGS]: FeatureFlags;
339343
[AUTH_SIDE_EFFECTS]: IAuthSideEffects;
340344
[SETUP_STORE]: ISetupStore;
345+
[HOST_CAPABILITIES]: HostCapabilities;
341346

342347
// --- desktop-contributions.ts ---
343348
[CONTRIBUTION]: Contribution;

apps/code/src/renderer/main.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import { Providers } from "@components/Providers";
1919
import { DevToolbarHost } from "@features/dev-toolbar/DevToolbarHost";
2020
import { preloadHighlighter } from "@pierre/diffs";
2121
import { boot } from "@posthog/di/contribution";
22+
import { assertHostCapabilities } from "@posthog/di/hostCapabilities";
2223
import { ServiceProvider } from "@posthog/di/react";
2324
import App from "@posthog/ui/shell/App";
2425
import { logger } from "@posthog/ui/shell/logger";
2526
import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl";
27+
import { REQUIRED_HOST_CAPABILITIES } from "@posthog/ui/shell/requiredHostCapabilities";
2628
import { registerDesktopContributions } from "@renderer/desktop-contributions";
2729
import { container } from "@renderer/di/container";
2830
import "@renderer/desktop-services";
@@ -95,6 +97,11 @@ const root = ReactDOM.createRoot(rootElement);
9597

9698
try {
9799
registerDesktopContributions();
100+
// Fail loudly (into BootErrorScreen) if a capability the shared app resolves
101+
// via service location is unbound, rather than deferring to the first
102+
// navigation that needs it. The renderer container backs every useService, so
103+
// all required tokens must resolve here. Shared with the web host.
104+
assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES);
98105
boot(container).catch((error: unknown) => {
99106
bootLog.error("Renderer boot sequence failed", error);
100107
// Replaces the mounted tree without running effect cleanup; acceptable
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from "vitest";
2+
import { assertHostCapabilities } from "./hostCapabilities";
3+
4+
const TOKEN_A = Symbol.for("posthog.test.capabilityA");
5+
const TOKEN_B = Symbol.for("posthog.test.capabilityB");
6+
7+
function containerBinding(bound: symbol[]): {
8+
isBound: (t: symbol) => boolean;
9+
} {
10+
const set = new Set(bound);
11+
return { isBound: (t) => set.has(t) };
12+
}
13+
14+
describe("assertHostCapabilities", () => {
15+
it("passes when every required token is bound", () => {
16+
const container = containerBinding([TOKEN_A, TOKEN_B]);
17+
expect(() =>
18+
assertHostCapabilities(container, [
19+
{ token: TOKEN_A, description: "A" },
20+
{ token: TOKEN_B, description: "B" },
21+
]),
22+
).not.toThrow();
23+
});
24+
25+
it("passes when there are no requirements", () => {
26+
expect(() =>
27+
assertHostCapabilities(containerBinding([]), []),
28+
).not.toThrow();
29+
});
30+
31+
it("throws listing every missing token and its description", () => {
32+
const container = containerBinding([TOKEN_A]);
33+
expect(() =>
34+
assertHostCapabilities(container, [
35+
{ token: TOKEN_A, description: "A is fine" },
36+
{ token: TOKEN_B, description: "B drives cloud runs" },
37+
]),
38+
).toThrowError(/missing 1 required capability/);
39+
});
40+
41+
it("reports all missing tokens at once, not just the first", () => {
42+
const container = containerBinding([]);
43+
let message = "";
44+
try {
45+
assertHostCapabilities(container, [
46+
{ token: TOKEN_A, description: "A drives X" },
47+
{ token: TOKEN_B, description: "B drives Y" },
48+
]);
49+
} catch (error) {
50+
message = (error as Error).message;
51+
}
52+
expect(message).toContain("missing 2 required capability");
53+
expect(message).toContain("A drives X");
54+
expect(message).toContain("B drives Y");
55+
expect(message).toContain(String(TOKEN_A));
56+
expect(message).toContain(String(TOKEN_B));
57+
});
58+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ServiceIdentifier } from "inversify";
2+
import type { ServiceContainer } from "./container";
3+
4+
/**
5+
* A DI token that shared `@posthog/ui` / `@posthog/core` resolves via service
6+
* location (`useService` / `resolveService`) and that every host mounting the
7+
* shared app must therefore bind in its composition root.
8+
*/
9+
export interface HostCapabilityRequirement {
10+
/** The token the shared app resolves at runtime. */
11+
readonly token: ServiceIdentifier;
12+
/** What breaks when it's missing — surfaced in the error message. */
13+
readonly description: string;
14+
}
15+
16+
/**
17+
* Throw if the host forgot to bind a capability the shared app resolves at
18+
* runtime.
19+
*
20+
* These gaps are invisible to the compiler: `useService<T>(TOKEN)`'s type
21+
* argument is supplied by the caller, not derived from any host's binding map,
22+
* so a `TypedContainer<HostBindings>` only checks the *provider* side. A missing
23+
* binding otherwise surfaces only when a user reaches the code path that
24+
* resolves the token (as happened with the inbox `reportModelResolver` on web).
25+
*
26+
* Call this synchronously at the end of each composition root, once every
27+
* binding is registered, so a broken container fails to start instead of
28+
* limping to the first unlucky navigation.
29+
*/
30+
export function assertHostCapabilities(
31+
container: Pick<ServiceContainer, "isBound">,
32+
requirements: readonly HostCapabilityRequirement[],
33+
): void {
34+
const missing = requirements.filter((r) => !container.isBound(r.token));
35+
if (missing.length === 0) {
36+
return;
37+
}
38+
39+
const lines = missing.map((r) => ` - ${String(r.token)}: ${r.description}`);
40+
throw new Error(
41+
`Host container is missing ${missing.length} required capability ` +
42+
`binding(s) that shared UI/core resolves at runtime:\n${lines.join("\n")}\n` +
43+
"Bind them in this host's composition root. See REQUIRED_HOST_CAPABILITIES " +
44+
"in @posthog/ui/shell/requiredHostCapabilities.",
45+
);
46+
}

packages/platform/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"description": "Host-agnostic platform port interfaces. Zero runtime deps; implemented by per-host adapters (Electron, Node server, React Native, web).",
55
"type": "module",
66
"exports": {
7+
"./host-capabilities": {
8+
"types": "./dist/host-capabilities.d.ts",
9+
"import": "./dist/host-capabilities.js"
10+
},
711
"./url-launcher": {
812
"types": "./dist/url-launcher.d.ts",
913
"import": "./dist/url-launcher.js"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Coarse host capabilities the shared UI branches on. Kept deliberately small:
3+
* a capability belongs here only when the UI must render differently by host
4+
* (not merely bind a different adapter). Hosts bind a constant value.
5+
*/
6+
export interface HostCapabilities {
7+
/**
8+
* Whether the host can access the local filesystem — local repository
9+
* folders, git worktrees, and a terminal. Desktop (Electron) has it; the
10+
* cloud-only browser host does not, so the UI falls back to remote
11+
* (connected-GitHub-org) repositories and cloud workspaces.
12+
*/
13+
readonly localWorkspaces: boolean;
14+
}
15+
16+
export const HOST_CAPABILITIES = Symbol.for(
17+
"posthog.platform.hostCapabilities",
18+
);

packages/platform/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineConfig } from "tsup";
22

33
export default defineConfig({
44
entry: [
5+
"src/host-capabilities.ts",
56
"src/url-launcher.ts",
67
"src/storage-paths.ts",
78
"src/app-meta.ts",

packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@posthog/ui/features/sessions/localHandoffService";
1010
import { useQueryClient } from "@tanstack/react-query";
1111
import { useState } from "react";
12+
import { useHostCapabilities } from "../../../shell/useHostCapabilities";
1213
import { useFeatureFlag } from "../../feature-flags/useFeatureFlag";
1314
import { DirtyTreeDialog } from "../../sessions/components/DirtyTreeDialog";
1415
import { HandoffConfirmDialog } from "../../sessions/components/HandoffConfirmDialog";
@@ -40,6 +41,7 @@ export function CloudGitInteractionHeader({
4041
GIT_CACHE_KEY_PROVIDER,
4142
);
4243
const localHandoff = useService<LocalHandoffService>(LOCAL_HANDOFF_SERVICE);
44+
const { localWorkspaces } = useHostCapabilities();
4345
const cloudHandoffEnabled =
4446
useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV;
4547

@@ -106,7 +108,8 @@ export function CloudGitInteractionHeader({
106108
await localHandoff.afterCommit();
107109
};
108110

109-
if (!cloudHandoffEnabled) return null;
111+
// "Continue locally" hands the task off to a local checkout
112+
if (!cloudHandoffEnabled || !localWorkspaces) return null;
110113
if (task.origin_product === "image_builder") return null;
111114

112115
const inProgress = session?.handoffInProgress ?? false;

packages/ui/src/features/onboarding/components/SelectRepoStep.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
} from "@phosphor-icons/react";
99
import { repoMatchesGitHubRepos } from "@posthog/core/onboarding/repoProvider";
1010
import { cn } from "@posthog/quill";
11+
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
1112
import { Box, Button, Flex, Text } from "@radix-ui/themes";
1213
import { AnimatePresence, motion } from "framer-motion";
1314
import { useMemo } from "react";
1415
import { builderHog } from "../../../assets/hedgehogs";
1516
import { OnboardingHogTip } from "../../../primitives/OnboardingHogTip";
1617
import { FolderPicker } from "../../folder-picker/FolderPicker";
18+
import { GitHubRepoPicker } from "../../folder-picker/GitHubRepoPicker";
1719
import { useUserRepositoryIntegration } from "../../integrations/useIntegrations";
1820
import type { DetectedRepo } from "../types";
1921
import { OptionalBadge } from "./OptionalBadge";
@@ -37,7 +39,13 @@ export function SelectRepoStep({
3739
isDetectingRepo,
3840
onDirectoryChange,
3941
}: SelectRepoStepProps) {
40-
const { repositories } = useUserRepositoryIntegration();
42+
const { localWorkspaces } = useHostCapabilities();
43+
const {
44+
repositories,
45+
isLoadingRepos,
46+
isRefreshingRepos,
47+
refreshRepositories,
48+
} = useUserRepositoryIntegration();
4149

4250
const repoMatchesGitHub = useMemo(
4351
() => repoMatchesGitHubRepos(detectedRepo, repositories),
@@ -96,18 +104,31 @@ export function SelectRepoStep({
96104
</Text>
97105
</Flex>
98106
<Text className="text-(--gray-11) text-sm">
99-
Select a single repository folder, not a parent folder
100-
that contains multiple repos.
107+
{localWorkspaces
108+
? "Select a single repository folder, not a parent folder that contains multiple repos."
109+
: "Pick a repository from your connected GitHub organizations."}
101110
</Text>
102111
</Flex>
103-
<FolderPicker
104-
variant="field"
105-
value={selectedDirectory}
106-
onChange={onDirectoryChange}
107-
placeholder="Select repository..."
108-
/>
112+
{localWorkspaces ? (
113+
<FolderPicker
114+
variant="field"
115+
value={selectedDirectory}
116+
onChange={onDirectoryChange}
117+
placeholder="Select repository..."
118+
/>
119+
) : (
120+
<GitHubRepoPicker
121+
value={selectedDirectory || null}
122+
onChange={(repo) => onDirectoryChange(repo ?? "")}
123+
repositories={repositories}
124+
isLoading={isLoadingRepos}
125+
onRefresh={refreshRepositories}
126+
isRefreshing={isRefreshingRepos}
127+
placeholder="Select repository..."
128+
/>
129+
)}
109130
<AnimatePresence mode="wait">
110-
{isDetectingRepo && (
131+
{localWorkspaces && isDetectingRepo && (
111132
<motion.div
112133
key="detecting"
113134
initial={{ opacity: 0 }}
@@ -126,7 +147,8 @@ export function SelectRepoStep({
126147
</Flex>
127148
</motion.div>
128149
)}
129-
{!isDetectingRepo &&
150+
{localWorkspaces &&
151+
!isDetectingRepo &&
130152
selectedDirectory &&
131153
detectedRepo && (
132154
<motion.div
@@ -161,7 +183,8 @@ export function SelectRepoStep({
161183
</Flex>
162184
</motion.div>
163185
)}
164-
{!isDetectingRepo &&
186+
{localWorkspaces &&
187+
!isDetectingRepo &&
165188
selectedDirectory &&
166189
!detectedRepo && (
167190
<motion.div

0 commit comments

Comments
 (0)