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
8 changes: 8 additions & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import {
} from "@posthog/core/speech/identifiers";
import { resolveService } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
HOST_CAPABILITIES,
type HostCapabilities,
} from "@posthog/platform/host-capabilities";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
Expand Down Expand Up @@ -441,3 +445,7 @@ container
.inSingletonScope();

container.bind(SETUP_STORE).toConstantValue(setupStore);

container
.bind(HOST_CAPABILITIES)
.toConstantValue({ localWorkspaces: true } satisfies HostCapabilities);
5 changes: 5 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ import {
HOST_TRPC_CLIENT,
type HostTrpcClient,
} from "@posthog/host-router/client";
import {
HOST_CAPABILITIES,
type HostCapabilities,
} from "@posthog/platform/host-capabilities";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
Expand Down Expand Up @@ -338,6 +342,7 @@ export interface RendererBindings {
[FEATURE_FLAGS]: FeatureFlags;
[AUTH_SIDE_EFFECTS]: IAuthSideEffects;
[SETUP_STORE]: ISetupStore;
[HOST_CAPABILITIES]: HostCapabilities;

// --- desktop-contributions.ts ---
[CONTRIBUTION]: Contribution;
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import { Providers } from "@components/Providers";
import { DevToolbarHost } from "@features/dev-toolbar/DevToolbarHost";
import { preloadHighlighter } from "@pierre/diffs";
import { boot } from "@posthog/di/contribution";
import { assertHostCapabilities } from "@posthog/di/hostCapabilities";
import { ServiceProvider } from "@posthog/di/react";
import App from "@posthog/ui/shell/App";
import { logger } from "@posthog/ui/shell/logger";
import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl";
import { REQUIRED_HOST_CAPABILITIES } from "@posthog/ui/shell/requiredHostCapabilities";
import { registerDesktopContributions } from "@renderer/desktop-contributions";
import { container } from "@renderer/di/container";
import "@renderer/desktop-services";
Expand Down Expand Up @@ -95,6 +97,11 @@ const root = ReactDOM.createRoot(rootElement);

try {
registerDesktopContributions();
// Fail loudly (into BootErrorScreen) if a capability the shared app resolves
// via service location is unbound, rather than deferring to the first
// navigation that needs it. The renderer container backs every useService, so
// all required tokens must resolve here. Shared with the web host.
assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES);
boot(container).catch((error: unknown) => {
bootLog.error("Renderer boot sequence failed", error);
// Replaces the mounted tree without running effect cleanup; acceptable
Expand Down
58 changes: 58 additions & 0 deletions packages/di/src/hostCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { assertHostCapabilities } from "./hostCapabilities";

const TOKEN_A = Symbol.for("posthog.test.capabilityA");
const TOKEN_B = Symbol.for("posthog.test.capabilityB");

function containerBinding(bound: symbol[]): {
isBound: (t: symbol) => boolean;
} {
const set = new Set(bound);
return { isBound: (t) => set.has(t) };
}

describe("assertHostCapabilities", () => {
it("passes when every required token is bound", () => {
const container = containerBinding([TOKEN_A, TOKEN_B]);
expect(() =>
assertHostCapabilities(container, [
{ token: TOKEN_A, description: "A" },
{ token: TOKEN_B, description: "B" },
]),
).not.toThrow();
});

it("passes when there are no requirements", () => {
expect(() =>
assertHostCapabilities(containerBinding([]), []),
).not.toThrow();
});

it("throws listing every missing token and its description", () => {
const container = containerBinding([TOKEN_A]);
expect(() =>
assertHostCapabilities(container, [
{ token: TOKEN_A, description: "A is fine" },
{ token: TOKEN_B, description: "B drives cloud runs" },
]),
).toThrowError(/missing 1 required capability/);
});

it("reports all missing tokens at once, not just the first", () => {
const container = containerBinding([]);
let message = "";
try {
assertHostCapabilities(container, [
{ token: TOKEN_A, description: "A drives X" },
{ token: TOKEN_B, description: "B drives Y" },
]);
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain("missing 2 required capability");
expect(message).toContain("A drives X");
expect(message).toContain("B drives Y");
expect(message).toContain(String(TOKEN_A));
expect(message).toContain(String(TOKEN_B));
});
});
46 changes: 46 additions & 0 deletions packages/di/src/hostCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ServiceIdentifier } from "inversify";
import type { ServiceContainer } from "./container";

/**
* A DI token that shared `@posthog/ui` / `@posthog/core` resolves via service
* location (`useService` / `resolveService`) and that every host mounting the
* shared app must therefore bind in its composition root.
*/
export interface HostCapabilityRequirement {
/** The token the shared app resolves at runtime. */
readonly token: ServiceIdentifier;
/** What breaks when it's missing — surfaced in the error message. */
readonly description: string;
}

/**
* Throw if the host forgot to bind a capability the shared app resolves at
* runtime.
*
* These gaps are invisible to the compiler: `useService<T>(TOKEN)`'s type
* argument is supplied by the caller, not derived from any host's binding map,
* so a `TypedContainer<HostBindings>` only checks the *provider* side. A missing
* binding otherwise surfaces only when a user reaches the code path that
* resolves the token (as happened with the inbox `reportModelResolver` on web).
*
* Call this synchronously at the end of each composition root, once every
* binding is registered, so a broken container fails to start instead of
* limping to the first unlucky navigation.
*/
export function assertHostCapabilities(
container: Pick<ServiceContainer, "isBound">,
requirements: readonly HostCapabilityRequirement[],
): void {
const missing = requirements.filter((r) => !container.isBound(r.token));
if (missing.length === 0) {
return;
}

const lines = missing.map((r) => ` - ${String(r.token)}: ${r.description}`);
throw new Error(
`Host container is missing ${missing.length} required capability ` +
`binding(s) that shared UI/core resolves at runtime:\n${lines.join("\n")}\n` +
"Bind them in this host's composition root. See REQUIRED_HOST_CAPABILITIES " +
"in @posthog/ui/shell/requiredHostCapabilities.",
);
}
4 changes: 4 additions & 0 deletions packages/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"description": "Host-agnostic platform port interfaces. Zero runtime deps; implemented by per-host adapters (Electron, Node server, React Native, web).",
"type": "module",
"exports": {
"./host-capabilities": {
"types": "./dist/host-capabilities.d.ts",
"import": "./dist/host-capabilities.js"
},
"./url-launcher": {
"types": "./dist/url-launcher.d.ts",
"import": "./dist/url-launcher.js"
Expand Down
18 changes: 18 additions & 0 deletions packages/platform/src/host-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Coarse host capabilities the shared UI branches on. Kept deliberately small:
* a capability belongs here only when the UI must render differently by host
* (not merely bind a different adapter). Hosts bind a constant value.
*/
export interface HostCapabilities {
/**
* Whether the host can access the local filesystem — local repository
* folders, git worktrees, and a terminal. Desktop (Electron) has it; the
* cloud-only browser host does not, so the UI falls back to remote
* (connected-GitHub-org) repositories and cloud workspaces.
*/
readonly localWorkspaces: boolean;
}

export const HOST_CAPABILITIES = Symbol.for(
"posthog.platform.hostCapabilities",
);
1 change: 1 addition & 0 deletions packages/platform/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from "tsup";

export default defineConfig({
entry: [
"src/host-capabilities.ts",
"src/url-launcher.ts",
"src/storage-paths.ts",
"src/app-meta.ts",
Expand Down
6 changes: 5 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,11 @@ export type {
SkillSource,
UploadableSkillSource,
} from "./skills";
export { SKILL_EXISTS_MARKER, stripFrontmatter } from "./skills";
export {
SKILL_EXISTS_MARKER,
serializeSkillMarkdown,
stripFrontmatter,
} from "./skills";
export type {
ArtifactType,
PostHogAPIConfig,
Expand Down
40 changes: 40 additions & 0 deletions packages/shared/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@ export interface ExportedSkill {
files: ExportedSkillFile[];
}

/**
* Serializes a SKILL.md file from frontmatter metadata plus a markdown body.
*
* The output must round-trip through `parseSkillFrontmatter` and also be valid
* YAML for the agents that consume these files, so scalars fall back from plain
* → double-quoted → literal block as they get more hostile. Lives here (shared)
* so both the workspace-server bundler and the web-host bundler produce the
* exact same SKILL.md — this is a serialization contract consumed by the cloud
* sandbox, so it must not drift between hosts.
*/
export function serializeSkillMarkdown(
meta: { name: string; description: string },
body: string,
): string {
const frontmatter = [
"---",
`name: ${serializeSkillScalar(meta.name)}`,
`description: ${serializeSkillScalar(meta.description)}`,
"---",
].join("\n");

const trimmedBody = body.replace(/^\n+/, "");
return `${frontmatter}\n\n${trimmedBody.trimEnd()}\n`;
}

const SKILL_PLAIN_SAFE = /^[A-Za-z0-9][A-Za-z0-9 _.,;()/-]*$/;

function serializeSkillScalar(value: string): string {
if (value === "") return '""';
if (!value.includes("\n")) {
if (SKILL_PLAIN_SAFE.test(value) && !value.endsWith(" ")) return value;
if (!value.includes('"') && !value.includes("\\")) return `"${value}"`;
}
// Literal block: survives quotes, backslashes, and newlines.
const lines = value
.split("\n")
.map((line) => (line.trim() ? ` ${line}` : ""));
return `|-\n${lines.join("\n")}`;
}

/**
* Server "skill already exists" messages must include this marker verbatim;
* the UI keys its overwrite-confirmation flow on it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@posthog/ui/features/sessions/localHandoffService";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useHostCapabilities } from "../../../shell/useHostCapabilities";
import { useFeatureFlag } from "../../feature-flags/useFeatureFlag";
import { DirtyTreeDialog } from "../../sessions/components/DirtyTreeDialog";
import { HandoffConfirmDialog } from "../../sessions/components/HandoffConfirmDialog";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function CloudGitInteractionHeader({
GIT_CACHE_KEY_PROVIDER,
);
const localHandoff = useService<LocalHandoffService>(LOCAL_HANDOFF_SERVICE);
const { localWorkspaces } = useHostCapabilities();
const cloudHandoffEnabled =
useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV;

Expand Down Expand Up @@ -106,7 +108,8 @@ export function CloudGitInteractionHeader({
await localHandoff.afterCommit();
};

if (!cloudHandoffEnabled) return null;
// "Continue locally" hands the task off to a local checkout
if (!cloudHandoffEnabled || !localWorkspaces) return null;
if (task.origin_product === "image_builder") return null;

const inProgress = session?.handoffInProgress ?? false;
Expand Down
47 changes: 35 additions & 12 deletions packages/ui/src/features/onboarding/components/SelectRepoStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
} from "@phosphor-icons/react";
import { repoMatchesGitHubRepos } from "@posthog/core/onboarding/repoProvider";
import { cn } from "@posthog/quill";
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
import { Box, Button, Flex, Text } from "@radix-ui/themes";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import { builderHog } from "../../../assets/hedgehogs";
import { OnboardingHogTip } from "../../../primitives/OnboardingHogTip";
import { FolderPicker } from "../../folder-picker/FolderPicker";
import { GitHubRepoPicker } from "../../folder-picker/GitHubRepoPicker";
import { useUserRepositoryIntegration } from "../../integrations/useIntegrations";
import type { DetectedRepo } from "../types";
import { OptionalBadge } from "./OptionalBadge";
Expand All @@ -37,7 +39,13 @@ export function SelectRepoStep({
isDetectingRepo,
onDirectoryChange,
}: SelectRepoStepProps) {
const { repositories } = useUserRepositoryIntegration();
const { localWorkspaces } = useHostCapabilities();
const {
repositories,
isLoadingRepos,
isRefreshingRepos,
refreshRepositories,
} = useUserRepositoryIntegration();

const repoMatchesGitHub = useMemo(
() => repoMatchesGitHubRepos(detectedRepo, repositories),
Expand Down Expand Up @@ -96,18 +104,31 @@ export function SelectRepoStep({
</Text>
</Flex>
<Text className="text-(--gray-11) text-sm">
Select a single repository folder, not a parent folder
that contains multiple repos.
{localWorkspaces
? "Select a single repository folder, not a parent folder that contains multiple repos."
: "Pick a repository from your connected GitHub organizations."}
</Text>
</Flex>
<FolderPicker
variant="field"
value={selectedDirectory}
onChange={onDirectoryChange}
placeholder="Select repository..."
/>
{localWorkspaces ? (
<FolderPicker
variant="field"
value={selectedDirectory}
onChange={onDirectoryChange}
placeholder="Select repository..."
/>
) : (
<GitHubRepoPicker
value={selectedDirectory || null}
onChange={(repo) => onDirectoryChange(repo ?? "")}
repositories={repositories}
isLoading={isLoadingRepos}
onRefresh={refreshRepositories}
isRefreshing={isRefreshingRepos}
placeholder="Select repository..."
/>
)}
<AnimatePresence mode="wait">
{isDetectingRepo && (
{localWorkspaces && isDetectingRepo && (
<motion.div
key="detecting"
initial={{ opacity: 0 }}
Expand All @@ -126,7 +147,8 @@ export function SelectRepoStep({
</Flex>
</motion.div>
)}
{!isDetectingRepo &&
{localWorkspaces &&
!isDetectingRepo &&
selectedDirectory &&
detectedRepo && (
<motion.div
Expand Down Expand Up @@ -161,7 +183,8 @@ export function SelectRepoStep({
</Flex>
</motion.div>
)}
{!isDetectingRepo &&
{localWorkspaces &&
!isDetectingRepo &&
selectedDirectory &&
!detectedRepo && (
<motion.div
Expand Down
Loading
Loading